feat: real-time server CRUD sync and pve CLI auto-install
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
39
deploy.sh
39
deploy.sh
@@ -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() {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
82
frontend/src/hooks/use-realtime-sync.ts
Normal file
82
frontend/src/hooks/use-realtime-sync.ts
Normal 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]);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user