feat: real-time server CRUD sync and pve CLI auto-install
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled

Backend:
- servers.py: instant connectivity check + VM cache sync after create/update;
  broadcast server_added/updated/deleted events via WebSocket
- websocket_manager: add broadcast_event() helper for global fire-and-forget events

Frontend:
- use-nodes: invalidate topology, VM list and resources after server create/update/delete
- use-realtime-sync: new hook with useGlobalRealtimeSync subscribing to server_*
  WS events and invalidating relevant React Query caches
- AppLayout: mount useGlobalRealtimeSync globally

Deploy:
- deploy.sh: auto-install pve CLI during main/quick deploy (idempotent, --auto mode)
This commit is contained in:
markmorado
2026-05-11 05:31:52 +02:00
parent 742ff27fb4
commit 453c18f192
6 changed files with 284 additions and 11 deletions

View File

@@ -99,7 +99,49 @@ def create_proxmox_server(
db.add(server)
db.commit()
db.refresh(server)
# Сразу проверяем подключение, чтобы статус is_online отобразился без задержки
# в 30 секунд (период monitoring_worker.run_server_availability_check).
try:
client = _get_proxmox_client(server)
if client.is_connected():
server.update_status(True)
else:
server.update_status(False, "Failed to connect")
db.commit()
db.refresh(server)
except Exception as e:
logger.warning(f"Initial connectivity check failed for {server.name}: {e}")
try:
server.update_status(False, str(e))
db.commit()
db.refresh(server)
except Exception:
db.rollback()
# Если сервер онлайн — мгновенно подтягиваем VM/LXC в локальный кэш,
# чтобы NodeDetailPage и InstancesPage сразу увидели инстансы (без ожидания
# 10-секундного цикла vm_cache_sync).
if server.is_online:
try:
from ...workers.monitoring_worker import monitoring_worker
monitoring_worker.sync_vm_cache()
except Exception as e:
logger.warning(f"Initial VM cache sync failed for server {server.name}: {e}")
# Уведомляем все подключённые клиенты, что сервер добавлен — фронтенд
# сразу инвалидирует список серверов и список VM.
try:
from ...websocket_manager import broadcast_event
broadcast_event(
"server_added",
server_id=server.id,
name=server.name,
is_online=bool(server.is_online),
)
except Exception as e:
logger.debug(f"Failed to broadcast server_added: {e}")
logger.info(f"User {current_user.username} added Proxmox server: {server.name}")
return server
@@ -305,7 +347,19 @@ async def auto_setup_proxmox_server(
db.commit()
logger.info(f"User {current_user.username} auto-setup Proxmox cluster with {len(created_servers)} nodes")
# Мгновенный sync VM-кэша + WS-broadcast для real-time обновления UI
try:
from ...workers.monitoring_worker import monitoring_worker
monitoring_worker.sync_vm_cache()
except Exception as _e:
logger.debug(f"VM sync after auto-setup failed: {_e}")
try:
from ...websocket_manager import broadcast_event
broadcast_event("server_added", cluster=True, count=len(created_servers))
except Exception as _e:
logger.debug(f"Failed to broadcast server_added (cluster): {_e}")
return {
"cluster": True,
"nodes_count": len(created_servers),
@@ -334,7 +388,24 @@ async def auto_setup_proxmox_server(
db.refresh(server)
logger.info(f"User {current_user.username} auto-setup Proxmox server: {server.name}")
# Мгновенный sync VM-кэша + WS-broadcast для real-time обновления UI
try:
from ...workers.monitoring_worker import monitoring_worker
monitoring_worker.sync_vm_cache()
except Exception as _e:
logger.debug(f"VM sync after auto-setup failed: {_e}")
try:
from ...websocket_manager import broadcast_event
broadcast_event(
"server_added",
server_id=server.id,
name=server.name,
is_online=bool(server.is_online),
)
except Exception as _e:
logger.debug(f"Failed to broadcast server_added: {_e}")
return {
"cluster": False,
"id": server.id,
@@ -442,6 +513,50 @@ def update_proxmox_server(
db.refresh(server)
logger.info(f"User {current_user.username} updated Proxmox server: {server.name}")
# Если изменились параметры подключения — сразу проверяем доступность,
# чтобы статус обновился без 30-секундной задержки monitoring_worker.
creds_changed = any(
key in update_data
for key in ('password', 'api_token_name', 'api_token_value', 'api_user',
'ip_address', 'hostname', 'port', 'verify_ssl', 'use_password')
)
if creds_changed:
try:
client = _get_proxmox_client(server)
if client.is_connected():
server.update_status(True)
else:
server.update_status(False, "Failed to connect")
db.commit()
db.refresh(server)
if server.is_online:
try:
from ...workers.monitoring_worker import monitoring_worker
monitoring_worker.sync_vm_cache()
except Exception as _e:
logger.debug(f"VM sync after update failed: {_e}")
except Exception as e:
logger.warning(f"Connectivity check after update failed for {server.name}: {e}")
try:
server.update_status(False, str(e))
db.commit()
db.refresh(server)
except Exception:
db.rollback()
# Broadcast — фронтенд инвалидирует кэши списков серверов и инстансов
try:
from ...websocket_manager import broadcast_event
broadcast_event(
"server_updated",
server_id=server.id,
name=server.name,
is_online=bool(server.is_online),
)
except Exception as e:
logger.debug(f"Failed to broadcast server_updated: {e}")
return server
@@ -519,7 +634,18 @@ def delete_proxmox_server(
monitoring_worker.cleanup_server_state(server_id)
except Exception as e:
logger.warning(f"Failed to cleanup monitoring state for server {server_id}: {e}")
# Broadcast — фронтенд мгновенно убирает удалённый сервер и его инстансы
try:
from ...websocket_manager import broadcast_event
broadcast_event(
"server_deleted",
server_id=server_id,
name=server_name,
)
except Exception as e:
logger.debug(f"Failed to broadcast server_deleted: {e}")
return None

View File

@@ -167,3 +167,13 @@ def broadcast_task_update(user_id: int, event_type: str, task_data: dict) -> Non
"""
payload = {"type": event_type, "task": task_data}
run_async_safe(ws_manager.send_to_user(user_id, payload))
def broadcast_event(event_type: str, **fields) -> None:
"""
Fire-and-forget global broadcast of an event to all connected WebSocket
clients from a sync context. Used to push real-time invalidations such as
`server_added`, `server_updated`, `server_deleted`.
"""
payload = {"type": event_type, **fields}
run_async_safe(ws_manager.broadcast(payload))

View File

@@ -664,6 +664,7 @@ main() {
check_requirements
create_env_file
install_update_watchdog
install_pve_cli --auto
# Ask deployment mode
echo ""
@@ -739,6 +740,7 @@ quick_deploy_standalone() {
fi
deploy_standalone
install_pve_cli --auto
show_deployment_info "standalone"
}
@@ -787,6 +789,8 @@ quick_deploy_nginx() {
deploy_with_nginx "$domain" "$use_ssl" "$email"
ssl_result=$?
install_pve_cli --auto
if [ "$use_ssl" = true ] && [ $ssl_result -eq 0 ]; then
show_deployment_info "nginx" "$domain" true
else
@@ -931,37 +935,64 @@ EOF
}
# Install pve CLI tool to /usr/local/bin/pve
# Usage:
# install_pve_cli — интерактивный режим (--install-cli): печатает help в конце
# install_pve_cli --auto — авто-установка во время deploy: тихая, не падает без root
install_pve_cli() {
local mode="${1:-manual}"
local project_dir
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local pve_source="$project_dir/pve"
local install_path="/usr/local/bin/pve"
if [ ! -f "$pve_source" ]; then
if [ "$mode" = "--auto" ]; then
print_warning "CLI скрипт 'pve' не найден рядом с deploy.sh — пропускаю авто-установку CLI."
return 0
fi
print_error "CLI скрипт не найден: $pve_source"
print_info "Убедитесь, что файл 'pve' находится в той же директории, что и deploy.sh"
return 1
fi
if [ "$(id -u)" -ne 0 ]; then
if [ "$mode" = "--auto" ]; then
print_warning "Не root — пропускаю авто-установку 'pve' CLI в /usr/local/bin."
print_info "Чтобы установить вручную: sudo bash $project_dir/deploy.sh --install-cli"
return 0
fi
print_error "Установка требует root-прав."
print_info "Выполните: sudo bash deploy.sh --install-cli"
return 1
fi
# Идемпотентность: если уже установлен и указывает на ту же директорию — пропускаем
if [ "$mode" = "--auto" ] && [ -f "$install_path" ]; then
if grep -q "^PVE_DIR=\"$project_dir\"" "$install_path" 2>/dev/null; then
print_info "pve CLI уже установлен ($install_path → $project_dir)"
return 0
fi
fi
print_info "Установка pve CLI..."
print_info " Источник : $pve_source"
print_info " Установка : $install_path"
# Подставить реальный путь к директории панели
sed "s|PVE_DIR=\"/opt/pvemanager\"|PVE_DIR=\"$project_dir\"|g" \
"$pve_source" > "$install_path"
if ! sed "s|PVE_DIR=\"/opt/pvemanager\"|PVE_DIR=\"$project_dir\"|g" \
"$pve_source" > "$install_path"; then
print_error "Не удалось записать $install_path"
return 1
fi
chmod +x "$install_path"
print_success "pve CLI установлен в $install_path"
print_info "Теперь вы можете использовать команду 'pve' из любого места."
echo ""
"$install_path" help
if [ "$mode" != "--auto" ]; then
echo ""
"$install_path" help
fi
}
show_help() {

View File

@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { ProxmoxServer, ProxmoxNode, ProxmoxServerCreate } from '@/types';
import { vmKeys } from './use-instances';
export const nodeKeys = {
servers: ['servers'] as const,
@@ -76,7 +77,14 @@ export function useCreateServer() {
return useMutation({
mutationFn: (data: ProxmoxServerCreate) =>
apiClient.post<ProxmoxServer>('/proxmox/api/servers', data),
onSuccess: () => qc.invalidateQueries({ queryKey: nodeKeys.servers }),
onSuccess: () => {
// Немедленно подтягиваем список серверов, VM и топологию кластера,
// чтобы UI показал новый сервер со статусом и его инстансами без рефреша.
qc.invalidateQueries({ queryKey: nodeKeys.servers });
qc.invalidateQueries({ queryKey: nodeKeys.topology });
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}
@@ -85,7 +93,12 @@ export function useUpdateServer() {
return useMutation({
mutationFn: ({ id, ...data }: { id: number } & Partial<ProxmoxServerCreate>) =>
apiClient.put<ProxmoxServer>(`/proxmox/api/servers/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: nodeKeys.servers }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: nodeKeys.servers });
qc.invalidateQueries({ queryKey: nodeKeys.topology });
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}
@@ -93,7 +106,12 @@ export function useDeleteServer() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/proxmox/api/servers/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: nodeKeys.servers }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: nodeKeys.servers });
qc.invalidateQueries({ queryKey: nodeKeys.topology });
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}

View File

@@ -0,0 +1,82 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getTasksWebSocket } from '@/lib/websocket';
import { vmKeys } from './use-instances';
import { nodeKeys } from './use-nodes';
/**
* Глобальная подписка на real-time события WebSocket, которая инвалидирует
* соответствующие React Query кэши.
*
* Покрывает события:
* - vm_status_update / vm_created / vm_deleted — изменения VM/LXC.
* - server_added / server_updated / server_deleted — изменения списка
* Proxmox-серверов (в т.ч. их статус is_online).
*
* Подключается один раз в AppLayout — действует на всех страницах, чтобы
* данные обновлялись мгновенно без обновления страницы.
*/
export function useGlobalRealtimeSync() {
const qc = useQueryClient();
useEffect(() => {
const ws = getTasksWebSocket();
ws.connect();
// ── VM events ──────────────────────────────────────────────────────────
const unsubVmStatus = ws.onType('vm_status_update', (data: unknown) => {
const msg = data as { vmid: number; server_id: number; status: string };
qc.setQueryData<Array<Record<string, unknown>>>(vmKeys.all, (old) =>
old?.map((vm) =>
vm.server_id === msg.server_id && vm.vmid === msg.vmid
? { ...vm, status: msg.status }
: vm
)
);
});
const unsubVmDeleted = ws.onType('vm_deleted', (data: unknown) => {
const msg = data as { vmid: number; server_id: number };
qc.setQueryData<Array<Record<string, unknown>>>(vmKeys.all, (old) =>
old?.filter((vm) => !(vm.server_id === msg.server_id && vm.vmid === msg.vmid))
);
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
});
const unsubVmCreated = ws.onType('vm_created', () => {
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
});
// ── Server events ──────────────────────────────────────────────────────
const invalidateServerCaches = () => {
qc.invalidateQueries({ queryKey: nodeKeys.servers });
qc.invalidateQueries({ queryKey: nodeKeys.topology });
// Список VM/LXC зависит от набора серверов и их online-статуса
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
};
const unsubServerAdded = ws.onType('server_added', invalidateServerCaches);
const unsubServerUpdated = ws.onType('server_updated', invalidateServerCaches);
const unsubServerDeleted = ws.onType('server_deleted', (data: unknown) => {
const msg = data as { server_id?: number };
// Локально удаляем VM удалённого сервера, чтобы UI отреагировал мгновенно
if (msg?.server_id != null) {
qc.setQueryData<Array<Record<string, unknown>>>(vmKeys.all, (old) =>
old?.filter((vm) => vm.server_id !== msg.server_id)
);
}
invalidateServerCaches();
});
return () => {
unsubVmStatus();
unsubVmDeleted();
unsubVmCreated();
unsubServerAdded();
unsubServerUpdated();
unsubServerDeleted();
};
}, [qc]);
}

View File

@@ -36,6 +36,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { useThemeStore } from '@/stores/theme-store';
import { useWorkspaceStore } from '@/stores/workspace-store';
import { useWorkspaces } from '@/hooks/use-workspaces';
import { useGlobalRealtimeSync } from '@/hooks/use-realtime-sync';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import NotificationsDropdown from '@/components/shared/NotificationsDropdown';
@@ -266,6 +267,11 @@ function TopBar() {
}
export default function AppLayout() {
// Глобальная подписка на real-time события WebSocket: server_added/updated/deleted,
// vm_status_update/created/deleted. Кэши React Query инвалидируются мгновенно,
// поэтому пользователю не нужно обновлять страницу.
useGlobalRealtimeSync();
return (
<div className="flex h-screen overflow-hidden">
{/* Desktop sidebar */}