Compare commits
3 Commits
07af758f9a
...
db7411b1bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db7411b1bf | ||
|
|
65c61e7886 | ||
|
|
b66424de12 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,6 +35,7 @@ systemd/
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
|
||||
@@ -16,6 +16,9 @@ from ...db import get_db
|
||||
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
|
||||
from app.schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
|
||||
from ...proxmox import ProxmoxClient, get_proxmox_resources, _run_in_executor
|
||||
from ...proxmox.token_provisioning import (
|
||||
TokenProvisionError, provision_api_token, revoke_api_token,
|
||||
)
|
||||
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
|
||||
from ...logging_service import LoggingService
|
||||
from ...ipam_service import IPAMService
|
||||
@@ -90,10 +93,49 @@ def create_proxmox_server(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Server with name '{server_data.name}' already exists"
|
||||
)
|
||||
|
||||
server = ProxmoxServer(**server_data.model_dump())
|
||||
|
||||
payload = server_data.model_dump()
|
||||
# Not a column — it selects how credentials are obtained, not what is stored.
|
||||
auto_token = payload.pop("auto_create_token", False)
|
||||
provisioned_token: str | None = None
|
||||
|
||||
if auto_token:
|
||||
try:
|
||||
token_name, token_value = provision_api_token(
|
||||
ip_address=payload["ip_address"],
|
||||
port=payload.get("port"),
|
||||
api_user=payload["api_user"],
|
||||
password=payload.get("password"),
|
||||
verify_ssl=payload.get("verify_ssl", False),
|
||||
comment=f"PVEmanager ({payload['name']})",
|
||||
)
|
||||
except TokenProvisionError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
payload["api_token_name"] = token_name
|
||||
payload["api_token_value"] = token_value
|
||||
# The password stays stored (encrypted) for SSH fallbacks like pvecm
|
||||
# delnode, but day-to-day API calls now go through the token.
|
||||
payload["use_password"] = False
|
||||
provisioned_token = token_name
|
||||
|
||||
server = ProxmoxServer(**payload)
|
||||
db.add(server)
|
||||
db.commit()
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
# The secret was never persisted, so the token on the node is unusable —
|
||||
# remove it instead of leaving an orphan behind.
|
||||
if provisioned_token:
|
||||
revoke_api_token(
|
||||
ip_address=payload["ip_address"],
|
||||
port=payload.get("port"),
|
||||
api_user=payload["api_user"],
|
||||
password=payload.get("password"),
|
||||
token_name=provisioned_token,
|
||||
verify_ssl=payload.get("verify_ssl", False),
|
||||
)
|
||||
raise
|
||||
db.refresh(server)
|
||||
|
||||
# Сразу проверяем подключение, чтобы статус is_online отобразился без задержки
|
||||
@@ -387,7 +429,9 @@ def test_proxmox_credentials(
|
||||
):
|
||||
"""Проверить произвольные учётные данные Proxmox без сохранения в БД."""
|
||||
try:
|
||||
if payload.use_password:
|
||||
# auto_create_token still authenticates by password at this point — the
|
||||
# token only gets created when the server is actually saved.
|
||||
if payload.use_password or payload.auto_create_token:
|
||||
client = ProxmoxClient(
|
||||
host=payload.ip_address,
|
||||
user=payload.api_user,
|
||||
@@ -411,6 +455,70 @@ def test_proxmox_credentials(
|
||||
return {"success": False, "status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/provision-token", status_code=status.HTTP_200_OK)
|
||||
def provision_server_token(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:update"))
|
||||
):
|
||||
"""Перевести уже подключённый сервер с парольной авторизации на API-токен.
|
||||
|
||||
Использует сохранённый пароль сервера: подключается им один раз, создаёт
|
||||
токен и переключает сервер на токен-авторизацию. Пароль остаётся в БД
|
||||
(зашифрованным) — он нужен для SSH-фолбэков вроде pvecm delnode.
|
||||
"""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
|
||||
if not server.password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Server has no stored password — re-add it with credentials to create a token",
|
||||
)
|
||||
|
||||
try:
|
||||
token_name, token_value = provision_api_token(
|
||||
ip_address=server.ip_address,
|
||||
port=server.port,
|
||||
api_user=server.api_user,
|
||||
password=server.password,
|
||||
verify_ssl=server.verify_ssl,
|
||||
comment=f"PVEmanager ({server.name})",
|
||||
)
|
||||
except TokenProvisionError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
previous_token = server.api_token_name
|
||||
server.api_token_name = token_name
|
||||
server.api_token_value = token_value
|
||||
server.use_password = False
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
revoke_api_token(
|
||||
ip_address=server.ip_address, port=server.port, api_user=server.api_user,
|
||||
password=server.password, token_name=token_name, verify_ssl=server.verify_ssl,
|
||||
)
|
||||
raise
|
||||
db.refresh(server)
|
||||
|
||||
# Cached connections still carry the old credentials.
|
||||
try:
|
||||
from ...proxmox.client import clear_server_cache
|
||||
clear_server_cache(server.ip_address)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to clear connection cache for {server.name}: {e}")
|
||||
|
||||
logger.info(
|
||||
f"User {current_user.username} switched server '{server.name}' to API token "
|
||||
f"'{server.api_user}!{token_name}'"
|
||||
+ (f" (previous token: {previous_token})" if previous_token else "")
|
||||
)
|
||||
return {"success": True, "token_name": token_name}
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/test", status_code=status.HTTP_200_OK)
|
||||
def test_proxmox_connection(
|
||||
server_id: int,
|
||||
|
||||
@@ -85,7 +85,10 @@ class ProxmoxServer(Base):
|
||||
|
||||
api_user = Column(String(100), nullable=False, default="root@pam")
|
||||
api_token_name = Column(String(100), nullable=True)
|
||||
api_token_value = Column(String(255), nullable=True)
|
||||
# Encrypted like the password: with auto-provisioning the token becomes the
|
||||
# primary credential. EncryptedString stores as String and falls back to
|
||||
# plaintext on read, so existing rows keep working until they are rewritten.
|
||||
api_token_value = Column(EncryptedString(255), nullable=True)
|
||||
|
||||
use_password = Column(Boolean, nullable=False, default=False)
|
||||
password = Column(EncryptedString(255), nullable=True)
|
||||
|
||||
130
backend/app/proxmox/token_provisioning.py
Normal file
130
backend/app/proxmox/token_provisioning.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Automatic API token provisioning for newly connected Proxmox servers.
|
||||
|
||||
The panel prefers token auth over storing a root password: a token can be
|
||||
revoked from the PVE UI, cannot be used for SSH or web login, and shows up in
|
||||
the PVE audit log under its own name. This module connects once with the
|
||||
supplied password, creates a dedicated token, and hands its credentials back to
|
||||
the caller — see ``api/proxmox/servers.py`` for the create/migrate endpoints.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .client import ProxmoxClient
|
||||
|
||||
# Token id created on the Proxmox side. Kept stable so a returning user
|
||||
# recognises it in Datacenter → Permissions → API Tokens.
|
||||
DEFAULT_TOKEN_NAME = "pvemanager"
|
||||
|
||||
# PVE rejects token ids outside [A-Za-z0-9.\-_], so suffixes stay numeric.
|
||||
MAX_NAME_ATTEMPTS = 50
|
||||
|
||||
|
||||
class TokenProvisionError(Exception):
|
||||
"""Raised when the panel could not create an API token on the server."""
|
||||
|
||||
|
||||
def build_host(ip_address: str, port: Optional[int]) -> str:
|
||||
"""Host string for ProxmoxClient — appends a non-default port."""
|
||||
host = ip_address or ""
|
||||
if port and port != 8006:
|
||||
return f"{host}:{port}"
|
||||
return host
|
||||
|
||||
|
||||
def pick_token_name(existing: set, base: str = DEFAULT_TOKEN_NAME) -> str:
|
||||
"""Pick a free token id, suffixing when the preferred name is taken.
|
||||
|
||||
Reusing an existing token is not an option: PVE returns the secret only at
|
||||
creation time, so an already-present ``pvemanager`` token likely belongs to
|
||||
another panel instance and must be left alone.
|
||||
"""
|
||||
if base not in existing:
|
||||
return base
|
||||
for i in range(2, MAX_NAME_ATTEMPTS + 1):
|
||||
candidate = f"{base}-{i}"
|
||||
if candidate not in existing:
|
||||
return candidate
|
||||
raise TokenProvisionError(
|
||||
f"Could not find a free API token name after {MAX_NAME_ATTEMPTS} attempts"
|
||||
)
|
||||
|
||||
|
||||
def provision_api_token(
|
||||
ip_address: str,
|
||||
port: Optional[int],
|
||||
api_user: str,
|
||||
password: str,
|
||||
verify_ssl: bool = False,
|
||||
comment: str = "Created automatically by PVEmanager",
|
||||
privsep: bool = False,
|
||||
timeout: int = 15,
|
||||
) -> Tuple[str, str]:
|
||||
"""Create an API token on the Proxmox server using password auth.
|
||||
|
||||
Returns ``(token_name, token_value)``. The secret is returned by PVE exactly
|
||||
once, so the caller must persist it or the token becomes unusable.
|
||||
|
||||
Raises TokenProvisionError when the server is unreachable, the credentials
|
||||
are wrong, or the user lacks permission to create tokens.
|
||||
"""
|
||||
if not password:
|
||||
raise TokenProvisionError("A password is required to create an API token")
|
||||
|
||||
client = ProxmoxClient(
|
||||
host=build_host(ip_address, port),
|
||||
user=api_user,
|
||||
password=password,
|
||||
verify_ssl=verify_ssl,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not client.is_connected():
|
||||
raise TokenProvisionError(
|
||||
"Could not authenticate to the Proxmox server with the supplied password"
|
||||
)
|
||||
|
||||
existing = {t.get("tokenid") for t in client.get_user_tokens(api_user)}
|
||||
token_name = pick_token_name(existing)
|
||||
|
||||
# privsep=0 grants the token the same privileges as the user itself; with
|
||||
# privsep=1 it would start with no permissions until ACLs are assigned.
|
||||
result = client.create_user_token(api_user, token_name, comment=comment, privsep=privsep)
|
||||
if not result.get("success") or not result.get("value"):
|
||||
raise TokenProvisionError(
|
||||
result.get("error") or "Proxmox did not return a token secret"
|
||||
)
|
||||
|
||||
logger.info(f"Created Proxmox API token '{api_user}!{token_name}' on {ip_address}")
|
||||
return token_name, result["value"]
|
||||
|
||||
|
||||
def revoke_api_token(
|
||||
ip_address: str,
|
||||
port: Optional[int],
|
||||
api_user: str,
|
||||
password: str,
|
||||
token_name: str,
|
||||
verify_ssl: bool = False,
|
||||
timeout: int = 15,
|
||||
) -> bool:
|
||||
"""Best-effort cleanup of a token the panel created but failed to persist.
|
||||
|
||||
Without this, a failed server registration leaves an orphaned token on the
|
||||
node that nobody can use (its secret was never stored).
|
||||
"""
|
||||
try:
|
||||
client = ProxmoxClient(
|
||||
host=build_host(ip_address, port),
|
||||
user=api_user,
|
||||
password=password,
|
||||
verify_ssl=verify_ssl,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not client.is_connected():
|
||||
return False
|
||||
result = client.delete_user_token(api_user, token_name)
|
||||
return bool(result.get("success"))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to revoke orphaned token '{token_name}' on {ip_address}: {e}")
|
||||
return False
|
||||
@@ -126,53 +126,6 @@ class ResetPasswordRequest(BaseModel):
|
||||
|
||||
# ==================== Proxmox Server Schemas ====================
|
||||
|
||||
class ProxmoxServerBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Server name")
|
||||
hostname: str = Field(..., min_length=1, max_length=255, description="Server hostname/FQDN")
|
||||
ip_address: str = Field(..., min_length=7, max_length=50, description="IP address")
|
||||
port: int = Field(default=8006, ge=1, le=65535, description="Proxmox API port")
|
||||
api_user: str = Field(default="root@pam", max_length=100, description="API username (e.g., root@pam)")
|
||||
api_token_name: Optional[str] = Field(None, max_length=100, description="API token name")
|
||||
api_token_value: Optional[str] = Field(None, max_length=255, description="API token value")
|
||||
use_password: bool = Field(default=False, description="Use password instead of token")
|
||||
password: Optional[str] = Field(None, max_length=255, description="Password (if use_password=True)")
|
||||
verify_ssl: bool = Field(default=False, description="Verify SSL certificate")
|
||||
description: Optional[str] = Field(None, description="Server description")
|
||||
|
||||
@field_validator('ip_address')
|
||||
@classmethod
|
||||
def validate_ip_address(cls, v):
|
||||
"""Validate IP address or hostname/FQDN"""
|
||||
return validate_host(v)
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_name(cls, v):
|
||||
"""Validate server name"""
|
||||
if not v or not v.strip():
|
||||
raise ValueError('Server name cannot be empty')
|
||||
return v.strip()
|
||||
|
||||
@field_validator('hostname')
|
||||
@classmethod
|
||||
def validate_hostname(cls, v):
|
||||
"""Validate hostname"""
|
||||
if not v or not v.strip():
|
||||
raise ValueError('Hostname cannot be empty')
|
||||
return v.strip()
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_auth_config(self):
|
||||
"""Validate authentication configuration"""
|
||||
if self.use_password:
|
||||
if not self.password:
|
||||
raise ValueError('Password is required when use_password=True')
|
||||
else:
|
||||
if not self.api_token_name or not self.api_token_value:
|
||||
raise ValueError('API token name and value are required when using token auth')
|
||||
return self
|
||||
|
||||
|
||||
class ProxmoxServerCreate(BaseModel):
|
||||
"""Schema for creating a new Proxmox server"""
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Server name")
|
||||
@@ -184,6 +137,10 @@ class ProxmoxServerCreate(BaseModel):
|
||||
api_token_value: Optional[str] = Field(None, max_length=255, description="API token value")
|
||||
use_password: bool = Field(default=False, description="Use password instead of token")
|
||||
password: Optional[str] = Field(None, max_length=255, description="Password (if use_password=True)")
|
||||
auto_create_token: bool = Field(
|
||||
default=False,
|
||||
description="Authenticate with the password once, then let the panel create its own API token",
|
||||
)
|
||||
verify_ssl: bool = Field(default=False, description="Verify SSL certificate")
|
||||
description: Optional[str] = Field(None, description="Server description")
|
||||
|
||||
@@ -212,7 +169,12 @@ class ProxmoxServerCreate(BaseModel):
|
||||
@model_validator(mode='after')
|
||||
def validate_auth_config(self):
|
||||
"""Validate authentication configuration"""
|
||||
if self.use_password:
|
||||
if self.auto_create_token:
|
||||
# The token does not exist yet — it is created during registration
|
||||
# using this password, so only the password is required here.
|
||||
if not self.password:
|
||||
raise ValueError('Password is required when auto_create_token=True')
|
||||
elif self.use_password:
|
||||
if not self.password:
|
||||
raise ValueError('Password is required when use_password=True')
|
||||
else:
|
||||
|
||||
206
docs/roadmap.md
Normal file
206
docs/roadmap.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# План развития PVEmanager
|
||||
|
||||
Документ описывает шесть направлений развития панели, согласованных 24.07.2026.
|
||||
Два из них уже реализованы, четыре — предстоят. Для каждого этапа указаны цель,
|
||||
затрагиваемые файлы, шаги и критерии готовности, чтобы задачу мог взять любой
|
||||
разработчик без дополнительного контекста.
|
||||
|
||||
## Статус
|
||||
|
||||
| # | Направление | Статус | Приоритет |
|
||||
|---|---|---|---|
|
||||
| 1 | Автосоздание API-токена при подключении ноды | ✅ Сделано | — |
|
||||
| 2 | Графики метрик нод | ✅ Сделано (не проверено визуально) | — |
|
||||
| 3 | Одна команда установки (`curl \| bash`) | ⬜ Запланировано | 1 |
|
||||
| 4 | Интеграция с Proxmox Backup Server | ⬜ Запланировано | 2 |
|
||||
| 5 | Redis: вынос воркера задач и общий кэш | ⬜ Запланировано | 3 |
|
||||
| 6 | Внешний стек метрик и логов (опционально) | ⬜ Запланировано | 4 |
|
||||
|
||||
Порядок выбран так: сначала дистрибуция (от неё зависит рост аудитории), затем
|
||||
крупная продуктовая фича, затем инфраструктурные улучшения. Этап 6 — самый
|
||||
дорогой и наименее обязательный, поэтому идёт последним.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Этап 1. Автосоздание API-токена
|
||||
|
||||
**Что сделано.** При добавлении сервера пользователь вводит логин и пароль,
|
||||
панель подключается ими один раз, создаёт на ноде собственный API-токен и
|
||||
дальше работает по нему. Токен отзывается из UI Proxmox, не даёт SSH- и
|
||||
веб-логина и виден в аудите PVE под своим именем.
|
||||
|
||||
- `backend/app/proxmox/token_provisioning.py` — создание токена (`privsep=0`),
|
||||
выбор свободного имени (`pvemanager`, при коллизии `pvemanager-2`), откат
|
||||
через `revoke_api_token`.
|
||||
- `POST /api/servers` (`backend/app/api/proxmox/servers.py`) — поле
|
||||
`auto_create_token`; при сбое коммита созданный токен отзывается, чтобы на
|
||||
ноде не оставался сирота с потерянным секретом.
|
||||
- `POST /api/servers/{id}/provision-token` — перевод уже подключённого сервера
|
||||
с пароля на токен по сохранённому паролю.
|
||||
- `api_token_value` переведён на `EncryptedString` (`backend/app/models/proxmox.py`) —
|
||||
раньше токены лежали в БД открытым текстом, в отличие от паролей.
|
||||
- Фронтенд: выбор из трёх режимов подключения в `NodesPage.tsx`, кнопка
|
||||
«Перевести на API-токен» на карточке сервера.
|
||||
|
||||
**Принятые решения.** Пароль после создания токена остаётся в БД
|
||||
(зашифрованным) — он нужен `ProxmoxClient._password` для SSH-фолбэков вроде
|
||||
`pvecm delnode`. Токен создаётся с `privsep=0`, то есть наследует права
|
||||
пользователя.
|
||||
|
||||
## ✅ Этап 2. Графики метрик нод
|
||||
|
||||
**Что сделано.** Вкладка «Графики» в `NodeDetailPage`: CPU, IO delay, память,
|
||||
load average, сеть in/out, swap, корневая ФС. Пресеты `1h/24h/7d/30d/1y`.
|
||||
|
||||
- `frontend/src/components/shared/MetricChart.tsx` — общий `ChartCard`,
|
||||
переиспользуется вкладкой инстансов.
|
||||
- `useNodeMetrics` в `frontend/src/hooks/use-nodes.ts` — нормализация сырого RRD
|
||||
(PVE отдаёт `cpu`/`iowait` долями 0..1).
|
||||
- `frontend/src/features/nodes/NodeGraphs.tsx`.
|
||||
|
||||
Бэкенд не менялся — эндпоинт `/api/{server_id}/node/rrddata` существовал ранее.
|
||||
Дисковых I/O и IOPS в наборе нет: PVE их в node RRD не отдаёт.
|
||||
|
||||
**Открытый хвост.** Рендер с реальными данными не проверялся визуально.
|
||||
|
||||
---
|
||||
|
||||
## ⬜ Этап 3. Установка одной командой
|
||||
|
||||
**Цель.** Пользователь разворачивает панель на своём сервере одной командой,
|
||||
вместе с ней устанавливается консольный хелпер `pve`.
|
||||
|
||||
**Что уже есть.** `deploy.sh` (36 КБ, основная логика развёртывания), `pve`
|
||||
(CLI-хелпер), каталог `systemd/` с юнитами, `compose.yml`. Не хватает тонкой
|
||||
обёртки-установщика и публикации.
|
||||
|
||||
**Шаги.**
|
||||
1. Написать `install.sh` в корне репозитория:
|
||||
- проверка ОС и наличия Docker + docker compose v2, понятная ошибка вместо
|
||||
падения на середине;
|
||||
- клонирование репозитория в `/opt/pvemanager` (путь настраиваемый);
|
||||
- генерация `.env` со случайными секретами;
|
||||
- `docker compose up -d`;
|
||||
- установка `pve` в `/usr/local/bin`, установка systemd-юнита;
|
||||
- идемпотентность: повторный запуск обновляет существующую установку, а не
|
||||
ломает её.
|
||||
2. Убрать дефолт `POSTGRES_PASSWORD: pvemanager_secure_password` из `compose.yml`
|
||||
— в публичной поставке пароль должен генерироваться, а не иметь значение
|
||||
по умолчанию.
|
||||
3. Опубликовать скрипт (raw.githubusercontent или короткий домен), в README
|
||||
дать два варианта: `curl … | bash` и «скачать → прочитать → запустить» с
|
||||
публикацией sha256.
|
||||
4. Документировать удаление и обновление.
|
||||
|
||||
**Готово, когда** на чистой Debian/Ubuntu-машине одна команда поднимает
|
||||
работающую панель, `pve --help` отвечает, повторный запуск скрипта не ломает
|
||||
установку.
|
||||
|
||||
**Риски.** `curl | bash` — потенциальный вектор атаки; обязательны контрольная
|
||||
сумма и альтернативный путь установки.
|
||||
|
||||
## ⬜ Этап 4. Интеграция с Proxmox Backup Server
|
||||
|
||||
**Цель.** Управлять PBS напрямую, а не только через PVE.
|
||||
|
||||
**Что уже есть.** PBS подключается к PVE как storage типа `pbs`, поэтому
|
||||
бэкап и восстановление уже работают через `backend/app/api/proxmox/backup.py`
|
||||
(storages, native jobs, restore, bulk restore).
|
||||
|
||||
**Чего не хватает** (не видно через PVE API): использование datastore и
|
||||
дедупликация, задания verify/prune/GC, namespaces, sync- и tape-задания,
|
||||
браузер снапшотов с восстановлением отдельных файлов.
|
||||
|
||||
**Шаги.**
|
||||
1. Модель `PBSServer`: host, порт (по умолчанию 8007), `api_user`, токен
|
||||
(`user@pbs!tokenid`), fingerprint сертификата, `verify_ssl`. Секреты — через
|
||||
`EncryptedString`.
|
||||
2. Клиент `backend/app/pbs/client.py` по образцу `ProxmoxClient` с миксинами:
|
||||
datastores, snapshots, verify/prune/GC, namespaces.
|
||||
3. API-роутер `backend/app/api/pbs/` + права в `backend/app/rbac/permissions.py`.
|
||||
4. Фронтенд: раздел PBS со списком серверов, datastore-обзором и браузером
|
||||
снапшотов.
|
||||
5. Связать с существующим разделом бэкапов: если storage типа `pbs` соответствует
|
||||
зарегистрированному PBS-серверу — показывать расширенные данные.
|
||||
|
||||
**Готово, когда** можно добавить PBS-сервер, увидеть занятость datastore и
|
||||
коэффициент дедупликации, запустить verify/prune и просмотреть содержимое
|
||||
снапшота.
|
||||
|
||||
**Оценка.** Сопоставимо по объёму со всем текущим модулем бэкапов.
|
||||
|
||||
## ⬜ Этап 5. Redis: вынос воркера и общий кэш
|
||||
|
||||
**Цель.** Вынести исполнение фоновых задач из процесса `app` в отдельный
|
||||
контейнер и убрать состояние, которое сейчас живёт только в памяти процесса.
|
||||
|
||||
**Текущее состояние.** `TaskQueueService` хранит задачи в Postgres, а
|
||||
исполняются они внутри `app` через APScheduler при `UVICORN_WORKERS=1`.
|
||||
Последствия: воркеры не масштабируются, задача в статусе `running` зависает при
|
||||
рестарте контейнера, нет ретраев.
|
||||
|
||||
**Важно:** очередь в Postgres сама по себе не проблема — для домашней нагрузки
|
||||
её достаточно. Выигрыш даёт именно вынос воркера; Redis нужен как брокер и
|
||||
попутно закрывает три места, которые сейчас ломаются при масштабировании:
|
||||
|
||||
- `connection_cache` в `backend/app/proxmox/client.py` — словарь в памяти;
|
||||
- `_rrd_cache` в `backend/app/services/metrics_broadcaster.py` — то же;
|
||||
- `websocket_manager.py` — при более чем одном uvicorn-воркере рассылка не
|
||||
работает без pub/sub.
|
||||
|
||||
**Шаги.**
|
||||
1. Добавить сервис `redis` в `compose.yml` (alpine-образ, ограничение памяти).
|
||||
2. Подключить **ARQ** как брокер (asyncio-native; Celery для этой задачи
|
||||
избыточен). Postgres остаётся источником правды по истории задач.
|
||||
3. Вынести обработку `TaskQueue` в отдельный контейнер `worker`.
|
||||
4. Перевести `connection_cache` и `_rrd_cache` на Redis.
|
||||
5. Перевести WebSocket-рассылку на Redis pub/sub, после чего можно поднять
|
||||
`UVICORN_WORKERS`.
|
||||
6. Добавить ретраи и переустановку статуса зависших задач при старте воркера.
|
||||
|
||||
**Готово, когда** задачи исполняются в отдельном контейнере, переживают рестарт
|
||||
`app`, а панель корректно работает при `UVICORN_WORKERS > 1`.
|
||||
|
||||
## ⬜ Этап 6. Внешний стек метрик и логов
|
||||
|
||||
**Цель.** Снять с панели хранение метрик и дать историю глубже, чем позволяет
|
||||
RRD, — но не навязывая всем пользователям тяжёлый стек мониторинга.
|
||||
|
||||
**Ключевой факт.** Собирать метрики самим не нужно: у Proxmox есть встроенный
|
||||
external metric server (протоколы InfluxDB и Graphite) — нода push-ит метрики
|
||||
наружу без всякого кода в панели. Для pull-модели существует
|
||||
`prometheus-pve-exporter`.
|
||||
|
||||
**Решения по составу.**
|
||||
- **VictoriaMetrics вместо Prometheus** и **VictoriaLogs вместо Loki** — заметно
|
||||
меньше RAM и диска при совместимом PromQL/LogsQL.
|
||||
- **Grafana необязательна.** Если панель сама ходит в PromQL, Grafana нужна
|
||||
только продвинутым пользователям — подключается опционально.
|
||||
- Весь стек включается через `docker compose --profile monitoring`, а **не**
|
||||
входит в поставку по умолчанию: для типовой домашней установки это дороже
|
||||
самой панели.
|
||||
|
||||
**Шаги.**
|
||||
1. Профиль `monitoring` в `compose.yml`.
|
||||
2. В панели — переключаемый источник метрик: `native` (RRD через PVE API, как
|
||||
сейчас) либо `victoriametrics` (PromQL). Настройка в разделе Settings.
|
||||
3. Абстрагировать слой доступа к данным на фронтенде, чтобы `GraphsTab` и
|
||||
`NodeGraphs` не переписывать при смене источника.
|
||||
4. Инструкция по настройке external metric server на ноде.
|
||||
5. Опциональная Grafana с преднастроенными дашбордами.
|
||||
|
||||
**Готово, когда** при выключенном профиле всё работает как сейчас, а при
|
||||
включённом графики строятся из VictoriaMetrics с историей глубже RRD.
|
||||
|
||||
**Зависимость.** Этап 2 намеренно сделан на RRD. При выполнении этого этапа
|
||||
слой доступа к данным нужно спрятать за один хук, а не переписывать компоненты.
|
||||
|
||||
---
|
||||
|
||||
## Технический долг, замеченный попутно
|
||||
|
||||
- **Хардкод русских строк** в `frontend/src/features/instances/tabs/GraphsTab.tsx`:
|
||||
«IOPS чтение», «IOPS запись», «Заполненность диска», «Все», «Сбросить»,
|
||||
«Нет данных» — не проходят через i18n.
|
||||
- **`graphify update .`** отказывается перезаписывать граф: AST-версия даёт 5938
|
||||
узлов против 5959 в текущем `graph.json`. Нужно решить, форсить ли перестроение.
|
||||
137
frontend/src/components/shared/MetricChart.tsx
Normal file
137
frontend/src/components/shared/MetricChart.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useId } from 'react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
/** Presets shared by the instance and node graph views. */
|
||||
export const TIMEFRAME_PRESETS = [
|
||||
{ tf: 'hour', label: '1h' },
|
||||
{ tf: 'day', label: '24h' },
|
||||
{ tf: 'week', label: '7d' },
|
||||
{ tf: 'month', label: '30d' },
|
||||
];
|
||||
|
||||
/** HH:MM — the default tick format, adequate for hour/day ranges. */
|
||||
export function fmtTime(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** DD.MM — used for week/month/year ranges where HH:MM repeats itself. */
|
||||
export function fmtDate(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Pick a tick formatter that stays readable for the given timeframe. */
|
||||
export function tickFormatterFor(timeframe: string): (ts: number) => string {
|
||||
return timeframe === 'hour' || timeframe === 'day' ? fmtTime : fmtDate;
|
||||
}
|
||||
|
||||
/** Convert a datetime-local string to unix seconds. */
|
||||
export function localToUnix(val: string): number | undefined {
|
||||
if (!val) return undefined;
|
||||
return Math.floor(new Date(val).getTime() / 1000);
|
||||
}
|
||||
|
||||
/** Convert unix seconds to a datetime-local value string. */
|
||||
export function unixToLocal(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export interface ChartCardProps {
|
||||
title: string;
|
||||
data: Array<Record<string, unknown>>;
|
||||
dataKey: string;
|
||||
color: string;
|
||||
formatValue?: (v: number) => string;
|
||||
unit?: string;
|
||||
headerRight?: React.ReactNode;
|
||||
/** Overrides the X axis tick format (defaults to HH:MM). */
|
||||
tickFormatter?: (ts: number) => string;
|
||||
}
|
||||
|
||||
/** Single area chart card — one metric series over time. */
|
||||
export function ChartCard({
|
||||
title,
|
||||
data,
|
||||
dataKey,
|
||||
color,
|
||||
formatValue,
|
||||
unit,
|
||||
headerRight,
|
||||
tickFormatter,
|
||||
}: ChartCardProps) {
|
||||
// useId keeps the gradient unique even when two cards share a dataKey.
|
||||
const gradientId = `grad-${dataKey}-${useId().replace(/:/g, '')}`;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
{headerRight}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={tickFormatter || fmtTime}
|
||||
className="text-2xs fill-muted-foreground"
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-2xs fill-muted-foreground"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(v) => (formatValue ? formatValue(v) : String(v))}
|
||||
width={55}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.65)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
color: '#fff',
|
||||
}}
|
||||
labelStyle={{ color: '#fff' }}
|
||||
itemStyle={{ color: '#fff' }}
|
||||
formatter={(val: unknown) => [
|
||||
val != null && typeof val === 'number' && formatValue
|
||||
? formatValue(val)
|
||||
: `${val ?? ''}${unit || ''}`,
|
||||
title,
|
||||
]}
|
||||
labelFormatter={(ts) => new Date((ts as number) * 1000).toLocaleString()}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#${gradientId})`}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useVMMetrics } from '@/hooks/use-instances';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import {
|
||||
ChartCard,
|
||||
TIMEFRAME_PRESETS as PRESETS,
|
||||
localToUnix,
|
||||
unixToLocal,
|
||||
tickFormatterFor,
|
||||
} from '@/components/shared/MetricChart';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
@@ -22,107 +19,8 @@ interface Props {
|
||||
node: string;
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ tf: 'hour', label: '1h' },
|
||||
{ tf: 'day', label: '24h' },
|
||||
{ tf: 'week', label: '7d' },
|
||||
{ tf: 'month', label: '30d' },
|
||||
];
|
||||
|
||||
const MAX_SPAN = 30 * 86400;
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Convert a datetime-local string to unix seconds
|
||||
function localToUnix(val: string): number | undefined {
|
||||
if (!val) return undefined;
|
||||
return Math.floor(new Date(val).getTime() / 1000);
|
||||
}
|
||||
|
||||
// Convert unix seconds to datetime-local value string
|
||||
function unixToLocal(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
interface ChartCardProps {
|
||||
title: string;
|
||||
data: Array<Record<string, unknown>>;
|
||||
dataKey: string;
|
||||
color: string;
|
||||
formatValue?: (v: number) => string;
|
||||
unit?: string;
|
||||
headerRight?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ChartCard({ title, data, dataKey, color, formatValue, unit, headerRight }: ChartCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
{headerRight}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={`grad-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={fmtTime}
|
||||
className="text-2xs fill-muted-foreground"
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-2xs fill-muted-foreground"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(v) => (formatValue ? formatValue(v) : String(v))}
|
||||
width={55}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.65)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
color: '#fff',
|
||||
}}
|
||||
labelStyle={{ color: '#fff' }}
|
||||
itemStyle={{ color: '#fff' }}
|
||||
formatter={(val: unknown) => [
|
||||
val != null && typeof val === 'number' && formatValue
|
||||
? formatValue(val)
|
||||
: `${val ?? ''}${unit || ''}`,
|
||||
title,
|
||||
]}
|
||||
labelFormatter={(ts) => new Date((ts as number) * 1000).toLocaleString()}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#grad-${dataKey})`}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [timeframe, setTimeframe] = useState('hour');
|
||||
@@ -153,6 +51,13 @@ export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
const points = (data?.data ?? []) as unknown as Array<Record<string, unknown>>;
|
||||
const nics = data?.meta?.nics ?? [];
|
||||
|
||||
// Custom ranges spanning more than a day need date ticks, not HH:MM.
|
||||
const spanSeconds =
|
||||
hasCustom && customRange.from != null && customRange.to != null
|
||||
? customRange.to - customRange.from
|
||||
: 0;
|
||||
const xTick = tickFormatterFor(hasCustom ? (spanSeconds > 86400 ? 'week' : 'day') : timeframe);
|
||||
|
||||
function resetCustom() {
|
||||
setFromLocal('');
|
||||
setToLocal('');
|
||||
@@ -233,6 +138,7 @@ export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.cpu_usage')}
|
||||
data={points}
|
||||
dataKey="cpu"
|
||||
@@ -241,6 +147,7 @@ export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
unit="%"
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.memory_usage')}
|
||||
data={points}
|
||||
dataKey="mem"
|
||||
@@ -248,6 +155,7 @@ export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.disk_write')}
|
||||
data={points}
|
||||
dataKey="diskwrite"
|
||||
@@ -255,6 +163,7 @@ export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
formatValue={(v) => `${formatBytes(v)}/s`}
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.disk_read')}
|
||||
data={points}
|
||||
dataKey="diskread"
|
||||
@@ -262,6 +171,7 @@ export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
formatValue={(v) => `${formatBytes(v)}/s`}
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.network_in')}
|
||||
data={points}
|
||||
dataKey="netin"
|
||||
@@ -270,6 +180,7 @@ export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
headerRight={nicSelector}
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.network_out')}
|
||||
data={points}
|
||||
dataKey="netout"
|
||||
@@ -278,6 +189,7 @@ export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
headerRight={nicSelector}
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title="IOPS чтение"
|
||||
data={points}
|
||||
dataKey="iops_read"
|
||||
@@ -286,6 +198,7 @@ export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
unit=" ops/s"
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title="IOPS запись"
|
||||
data={points}
|
||||
dataKey="iops_write"
|
||||
@@ -294,6 +207,7 @@ export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
unit=" ops/s"
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title="Заполненность диска"
|
||||
data={points}
|
||||
dataKey="diskpct"
|
||||
|
||||
@@ -67,6 +67,7 @@ function NodeActionsMenu({ serverId, node }: { serverId: number; node: string })
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
import NodeGraphs from './NodeGraphs';
|
||||
import NodeNetworks from './NodeNetworks';
|
||||
import HAResources from './HAResources';
|
||||
import SDNManager from './SDNManager';
|
||||
@@ -126,6 +127,7 @@ export default function NodeDetailPage() {
|
||||
<TabsTrigger value="overview">{t('nodes.tab_overview', 'Обзор')}</TabsTrigger>
|
||||
{nodes.length > 0 && (
|
||||
<>
|
||||
<TabsTrigger value="graphs">{t('nodes.tab_graphs', 'Графики')}</TabsTrigger>
|
||||
<TabsTrigger value="network">{t('nodes.tab_network', 'Сеть')}</TabsTrigger>
|
||||
<TabsTrigger value="ha">{t('nodes.tab_ha', 'HA')}</TabsTrigger>
|
||||
<TabsTrigger value="firewall">{t('nodes.tab_firewall', 'Firewall')}</TabsTrigger>
|
||||
@@ -206,6 +208,11 @@ export default function NodeDetailPage() {
|
||||
|
||||
{nodes.length > 0 && (
|
||||
<>
|
||||
{/* Graphs: node RRD metrics */}
|
||||
<TabsContent value="graphs" className="mt-4">
|
||||
<NodeGraphs serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Network: interfaces + SDN */}
|
||||
<TabsContent value="network" className="mt-4 space-y-6">
|
||||
<NodeNetworks serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
|
||||
146
frontend/src/features/nodes/NodeGraphs.tsx
Normal file
146
frontend/src/features/nodes/NodeGraphs.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNodeMetrics } from '@/hooks/use-nodes';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import {
|
||||
ChartCard,
|
||||
TIMEFRAME_PRESETS,
|
||||
tickFormatterFor,
|
||||
} from '@/components/shared/MetricChart';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
nodeNames: string[];
|
||||
}
|
||||
|
||||
// PVE keeps a year of node RRD data, unlike the 30-day instance history.
|
||||
const PRESETS = [...TIMEFRAME_PRESETS, { tf: 'year', label: '1y' }];
|
||||
|
||||
export default function NodeGraphs({ serverId, nodeNames }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [node, setNode] = useState(nodeNames[0] || '');
|
||||
const [timeframe, setTimeframe] = useState('hour');
|
||||
|
||||
// Nodes arrive asynchronously; adopt the first one once it shows up, and
|
||||
// fall back if the selected node disappears from the cluster.
|
||||
useEffect(() => {
|
||||
if (nodeNames.length && !nodeNames.includes(node)) setNode(nodeNames[0]);
|
||||
}, [nodeNames, node]);
|
||||
|
||||
const { data: points = [], isLoading } = useNodeMetrics(serverId, node, timeframe);
|
||||
const xTick = tickFormatterFor(timeframe);
|
||||
const rows = points as unknown as Array<Record<string, unknown>>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{nodeNames.length > 1 && (
|
||||
<select
|
||||
value={node}
|
||||
onChange={(e) => setNode(e.target.value)}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:outline-none"
|
||||
>
|
||||
{nodeNames.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
{PRESETS.map((p) => (
|
||||
<Button
|
||||
key={p.tf}
|
||||
variant={timeframe === p.tf ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTimeframe(p.tf)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-20 text-sm text-muted-foreground">
|
||||
{t('common.no_data')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.cpu_usage')}
|
||||
data={rows}
|
||||
dataKey="cpu"
|
||||
color="hsl(217, 91%, 60%)"
|
||||
formatValue={(v) => `${v.toFixed(1)}%`}
|
||||
unit="%"
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.io_delay', 'IO delay')}
|
||||
data={rows}
|
||||
dataKey="iowait"
|
||||
color="hsl(45, 93%, 47%)"
|
||||
formatValue={(v) => `${v.toFixed(2)}%`}
|
||||
unit="%"
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.memory_usage')}
|
||||
data={rows}
|
||||
dataKey="memused"
|
||||
color="hsl(142, 76%, 36%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.load_average', 'Load average')}
|
||||
data={rows}
|
||||
dataKey="loadavg"
|
||||
color="hsl(0, 84%, 60%)"
|
||||
formatValue={(v) => v.toFixed(2)}
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.network_in')}
|
||||
data={rows}
|
||||
dataKey="netin"
|
||||
color="hsl(262, 83%, 58%)"
|
||||
formatValue={(v) => `${formatBytes(v)}/s`}
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.network_out')}
|
||||
data={rows}
|
||||
dataKey="netout"
|
||||
color="hsl(25, 95%, 53%)"
|
||||
formatValue={(v) => `${formatBytes(v)}/s`}
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.swap_usage', 'Swap')}
|
||||
data={rows}
|
||||
dataKey="swapused"
|
||||
color="hsl(300, 60%, 50%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
/>
|
||||
<ChartCard
|
||||
tickFormatter={xTick}
|
||||
title={t('graphs.root_fs', 'Root filesystem')}
|
||||
data={rows}
|
||||
dataKey="rootused"
|
||||
color="hsl(180, 70%, 40%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, Monitor, Container, Plus, Pencil, Trash2, Wifi, Loader2, Clock, Layers, LayoutGrid, ExternalLink } from 'lucide-react';
|
||||
import { Server, Monitor, Container, Plus, Pencil, Trash2, Wifi, Loader2, Clock, Layers, LayoutGrid, ExternalLink, KeyRound } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -9,7 +9,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog';
|
||||
import { useServers, useCreateServer, useUpdateServer, useDeleteServer, useTestServerCredentials, useNodes } from '@/hooks/use-nodes';
|
||||
import { useServers, useCreateServer, useUpdateServer, useDeleteServer, useTestServerCredentials, useProvisionServerToken, useNodes } from '@/hooks/use-nodes';
|
||||
import { useVirtualMachines } from '@/hooks/use-instances';
|
||||
import type { ProxmoxServer, ProxmoxServerCreate } from '@/types';
|
||||
import { formatUptime } from '@/lib/format';
|
||||
@@ -83,15 +83,23 @@ function WorkspaceChips({ server }: { server: ProxmoxServer }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How the panel authenticates to Proxmox:
|
||||
* - auto — log in with the password once, panel creates its own API token
|
||||
* - token — an API token created by hand in the PVE UI
|
||||
* - password — password auth on every call (legacy)
|
||||
*/
|
||||
type AuthMode = 'auto' | 'token' | 'password';
|
||||
|
||||
interface ServerFormData {
|
||||
name: string;
|
||||
hostname: string;
|
||||
ip_address: string;
|
||||
port: string;
|
||||
api_user: string;
|
||||
auth_mode: AuthMode;
|
||||
api_token_name: string;
|
||||
api_token_value: string;
|
||||
use_password: boolean;
|
||||
password: string;
|
||||
verify_ssl: boolean;
|
||||
description: string;
|
||||
@@ -103,9 +111,9 @@ const emptyForm: ServerFormData = {
|
||||
ip_address: '',
|
||||
port: '8006',
|
||||
api_user: 'root@pam',
|
||||
auth_mode: 'auto',
|
||||
api_token_name: '',
|
||||
api_token_value: '',
|
||||
use_password: false,
|
||||
password: '',
|
||||
verify_ssl: false,
|
||||
description: '',
|
||||
@@ -154,7 +162,11 @@ function ServerFormDialog({
|
||||
// "не менять" — отправлять его нельзя, иначе бэкенд затрёт реальный
|
||||
// токен/пароль пустой строкой и сервер уйдёт в offline.
|
||||
const isEdit = serverId != null;
|
||||
if (form.use_password) {
|
||||
if (form.auth_mode === 'auto') {
|
||||
// Пароль уходит на бэкенд, тот подключается им один раз и создаёт токен.
|
||||
payload.auto_create_token = true;
|
||||
payload.password = form.password;
|
||||
} else if (form.auth_mode === 'password') {
|
||||
if (!isEdit || form.password) {
|
||||
payload.use_password = true;
|
||||
payload.password = form.password;
|
||||
@@ -170,8 +182,13 @@ function ServerFormDialog({
|
||||
onSubmit(buildPayload());
|
||||
};
|
||||
|
||||
const credentialsMissing =
|
||||
form.auth_mode === 'token'
|
||||
? !form.api_token_name || !form.api_token_value
|
||||
: !form.password;
|
||||
|
||||
const handleTest = () => {
|
||||
if (form.use_password ? !form.password : (!form.api_token_name || !form.api_token_value)) {
|
||||
if (credentialsMissing) {
|
||||
toast.error(t('nodes.connection_failed'));
|
||||
return;
|
||||
}
|
||||
@@ -208,21 +225,22 @@ function ServerFormDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="use_password"
|
||||
checked={form.use_password}
|
||||
onChange={e => set('use_password', e.target.checked)}
|
||||
/>
|
||||
<Label htmlFor="use_password" className="cursor-pointer">{t('nodes.use_password')}</Label>
|
||||
<div>
|
||||
<Label>{t('nodes.auth_mode')}</Label>
|
||||
<select
|
||||
value={form.auth_mode}
|
||||
onChange={e => set('auth_mode', e.target.value as AuthMode)}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none"
|
||||
>
|
||||
{/* Перевыпуск токена для существующего сервера — отдельным
|
||||
действием на карточке, а не через форму редактирования. */}
|
||||
{serverId == null && <option value="auto">{t('nodes.auth_mode_auto')}</option>}
|
||||
<option value="token">{t('nodes.auth_mode_token')}</option>
|
||||
<option value="password">{t('nodes.auth_mode_password')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{form.use_password ? (
|
||||
<div>
|
||||
<Label>{t('wizard.password')}</Label>
|
||||
<Input type="password" value={form.password} onChange={e => set('password', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
) : (
|
||||
{form.auth_mode === 'token' ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{t('nodes.api_token_name')}</Label>
|
||||
@@ -233,6 +251,14 @@ function ServerFormDialog({
|
||||
<Input value={form.api_token_value} onChange={e => set('api_token_value', e.target.value)} className="mt-1" placeholder="xxxxxxxx-xxxx-..." />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label>{t('wizard.password')}</Label>
|
||||
<Input type="password" value={form.password} onChange={e => set('password', e.target.value)} className="mt-1" />
|
||||
{form.auth_mode === 'auto' && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('nodes.auth_mode_auto_hint')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -278,6 +304,7 @@ export default function NodesPage() {
|
||||
const createServer = useCreateServer();
|
||||
const updateServer = useUpdateServer();
|
||||
const deleteServer = useDeleteServer();
|
||||
const provisionToken = useProvisionServerToken();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -310,6 +337,14 @@ export default function NodesPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleProvisionToken = async (id: number, name: string) => {
|
||||
if (!await confirm(t('nodes.switch_to_token_confirm', { name }), { title: t('nodes.switch_to_token') })) return;
|
||||
provisionToken.mutate(id, {
|
||||
onSuccess: (res) => toast.success(t('nodes.switch_to_token_ok', { token: res.token_name })),
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -353,6 +388,18 @@ export default function NodesPage() {
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{srv.use_password && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title={t('nodes.switch_to_token')}
|
||||
disabled={provisionToken.isPending}
|
||||
onClick={() => handleProvisionToken(srv.id, srv.name)}
|
||||
>
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -365,9 +412,9 @@ export default function NodesPage() {
|
||||
ip_address: srv.ip_address,
|
||||
port: String(srv.port || 8006),
|
||||
api_user: srv.api_user || 'root@pam',
|
||||
auth_mode: srv.use_password ? 'password' : 'token',
|
||||
api_token_name: '',
|
||||
api_token_value: '',
|
||||
use_password: false,
|
||||
password: '',
|
||||
verify_ssl: srv.verify_ssl || false,
|
||||
description: srv.description || '',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { ProxmoxServer, ProxmoxNode, ProxmoxServerCreate, NodeNetworkInterface } from '@/types';
|
||||
import { vmKeys } from './use-instances';
|
||||
@@ -53,11 +53,76 @@ export function useNodeStatus(serverId: number, node: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useNodeRrddata(serverId: number, node: string, timeframe = 'hour') {
|
||||
return useQuery({
|
||||
/** One normalized sample of node RRD data — mirrors MetricPoint for instances. */
|
||||
export interface NodeMetricPoint {
|
||||
time: number;
|
||||
cpu: number | null; // percent
|
||||
iowait: number | null; // percent
|
||||
loadavg: number | null;
|
||||
memused: number | null; // bytes
|
||||
memtotal: number | null; // bytes
|
||||
mempct: number | null; // percent
|
||||
swapused: number | null; // bytes
|
||||
rootused: number | null; // bytes
|
||||
rootpct: number | null; // percent
|
||||
netin: number | null; // bytes/s
|
||||
netout: number | null; // bytes/s
|
||||
}
|
||||
|
||||
/** Raw RRD rows report cpu/iowait as 0..1 fractions and may omit fields entirely. */
|
||||
function num(row: Record<string, unknown>, key: string): number | null {
|
||||
const v = row[key];
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function pct(used: number | null, total: number | null): number | null {
|
||||
if (used == null || !total) return null;
|
||||
return (used / total) * 100;
|
||||
}
|
||||
|
||||
export function normalizeNodeRrd(rows: Array<Record<string, unknown>>): NodeMetricPoint[] {
|
||||
return rows.map((r) => {
|
||||
const cpu = num(r, 'cpu');
|
||||
const iowait = num(r, 'iowait');
|
||||
const memused = num(r, 'memused');
|
||||
const memtotal = num(r, 'memtotal');
|
||||
const rootused = num(r, 'rootused');
|
||||
const roottotal = num(r, 'roottotal');
|
||||
return {
|
||||
time: num(r, 'time') ?? 0,
|
||||
cpu: cpu == null ? null : cpu * 100,
|
||||
iowait: iowait == null ? null : iowait * 100,
|
||||
loadavg: num(r, 'loadavg'),
|
||||
memused,
|
||||
memtotal,
|
||||
mempct: pct(memused, memtotal),
|
||||
swapused: num(r, 'swapused'),
|
||||
rootused,
|
||||
rootpct: pct(rootused, roottotal),
|
||||
netin: num(r, 'netin'),
|
||||
netout: num(r, 'netout'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Node metrics for the graphs tab. PVE returns raw RRD rows (cpu as a 0..1
|
||||
* fraction, no percentages), so normalization happens here to keep the chart
|
||||
* components identical to the instance ones.
|
||||
*/
|
||||
export function useNodeMetrics(serverId: number, node: string, timeframe = 'hour') {
|
||||
return useQuery<NodeMetricPoint[]>({
|
||||
queryKey: nodeKeys.nodeRrd(serverId, node, timeframe),
|
||||
queryFn: () => apiClient.get<unknown[]>(`/proxmox/api/${serverId}/node/rrddata?node=${node}&timeframe=${timeframe}`),
|
||||
queryFn: () =>
|
||||
apiClient
|
||||
.get<{ data?: Array<Record<string, unknown>> } | Array<Record<string, unknown>>>(
|
||||
`/proxmox/api/${serverId}/node/rrddata?node=${node}&timeframe=${timeframe}`,
|
||||
)
|
||||
.then((r) => normalizeNodeRrd(Array.isArray(r) ? r : (r?.data ?? []))),
|
||||
enabled: serverId > 0 && !!node,
|
||||
// Keep the previous series on screen while a new timeframe loads.
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: timeframe === 'hour' ? 60000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,6 +187,23 @@ export function useDeleteServer() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch a password-authenticated server over to an API token the panel creates
|
||||
* for itself. Uses the password already stored for that server.
|
||||
*/
|
||||
export function useProvisionServerToken() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
apiClient.post<{ success: boolean; token_name: string }>(
|
||||
`/proxmox/api/servers/${id}/provision-token`, {},
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: nodeKeys.servers });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestServer() {
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiClient.post<{ success: boolean; status?: string; message?: string }>(`/proxmox/api/servers/${id}/test`, {}),
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"cluster_nodes": "Cluster Nodes",
|
||||
"instances_on_server": "Instances on server",
|
||||
"tab_overview": "Overview",
|
||||
"tab_graphs": "Graphs",
|
||||
"tab_network": "Network",
|
||||
"tab_ha": "HA",
|
||||
"tab_firewall": "Firewall",
|
||||
@@ -619,6 +620,14 @@
|
||||
"api_token_name": "API Token Name",
|
||||
"api_token_value": "API Token Value",
|
||||
"use_password": "Use password auth",
|
||||
"auth_mode": "Connection method",
|
||||
"auth_mode_auto": "Login and password (panel creates an API token)",
|
||||
"auth_mode_token": "Existing API token",
|
||||
"auth_mode_password": "Password only",
|
||||
"auth_mode_auto_hint": "The password is used once to create an API token. From then on the panel works through the token, which you can revoke in Proxmox at any time.",
|
||||
"switch_to_token": "Switch to API token",
|
||||
"switch_to_token_confirm": "Create an API token for server \"{{name}}\" and switch the panel to it? The stored password is used.",
|
||||
"switch_to_token_ok": "Server switched to token \"{{token}}\"",
|
||||
"verify_ssl": "Verify SSL",
|
||||
"connection_ok": "Connection successful",
|
||||
"connection_failed": "Connection failed",
|
||||
@@ -1491,7 +1500,11 @@
|
||||
"network_in": "Network In",
|
||||
"network_out": "Network Out",
|
||||
"disk_read": "Disk Read",
|
||||
"disk_write": "Disk Write"
|
||||
"disk_write": "Disk Write",
|
||||
"io_delay": "IO Delay",
|
||||
"load_average": "Load Average",
|
||||
"swap_usage": "Swap Usage",
|
||||
"root_fs": "Root Filesystem"
|
||||
},
|
||||
"console": {
|
||||
"connecting": "Connecting to console...",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"cluster_nodes": "Узлы кластера",
|
||||
"instances_on_server": "Инстансы на сервере",
|
||||
"tab_overview": "Обзор",
|
||||
"tab_graphs": "Графики",
|
||||
"tab_network": "Сеть",
|
||||
"tab_ha": "HA",
|
||||
"tab_firewall": "Firewall",
|
||||
@@ -619,6 +620,14 @@
|
||||
"api_token_name": "Имя API токена",
|
||||
"api_token_value": "Значение API токена",
|
||||
"use_password": "Использовать аутентификацию по паролю",
|
||||
"auth_mode": "Способ подключения",
|
||||
"auth_mode_auto": "Логин и пароль (панель создаст API-токен)",
|
||||
"auth_mode_token": "Готовый API-токен",
|
||||
"auth_mode_password": "Только пароль",
|
||||
"auth_mode_auto_hint": "Пароль будет использован один раз для создания API-токена. Дальше панель работает по токену — его можно отозвать в Proxmox в любой момент.",
|
||||
"switch_to_token": "Перевести на API-токен",
|
||||
"switch_to_token_confirm": "Создать API-токен для сервера «{{name}}» и переключить панель на него? Используется сохранённый пароль.",
|
||||
"switch_to_token_ok": "Сервер переключён на токен «{{token}}»",
|
||||
"verify_ssl": "Проверять SSL",
|
||||
"connection_ok": "Соединение успешно",
|
||||
"connection_failed": "Ошибка соединения",
|
||||
@@ -1491,7 +1500,11 @@
|
||||
"network_in": "Входящий трафик",
|
||||
"network_out": "Исходящий трафик",
|
||||
"disk_read": "Чтение диска",
|
||||
"disk_write": "Запись диска"
|
||||
"disk_write": "Запись диска",
|
||||
"io_delay": "Задержка ввода-вывода",
|
||||
"load_average": "Средняя нагрузка",
|
||||
"swap_usage": "Использование swap",
|
||||
"root_fs": "Корневая ФС"
|
||||
},
|
||||
"console": {
|
||||
"connecting": "Подключение к консоли...",
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface ProxmoxServerCreate {
|
||||
api_token_value?: string;
|
||||
use_password?: boolean;
|
||||
password?: string;
|
||||
/** Authenticate once with the password, then let the panel create its own API token. */
|
||||
auto_create_token?: boolean;
|
||||
verify_ssl?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user