feat: SSH keys management, backups fixes, instances/nodes/templates improvements, dev mode support
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled

This commit is contained in:
markmorado
2026-05-07 00:30:21 +02:00
parent a92cb0df33
commit 323d1e57fa
40 changed files with 4112 additions and 288 deletions

View File

@@ -19,8 +19,13 @@ export function useBackupStorages(serverId: number) {
export function useBackupList(serverId: number, node: string, storage: string) {
return useQuery({
queryKey: backupKeys.list(serverId, node, storage),
queryFn: () => apiClient.get<{ backups: unknown[] }>(`/proxmox/api/backups/list/${serverId}?node=${node}&storage=${storage}`),
enabled: serverId > 0 && !!node && !!storage,
queryFn: () => {
const params = new URLSearchParams();
params.set('storage', storage);
if (node) params.set('node', node);
return apiClient.get<{ backups: unknown[] }>(`/proxmox/api/backups/list/${serverId}?${params.toString()}`);
},
enabled: serverId > 0 && !!storage,
});
}
@@ -44,7 +49,11 @@ export function useDeleteBackup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { server_id: number; node: string; storage: string; volid: string }) =>
apiClient.delete(`/proxmox/api/backups/${data.server_id}/backup`),
apiClient.delete(`/proxmox/api/backups/${data.server_id}/backup`, {
node: data.node,
storage: data.storage,
volid: data.volid,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['backups'] }),
});
}
@@ -56,6 +65,48 @@ export function useRestoreBackup() {
});
}
export type BackupJobInput = {
server_id: number;
node: string;
vmids: (number | string)[];
storage: string;
mode?: string;
compress?: string;
notes?: string;
keep_last?: number;
keep_daily?: number;
keep_weekly?: number;
keep_monthly?: number;
cron_expression: string;
enabled?: boolean;
};
export function useCreateBackupJob() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: BackupJobInput) =>
apiClient.post<{ success: boolean; job: unknown }>('/proxmox/api/backups/jobs', data),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.jobs }),
});
}
export function useUpdateBackupJob() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<BackupJobInput> }) =>
apiClient.put<{ success: boolean; job: unknown }>(`/proxmox/api/backups/jobs/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.jobs }),
});
}
export function useDeleteBackupJob() {
const qc = useQueryClient();
return useMutation({
mutationFn: (jobId: number) => apiClient.delete<{ success: boolean }>(`/proxmox/api/backups/jobs/${jobId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.jobs }),
});
}
export function useToggleBackupJob() {
const qc = useQueryClient();
return useMutation({

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { apiClient } from '@/lib/api-client';
import { getTasksWebSocket } from '@/lib/websocket';
import type { VMInstance, Snapshot, VMConfig } from '@/types';
@@ -18,21 +18,25 @@ export const vmKeys = {
};
// ==================== VM List ====================
// No HTTP polling: live metrics arrive via WebSocket (`useInstancesMetricsSync`).
// We still refetch on reconnect/focus to recover from missed events.
export function useVirtualMachines() {
return useQuery({
queryKey: vmKeys.all,
queryFn: () => apiClient.get<VMInstance[]>('/proxmox/api/virtual-machines'),
refetchInterval: 30000,
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
});
}
/** Subscribes to WebSocket vm_status_update events and patches the VM list cache in real time. */
/** Subscribes to WebSocket vm_status_update / vm_deleted / vm_created events and patches the VM list cache in real time. */
export function useVMStatusSync() {
const qc = useQueryClient();
useEffect(() => {
const ws = getTasksWebSocket();
ws.connect();
const unsub = ws.onType('vm_status_update', (data: unknown) => {
const unsubStatus = ws.onType('vm_status_update', (data: unknown) => {
const msg = data as { vmid: number; server_id: number; status: string };
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
old?.map((vm) =>
@@ -42,10 +46,56 @@ export function useVMStatusSync() {
)
);
});
return () => unsub();
const unsubDeleted = ws.onType('vm_deleted', (data: unknown) => {
const msg = data as { vmid: number; server_id: number };
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
old?.filter((vm) => !(vm.server_id === msg.server_id && vm.vmid === msg.vmid))
);
// Also invalidate any per-VM detail queries so stale pages refresh
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
});
const unsubCreated = ws.onType('vm_created', () => {
// A new VM appeared on Proxmox; refetch the full list
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
});
return () => {
unsubStatus();
unsubDeleted();
unsubCreated();
};
}, [qc]);
}
export type LiveInstanceMetrics = {
server_id: number; vmid: number; node?: string; status?: string;
cpu?: number; mem?: number; maxmem?: number; disk?: number; maxdisk?: number;
uptime?: number; netin?: number; netout?: number;
};
/**
* Subscribes to live VM/LXC metrics via WebSocket channel `instances_list_metrics`.
* Returns a Map keyed by `"server_id:vmid"` that updates every ~1 s.
* Uses its own useState — bypasses TanStack Query structural sharing entirely.
*/
export function useInstancesMetricsSync(): Map<string, LiveInstanceMetrics> {
const [metricsMap, setMetricsMap] = useState(() => new Map<string, LiveInstanceMetrics>());
useEffect(() => {
const ws = getTasksWebSocket();
ws.connect();
const apply = (data: unknown) => {
const items = (data as { data?: { items?: LiveInstanceMetrics[] } })?.data?.items;
if (!items?.length) return;
const next = new Map<string, LiveInstanceMetrics>();
for (const it of items) next.set(`${it.server_id}:${it.vmid}`, it);
setMetricsMap(next);
};
const unsub = ws.subscribe('instances_list_metrics', apply);
return () => unsub();
}, []);
return metricsMap;
}
export function useAllResources() {
return useQuery({
queryKey: vmKeys.resourcesAll,

View File

@@ -99,6 +99,13 @@ export function useDeleteServer() {
export function useTestServer() {
return useMutation({
mutationFn: (id: number) => apiClient.post<{ success: boolean; message?: string }>(`/proxmox/api/servers/${id}/test`, {}),
mutationFn: (id: number) => apiClient.post<{ success: boolean; status?: string; message?: string }>(`/proxmox/api/servers/${id}/test`, {}),
});
}
export function useTestServerCredentials() {
return useMutation({
mutationFn: (data: ProxmoxServerCreate) =>
apiClient.post<{ success: boolean; status?: string; message?: string }>(`/proxmox/api/servers/test`, data),
});
}

View File

@@ -0,0 +1,132 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export interface SSHKey {
id: number;
user_id: number;
name: string;
public_key: string;
fingerprint: string | null;
comment: string | null;
has_private_key: boolean;
created_at: string;
}
export interface CreateSSHKeyPayload {
name: string;
public_key: string;
private_key?: string;
comment?: string;
}
export interface UpdateSSHKeyPayload {
name?: string;
public_key?: string;
private_key?: string;
comment?: string;
}
export const sshKeyKeys = {
my: ['ssh-keys', 'my'] as const,
byUser: (userId: number) => ['ssh-keys', 'user', userId] as const,
};
// ── Self-service ────────────────────────────────────────────────────────────
export function useMySSHKeys() {
return useQuery({
queryKey: sshKeyKeys.my,
queryFn: () => apiClient.get<SSHKey[]>('/api/ssh-keys/my'),
});
}
export function useCreateMySSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateSSHKeyPayload) =>
apiClient.post<SSHKey>('/api/ssh-keys/my', data),
onSuccess: () => qc.invalidateQueries({ queryKey: sshKeyKeys.my }),
});
}
export function useUpdateMySSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateSSHKeyPayload }) =>
apiClient.put<SSHKey>(`/api/ssh-keys/my/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: sshKeyKeys.my }),
});
}
export function useDeleteMySSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiClient.delete<{ success: boolean; message: string }>(`/api/ssh-keys/my/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: sshKeyKeys.my }),
});
}
export function useDownloadMyPrivateKey() {
return useMutation({
mutationFn: (id: number) =>
apiClient.get<{ id: number; name: string; private_key: string }>(
`/api/ssh-keys/my/${id}/private`,
),
});
}
// ── Admin: per-user ─────────────────────────────────────────────────────────
export function useUserSSHKeys(userId: number | null | undefined) {
return useQuery({
queryKey: userId ? sshKeyKeys.byUser(userId) : ['ssh-keys', 'user', 'none'],
queryFn: () =>
apiClient.get<SSHKey[]>(`/api/ssh-keys/users/${userId}`),
enabled: !!userId,
});
}
export function useAdminCreateUserSSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, data }: { userId: number; data: CreateSSHKeyPayload }) =>
apiClient.post<SSHKey>(`/api/ssh-keys/users/${userId}`, data),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: sshKeyKeys.byUser(vars.userId) });
},
});
}
export function useAdminUpdateUserSSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, keyId, data }: { userId: number; keyId: number; data: UpdateSSHKeyPayload }) =>
apiClient.put<SSHKey>(`/api/ssh-keys/users/${userId}/${keyId}`, data),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: sshKeyKeys.byUser(vars.userId) });
},
});
}
export function useAdminDeleteUserSSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, keyId }: { userId: number; keyId: number }) =>
apiClient.delete<{ success: boolean; message: string }>(
`/api/ssh-keys/users/${userId}/${keyId}`,
),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: sshKeyKeys.byUser(vars.userId) });
},
});
}
export function useAdminDownloadUserPrivateKey() {
return useMutation({
mutationFn: ({ userId, keyId }: { userId: number; keyId: number }) =>
apiClient.get<{ id: number; name: string; private_key: string }>(
`/api/ssh-keys/users/${userId}/${keyId}/private`,
),
});
}

