3 Commits

Author SHA1 Message Date
markmorado
db7411b1bf docs: add development roadmap
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
Records the six agreed directions with their status, the files each one
touches, the steps involved and the decisions already made, so the work can
be picked up without prior context.
2026-07-24 04:03:37 +05:00
markmorado
65c61e7886 feat(servers): auto-provision API tokens and add node metric graphs
Servers can now be connected with a login and password: the panel
authenticates once, creates its own API token on the node (privsep=0) and
uses it from then on. The token can be revoked from the Proxmox UI, cannot
be used for SSH or web login, and appears in the PVE audit log under its
own name. The password stays stored (encrypted) because ProxmoxClient needs
it for SSH fallbacks such as pvecm delnode.

- add proxmox/token_provisioning.py: token creation with free-name
  selection and revoke-on-failure so a failed registration does not leave
  an orphaned token whose secret was never persisted
- POST /api/servers accepts auto_create_token
- add POST /api/servers/{id}/provision-token to migrate servers that are
  still on password auth
- store api_token_value as EncryptedString; it was kept in plaintext while
  the password next to it was already encrypted
- drop the unused ProxmoxServerBase schema, a stale copy of the create
  schema whose validator rejected the new auth mode

Node detail pages gain a Graphs tab (CPU, IO delay, memory, load average,
network, swap, root filesystem) built on the existing node rrddata
endpoint. The chart card is extracted from the instance graphs tab so both
views share it, and X axis ticks now switch to dates on multi-day ranges.
2026-07-24 04:03:30 +05:00
markmorado
b66424de12 chore: ignore .claude directory 2026-07-24 04:02:54 +05:00
15 changed files with 959 additions and 188 deletions

1
.gitignore vendored
View File

@@ -35,6 +35,7 @@ systemd/
# Editors
.vscode/
.idea/
.claude/
# System files
.DS_Store

View File

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

View File

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

View 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

View File

@@ -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
View 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`. Нужно решить, форсить ли перестроение.

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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": "Подключение к консоли...",

View File

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