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

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