View File

@@ -4,7 +4,7 @@ import type { OSTemplate, OSTemplateGroup } from '@/types';
export const templateKeys = {
groups: ['template-groups'] as const,
templates: (groupId?: number, serverId?: number) => ['templates', groupId, serverId] as const,
templates: (groupId?: number, serverId?: number, vmType?: string) => ['templates', groupId, serverId, vmType] as const,
template: (id: number) => ['template', id] as const,
discover: (serverId: number) => ['template-discover', serverId] as const,
};
@@ -16,13 +16,14 @@ export function useTemplateGroups() {
});
}
export function useTemplates(groupId?: number, serverId?: number) {
export function useTemplates(groupId?: number, serverId?: number, vmType?: string) {
const params = new URLSearchParams();
if (groupId) params.set('group_id', String(groupId));
if (serverId) params.set('server_id', String(serverId));
if (vmType) params.set('vm_type', vmType);
const qs = params.toString();
return useQuery({
queryKey: templateKeys.templates(groupId, serverId),
queryKey: templateKeys.templates(groupId, serverId, vmType),
queryFn: () => apiClient.get<OSTemplate[]>(`/templates/api/templates${qs ? `?${qs}` : ''}`),
});
}

View File

@@ -5,6 +5,7 @@ import type { User } from '@/types';
export const userKeys = {
users: ['users'] as const,
user: (id: number) => ['user', id] as const,
userServers: (id: number) => ['user-server-assignments', id] as const,
roles: ['roles'] as const,
permissions: ['permissions-v2'] as const,
sessions: ['sessions'] as const,
@@ -13,17 +14,80 @@ export const userKeys = {
securityEvents: ['security-events'] as const,
};
interface Role {
export interface Role {
id: number;
name: string;
display_name: string;
description?: string;
permissions: string[];
permissions: Record<string, boolean>;
is_system: boolean;
is_active: boolean;
user_count: number;
created_at?: string;
}
export interface SessionInfo {
id: number;
user_id: number;
username: string;
ip_address?: string;
device_info?: string;
created_at?: string;
last_activity?: string;
expires_at?: string;
}
export interface BlockedIp {
id: number;
ip_address: string;
reason?: string;
blocked_by?: string;
blocked_at?: string;
expires_at?: string;
is_permanent: boolean;
attempts_count?: number;
is_active: boolean;
}
export interface SecuritySettings {
max_login_attempts: number;
lockout_duration_minutes: number;
session_timeout_minutes: number;
ip_block_threshold: number;
ip_block_duration_minutes: number;
password_min_length: number;
password_require_uppercase: boolean;
password_require_lowercase: boolean;
password_require_numbers: boolean;
password_require_special: boolean;
}
export interface SecurityEvent {
id: number;
event_type: string;
ip_address?: string;
username?: string;
message?: string;
created_at?: string;
}
export interface ServerAssignment {
id: number;
name: string;
ip_address: string;
is_online: boolean;
workspaces: { id: number; name: string }[];
compatible: boolean;
assigned: boolean;
}
export interface UserServerAssignmentsResponse {
servers: ServerAssignment[];
user_workspaces: { id: number; name: string }[];
}
// ==================== Users ====================
export function useUsers() {
return useQuery({
queryKey: userKeys.users,
@@ -39,21 +103,43 @@ export function useUser(userId: number) {
});
}
export interface CreateUserPayload {
username: string;
email: string;
password: string;
full_name?: string;
role_id?: number | null;
is_active?: boolean;
is_admin?: boolean;
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; email: string; password: string; full_name?: string; role_id?: number; is_active?: boolean; is_admin?: boolean }) =>
apiClient.post<User>('/admin/api/users', data),
mutationFn: (data: CreateUserPayload) => apiClient.post<User>('/admin/api/users', data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export interface UpdateUserPayload {
email?: string;
full_name?: string;
password?: string;
role_id?: number | null;
is_active?: boolean;
is_admin?: boolean;
require_password_change?: boolean;
}
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number; email?: string; full_name?: string; role_id?: number; is_active?: boolean; is_admin?: boolean }) =>
mutationFn: ({ id, ...data }: { id: number } & UpdateUserPayload) =>
apiClient.put<User>(`/admin/api/users/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: userKeys.users });
qc.invalidateQueries({ queryKey: userKeys.user(vars.id) });
},
});
}
@@ -68,8 +154,10 @@ export function useDeleteUser() {
export function useResetPassword() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/reset-password`, {}),
mutationFn: ({ userId, newPassword }: { userId: number; newPassword: string }) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/reset-password`, {
new_password: newPassword,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
@@ -77,11 +165,51 @@ export function useResetPassword() {
export function useUnlockUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) => apiClient.post<{ message: string }>(`/admin/api/users/${userId}/unlock`),
mutationFn: (userId: number) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/unlock`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export function useTerminateUserSessions() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/terminate-sessions`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.sessions }),
});
}
// ==================== User → Servers ====================
export function useUserServerAssignments(userId: number) {
return useQuery({
queryKey: userKeys.userServers(userId),
queryFn: () =>
apiClient.get<UserServerAssignmentsResponse>(
`/admin/api/users/${userId}/server-assignments`
),
enabled: userId > 0,
});
}
export function useSetUserServers() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, serverIds }: { userId: number; serverIds: number[] }) =>
apiClient.put<{ message: string; server_ids: number[] }>(
`/admin/api/users/${userId}/servers`,
{ server_ids: serverIds }
),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: userKeys.userServers(vars.userId) });
qc.invalidateQueries({ queryKey: userKeys.users });
},
});
}
// ==================== Roles ====================
export function useRoles() {
return useQuery({
queryKey: userKeys.roles,
@@ -92,8 +220,12 @@ export function useRoles() {
export function useCreateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; display_name: string; description?: string; permissions: string[] }) =>
apiClient.post<Role>('/admin/api/roles', data),
mutationFn: (data: {
name: string;
display_name: string;
description?: string;
permissions: Record<string, boolean>;
}) => apiClient.post<Role>('/admin/api/roles', data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
});
}
@@ -101,8 +233,16 @@ export function useCreateRole() {
export function useUpdateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number; display_name?: string; description?: string; permissions?: string[]; is_active?: boolean }) =>
apiClient.put<Role>(`/admin/api/roles/${id}`, data),
mutationFn: ({
id,
...data
}: {
id: number;
display_name?: string;
description?: string;
permissions?: Record<string, boolean>;
is_active?: boolean;
}) => apiClient.put<Role>(`/admin/api/roles/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
});
}
@@ -118,15 +258,18 @@ export function useDeleteRole() {
export function usePermissions() {
return useQuery({
queryKey: userKeys.permissions,
queryFn: () => apiClient.get<Record<string, unknown>>('/admin/api/permissions/v2'),
queryFn: () =>
apiClient.get<Record<string, Record<string, string>>>('/admin/api/permissions/v2'),
staleTime: Infinity,
});
}
// ==================== Sessions ====================
export function useSessions() {
return useQuery({
queryKey: userKeys.sessions,
queryFn: () => apiClient.get<any[]>('/admin/api/sessions'),
queryFn: () => apiClient.get<SessionInfo[]>('/admin/api/sessions'),
refetchInterval: 15000,
});
}
@@ -134,7 +277,70 @@ export function useSessions() {
export function useTerminateSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (sessionId: string) => apiClient.delete(`/admin/api/sessions/${sessionId}`),
mutationFn: (sessionId: number) => apiClient.delete(`/admin/api/sessions/${sessionId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.sessions }),
});
}
export function useTerminateAllSessions() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiClient.post<{ message: string }>('/admin/api/sessions/terminate-all'),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.sessions }),
});
}
// ==================== Security ====================
export function useSecuritySettings() {
return useQuery({
queryKey: userKeys.securitySettings,
queryFn: () => apiClient.get<SecuritySettings>('/admin/api/security/settings'),
});
}
export function useUpdateSecuritySettings() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<SecuritySettings>) =>
apiClient.put<{ message: string; updated: string[] }>(
'/admin/api/security/settings',
data
),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.securitySettings }),
});
}
export function useBlockedIps() {
return useQuery({
queryKey: userKeys.blockedIps,
queryFn: () => apiClient.get<BlockedIp[]>('/admin/api/security/blocked-ips'),
refetchInterval: 30000,
});
}
export function useBlockIp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { ip_address: string; reason?: string; duration_minutes?: number }) =>
apiClient.post<{ message: string; id: number }>('/admin/api/security/blocked-ips', data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.blockedIps }),
});
}
export function useUnblockIp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/admin/api/security/blocked-ips/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.blockedIps }),
});
}
export function useSecurityEvents(limit = 50) {
return useQuery({
queryKey: [...userKeys.securityEvents, limit],
queryFn: () =>
apiClient.get<SecurityEvent[]>(`/admin/api/security/events?limit=${limit}`),
refetchInterval: 30000,
});
}