fix: frontend-backend integration — 14 issues resolved

- nginx: add proxy routes for /settings/, /templates/, /ipam/, /logs/, /admin/, /health
- dashboard: fix data format mismatch (servers[] not nodes[]/vms[])
- dashboard: add WebSocket subscription to dashboard_metrics for real-time updates
- dashboard: connect activity feed to /logs/api/logs endpoint
- dashboard: reduce polling to 15s with WS as primary update source
- graphs: fix rrddata response unwrap (backend returns {data:[],timeframe:''})
- graphs: add i18n translations for chart titles
- console: add xterm.js CSS import for proper terminal rendering
- console: add i18n translations (connecting, reconnect)
- workspace: add switcher dropdown in TopBar
- workspace: add edit dialog in WorkspacesPage
- workspace: create Zustand store with localStorage persistence
- workspace: add X-Active-Workspace header to API client
- sidebar: replace generic icon with actual favicon.svg logo
- instances: translate RowActionMenu and InstanceDetailPage strings
- locales: add graphs, console, workspace, instance tab keys to en/ru
This commit is contained in:
markmorado
2026-04-07 00:45:43 +02:00
parent 4546a798d0
commit 1d08c815a7
138 changed files with 17826 additions and 32024 deletions

View File

@@ -0,0 +1,71 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export const backupKeys = {
storages: (serverId: number) => ['backup-storages', serverId] as const,
list: (serverId: number, node: string, storage: string) => ['backups', serverId, node, storage] as const,
jobs: ['backup-jobs'] as const,
proxmoxJobs: (serverId: number) => ['proxmox-backup-jobs', serverId] as const,
};
export function useBackupStorages(serverId: number) {
return useQuery({
queryKey: backupKeys.storages(serverId),
queryFn: () => apiClient.get<{ storages: unknown[] }>(`/proxmox/api/backups/storages/${serverId}`),
enabled: serverId > 0,
});
}
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,
});
}
export function useBackupJobs() {
return useQuery({
queryKey: backupKeys.jobs,
queryFn: () => apiClient.get<{ jobs: unknown[] }>('/proxmox/api/backups/jobs'),
});
}
export function useCreateBackup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { server_id: number; node: string; vmid: number; storage: string; mode?: string; compress?: string; notes?: string }) =>
apiClient.post<{ success: boolean; upid: string }>('/proxmox/api/backups/create', data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['backups'] }),
});
}
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`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['backups'] }),
});
}
export function useRestoreBackup() {
return useMutation({
mutationFn: (data: { server_id: number; node: string; vmid: number; archive: string; storage: string; vm_type: string; new_vmid?: number; start?: boolean }) =>
apiClient.post<{ success: boolean; upid: string }>('/proxmox/api/backups/restore', data),
});
}
export function useToggleBackupJob() {
const qc = useQueryClient();
return useMutation({
mutationFn: (jobId: number) => apiClient.patch<{ success: boolean }>(`/proxmox/api/backups/jobs/${jobId}/toggle`),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.jobs }),
});
}
export function useRunBackupJob() {
return useMutation({
mutationFn: (jobId: number) => apiClient.post<{ success: boolean }>(`/proxmox/api/backups/jobs/${jobId}/run-now`),
});
}

View File

@@ -0,0 +1,229 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { VMInstance, Snapshot, VMConfig } from '@/types';
// ==================== Query Keys ====================
export const vmKeys = {
all: ['virtual-machines'] as const,
resources: (serverId?: number) => ['resources', serverId] as const,
resourcesAll: ['resources', 'all'] as const,
detail: (serverId: number, vmid: number) => ['vm', serverId, vmid] as const,
config: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'config'] as const,
status: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'status'] as const,
rrddata: (serverId: number, vmid: number, tf: string) => ['vm', serverId, vmid, 'rrddata', tf] as const,
snapshots: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'snapshots'] as const,
interfaces: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'interfaces'] as const,
};
// ==================== VM List ====================
export function useVirtualMachines() {
return useQuery({
queryKey: vmKeys.all,
queryFn: () => apiClient.get<VMInstance[]>('/proxmox/api/virtual-machines'),
refetchInterval: 30000,
});
}
export function useAllResources() {
return useQuery({
queryKey: vmKeys.resourcesAll,
queryFn: () => apiClient.get<Record<string, unknown>>('/proxmox/api/resources/all'),
refetchInterval: 30000,
});
}
// ==================== VM Detail ====================
interface VMStatusResponse {
status: string;
cpu: number;
mem: number;
maxmem: number;
disk: number;
maxdisk: number;
uptime: number;
netin: number;
netout: number;
name?: string;
pid?: number;
[key: string]: unknown;
}
export function useVMStatus(serverId: number, vmid: number, type: string, node: string, enabled = true) {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useQuery<VMStatusResponse>({
queryKey: vmKeys.status(serverId, vmid),
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/status?node=${node}`),
refetchInterval: 10000,
enabled,
});
}
export function useVMConfig(serverId: number, vmid: number, type: string, node: string, enabled = true) {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useQuery<VMConfig>({
queryKey: vmKeys.config(serverId, vmid),
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/config?node=${node}`),
enabled,
});
}
export function useVMRrddata(serverId: number, vmid: number, type: string, node: string, timeframe = 'hour') {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useQuery({
queryKey: vmKeys.rrddata(serverId, vmid, timeframe),
queryFn: async () => {
const res = await apiClient.get<{ data: unknown[]; timeframe: string }>(`/proxmox/api/${serverId}/${prefix}/${vmid}/rrddata?node=${node}&timeframe=${timeframe}`);
return res.data ?? [];
},
});
}
interface InterfaceInfo {
name: string;
mac?: string;
ips?: Array<{ address: string; prefix: number; type: string }>;
}
export function useVMInterfaces(serverId: number, vmid: number, type: string, node: string, enabled = true) {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useQuery<{ interfaces: InterfaceInfo[] }>({
queryKey: vmKeys.interfaces(serverId, vmid),
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/interfaces?node=${node}`),
enabled,
retry: 1,
});
}
// ==================== Snapshots ====================
export function useSnapshots(serverId: number, vmid: number, type: string, node: string, enabled = true) {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useQuery<{ snapshots: Snapshot[] }>({
queryKey: vmKeys.snapshots(serverId, vmid),
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/snapshots?node=${node}`),
enabled,
});
}
export function useCreateSnapshot(serverId: number, vmid: number, type: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: (body: { snapname: string; description?: string; vmstate?: boolean }) =>
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/snapshots`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.snapshots(serverId, vmid) });
},
});
}
export function useDeleteSnapshot(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: (snapname: string) =>
apiClient.delete(`/proxmox/api/${serverId}/${prefix}/${vmid}/snapshots/${snapname}?node=${node}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.snapshots(serverId, vmid) });
},
});
}
export function useRollbackSnapshot(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: (snapname: string) =>
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/snapshots/${snapname}/rollback?node=${node}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.snapshots(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
},
});
}
// ==================== Power Actions ====================
interface PowerActionResult {
status: string;
action: string;
vmid: number;
node: string;
upid?: string;
}
export function usePowerAction(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation<PowerActionResult, Error, { action: string; force?: boolean }>({
mutationFn: ({ action, force }) => {
const params = new URLSearchParams({ node });
if (force) params.set('force', '1');
return apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/${action}?${params}`);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}
// ==================== Delete VM ====================
export function useDeleteVM(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: () => apiClient.delete(`/proxmox/api/${serverId}/${prefix}/${vmid}?node=${node}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}
// ==================== Bulk Operations ====================
interface BulkItem {
server_id: number;
vmid: number;
vm_type: string;
name: string;
node: string;
}
export function useBulkOperation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { action: string; items: BulkItem[] }) =>
apiClient.post('/proxmox/api/bulk-operation', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}
// ==================== Disk Resize ====================
export function useResizeDisk(serverId: number, vmid: number, type: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: (body: { disk: string; size: string }) =>
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/disk/resize`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
},
});
}
// ==================== Config Update ====================
export function useUpdateConfig(serverId: number, vmid: number, type: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiClient.put(`/proxmox/api/${serverId}/${prefix}/${vmid}/config`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
},
});
}

View File

@@ -0,0 +1,131 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { IPAMNetwork, IPAMPool, IPAMAllocation } from '@/types';
export const ipamKeys = {
networks: ['ipam-networks'] as const,
network: (id: number) => ['ipam-network', id] as const,
networkStats: (id: number) => ['ipam-network-stats', id] as const,
ipMap: (id: number) => ['ipam-ip-map', id] as const,
pools: (networkId?: number) => ['ipam-pools', networkId] as const,
allocations: (params?: Record<string, unknown>) => ['ipam-allocations', params] as const,
history: (params?: Record<string, unknown>) => ['ipam-history', params] as const,
summary: ['ipam-summary'] as const,
conflicts: ['ipam-conflicts'] as const,
};
export function useIPAMNetworks() {
return useQuery({
queryKey: ipamKeys.networks,
queryFn: () => apiClient.get<IPAMNetwork[]>('/ipam/api/networks'),
});
}
export function useIPAMNetwork(id: number) {
return useQuery({
queryKey: ipamKeys.network(id),
queryFn: () => apiClient.get<IPAMNetwork>(`/ipam/api/networks/${id}`),
enabled: id > 0,
});
}
export function useIPAMNetworkStats(id: number) {
return useQuery({
queryKey: ipamKeys.networkStats(id),
queryFn: () => apiClient.get<{ total_ips: number; allocated_ips: number; reserved_ips: number; available_ips: number; utilization_percent: number }>(`/ipam/api/networks/${id}/stats`),
enabled: id > 0,
});
}
export function useIPAMIPMap(id: number) {
return useQuery({
queryKey: ipamKeys.ipMap(id),
queryFn: () => apiClient.get<{ network_id: number; ip_map: unknown[] }>(`/ipam/api/networks/${id}/ip-map`),
enabled: id > 0,
});
}
export function useIPAMPools(networkId?: number) {
const qs = networkId ? `?network_id=${networkId}` : '';
return useQuery({
queryKey: ipamKeys.pools(networkId),
queryFn: () => apiClient.get<IPAMPool[]>(`/ipam/api/pools${qs}`),
});
}
export function useIPAMAllocations(params?: { network_id?: number; pool_id?: number; status?: string; search?: string; limit?: number; offset?: number }) {
const qs = new URLSearchParams();
if (params?.network_id) qs.set('network_id', String(params.network_id));
if (params?.pool_id) qs.set('pool_id', String(params.pool_id));
if (params?.status) qs.set('status', params.status);
if (params?.search) qs.set('search', params.search);
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.offset) qs.set('offset', String(params.offset));
return useQuery({
queryKey: ipamKeys.allocations(params),
queryFn: () => apiClient.get<IPAMAllocation[]>(`/ipam/api/allocations?${qs}`),
});
}
export function useIPAMHistory(params?: { network_id?: number; ip_address?: string; action?: string; limit?: number }) {
const qs = new URLSearchParams();
if (params?.network_id) qs.set('network_id', String(params.network_id));
if (params?.ip_address) qs.set('ip_address', params.ip_address);
if (params?.action) qs.set('action', params.action);
if (params?.limit) qs.set('limit', String(params.limit));
return useQuery({
queryKey: ipamKeys.history(params),
queryFn: () => apiClient.get<any[]>(`/ipam/api/history?${qs}`),
});
}
export function useIPAMSummary() {
return useQuery({
queryKey: ipamKeys.summary,
queryFn: () => apiClient.get<Record<string, unknown>>('/ipam/api/summary'),
});
}
export function useCreateNetwork() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<IPAMNetwork>) => apiClient.post<IPAMNetwork>('/ipam/api/networks', data),
onSuccess: () => qc.invalidateQueries({ queryKey: ipamKeys.networks }),
});
}
export function useUpdateNetwork() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: Partial<IPAMNetwork> & { id: number }) =>
apiClient.put<IPAMNetwork>(`/ipam/api/networks/${id}`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ipam-network'] });
qc.invalidateQueries({ queryKey: ipamKeys.networks });
},
});
}
export function useDeleteNetwork() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/ipam/api/networks/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ipamKeys.networks }),
});
}
export function useCreateAllocation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<IPAMAllocation>) => apiClient.post<IPAMAllocation>('/ipam/api/allocations', data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['ipam-allocations'] }),
});
}
export function useDeleteAllocation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/ipam/api/allocations/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['ipam-allocations'] }),
});
}

View File

@@ -0,0 +1,75 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { AuditLog } from '@/types';
export const logKeys = {
logs: (params?: Record<string, unknown>) => ['logs', params] as const,
stats: (hours?: number) => ['log-stats', hours] as const,
levels: ['log-levels'] as const,
categories: ['log-categories'] as const,
};
interface LogsResponse {
logs: AuditLog[];
total: number;
page: number;
limit: number;
pages: number;
}
interface LogStats {
period_hours: number;
total: number;
by_level: Record<string, number>;
by_category: Record<string, number>;
errors_count: number;
failed_logins: number;
recent_errors: unknown[];
}
export function useLogs(params?: { level?: string; category?: string; username?: string; search?: string; page?: number; limit?: number; date_from?: string; date_to?: string }) {
const qs = new URLSearchParams();
if (params?.level) qs.set('level', params.level);
if (params?.category) qs.set('category', params.category);
if (params?.username) qs.set('username', params.username);
if (params?.search) qs.set('search', params.search);
if (params?.page) qs.set('page', String(params.page));
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.date_from) qs.set('date_from', params.date_from);
if (params?.date_to) qs.set('date_to', params.date_to);
return useQuery({
queryKey: logKeys.logs(params),
queryFn: () => apiClient.get<LogsResponse>(`/logs/api/logs?${qs}`),
});
}
export function useLogStats(hours = 24) {
return useQuery({
queryKey: logKeys.stats(hours),
queryFn: () => apiClient.get<LogStats>(`/logs/api/stats?hours=${hours}`),
});
}
export function useLogLevels() {
return useQuery({
queryKey: logKeys.levels,
queryFn: () => apiClient.get<{ levels: { value: string; label: string; color: string }[] }>('/logs/api/levels'),
staleTime: Infinity,
});
}
export function useLogCategories() {
return useQuery({
queryKey: logKeys.categories,
queryFn: () => apiClient.get<{ categories: { value: string; label: string }[] }>('/logs/api/categories'),
staleTime: Infinity,
});
}
export function useDeleteOldLogs() {
const qc = useQueryClient();
return useMutation({
mutationFn: (days: number) => apiClient.delete(`/logs/api/logs?days=${days}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['logs'] }),
});
}

View File

@@ -0,0 +1,72 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { ProxmoxServer, ProxmoxNode } from '@/types';
export const nodeKeys = {
servers: ['servers'] as const,
server: (id: number) => ['servers', id] as const,
nodes: (serverId: number) => ['nodes', serverId] as const,
nodeStatus: (serverId: number, node: string) => ['node-status', serverId, node] as const,
nodeRrd: (serverId: number, node: string, tf: string) => ['node-rrd', serverId, node, tf] as const,
clusterInfo: (serverId: number) => ['cluster-info', serverId] as const,
topology: ['cluster-topology'] as const,
storages: (serverId: number, node: string) => ['storages', serverId, node] as const,
};
export function useServers() {
return useQuery({
queryKey: nodeKeys.servers,
queryFn: () => apiClient.get<ProxmoxServer[]>('/proxmox/api/servers'),
refetchInterval: 30000,
});
}
export function useServer(serverId: number) {
return useQuery({
queryKey: nodeKeys.server(serverId),
queryFn: () => apiClient.get<ProxmoxServer>(`/proxmox/api/servers/${serverId}`),
enabled: serverId > 0,
});
}
export function useNodes(serverId: number) {
return useQuery({
queryKey: nodeKeys.nodes(serverId),
queryFn: () => apiClient.get<{ nodes: ProxmoxNode[]; cluster_id: string }>(`/proxmox/api/${serverId}/nodes`),
enabled: serverId > 0,
refetchInterval: 30000,
});
}
export function useNodeStatus(serverId: number, node: string) {
return useQuery({
queryKey: nodeKeys.nodeStatus(serverId, node),
queryFn: () => apiClient.get<Record<string, unknown>>(`/proxmox/api/${serverId}/node/status?node=${node}`),
enabled: serverId > 0 && !!node,
refetchInterval: 10000,
});
}
export function useNodeRrddata(serverId: number, node: string, timeframe = 'hour') {
return useQuery({
queryKey: nodeKeys.nodeRrd(serverId, node, timeframe),
queryFn: () => apiClient.get<unknown[]>(`/proxmox/api/${serverId}/node/rrddata?node=${node}&timeframe=${timeframe}`),
enabled: serverId > 0 && !!node,
});
}
export function useClusterTopology() {
return useQuery({
queryKey: nodeKeys.topology,
queryFn: () => apiClient.get<{ clusters: Record<string, unknown[]>; standalone: unknown[] }>('/proxmox/api/cluster/topology'),
refetchInterval: 30000,
});
}
export function useStorages(serverId: number, node: string) {
return useQuery({
queryKey: nodeKeys.storages(serverId, node),
queryFn: () => apiClient.get<unknown[]>(`/proxmox/api/${serverId}/storages?node=${node}`),
enabled: serverId > 0 && !!node,
});
}

View File

@@ -0,0 +1,77 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { Notification } from '@/types';
export const notificationKeys = {
list: (params?: Record<string, unknown>) => ['notifications', params] as const,
unreadCount: ['notifications-unread-count'] as const,
preferences: ['notification-preferences'] as const,
};
interface NotificationListResponse {
total: number;
unread_count: number;
notifications: Notification[];
}
export function useNotifications(params?: { unread_only?: boolean; level?: string; limit?: number; offset?: number }) {
const qs = new URLSearchParams();
if (params?.unread_only) qs.set('unread_only', 'true');
if (params?.level) qs.set('level', params.level);
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.offset) qs.set('offset', String(params.offset));
return useQuery({
queryKey: notificationKeys.list(params),
queryFn: () => apiClient.get<NotificationListResponse>(`/api/notifications/?${qs}`),
refetchInterval: 30000,
});
}
export function useUnreadCount() {
return useQuery({
queryKey: notificationKeys.unreadCount,
queryFn: () => apiClient.get<{ count: number }>('/api/notifications/unread-count'),
refetchInterval: 15000,
});
}
export function useMarkRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.patch<Notification>(`/api/notifications/${id}/read`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications'] });
qc.invalidateQueries({ queryKey: notificationKeys.unreadCount });
},
});
}
export function useMarkAllRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiClient.post<{ message: string }>('/api/notifications/mark-all-read'),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications'] });
qc.invalidateQueries({ queryKey: notificationKeys.unreadCount });
},
});
}
export function useDeleteNotification() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/api/notifications/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications'] });
qc.invalidateQueries({ queryKey: notificationKeys.unreadCount });
},
});
}
export function useDeleteAllRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiClient.delete('/api/notifications/read/all'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
});
}

View File

@@ -0,0 +1,122 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export const settingsKeys = {
profile: ['settings-profile'] as const,
panel: ['settings-panel'] as const,
security: ['settings-security'] as const,
channels: ['notification-channels'] as const,
version: ['app-version'] as const,
};
interface ProfileResponse {
id: number;
username: string;
email: string;
full_name: string;
is_admin: boolean;
is_active: boolean;
ssh_public_key?: string;
created_at: string;
last_login?: string;
}
interface PanelSettings {
panel_name: string;
log_retention_days: number;
language: string;
}
export function useProfile() {
return useQuery({
queryKey: settingsKeys.profile,
queryFn: () => apiClient.get<ProfileResponse>('/settings/api/profile'),
});
}
export function useUpdateProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { full_name?: string; email?: string; ssh_public_key?: string }) =>
apiClient.put<{ message: string }>('/settings/api/profile', data),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.profile }),
});
}
export function useChangePassword() {
return useMutation({
mutationFn: (data: { current_password: string; new_password: string; confirm_password: string }) =>
apiClient.post<{ message: string }>('/settings/api/change-password', data),
});
}
export function usePanelSettings() {
return useQuery({
queryKey: settingsKeys.panel,
queryFn: () => apiClient.get<PanelSettings>('/settings/api/panel'),
});
}
export function useUpdatePanelSettings() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<PanelSettings>) =>
apiClient.put<{ message: string }>('/settings/api/panel', data),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.panel }),
});
}
export function useSecuritySettings() {
return useQuery({
queryKey: settingsKeys.security,
queryFn: () => apiClient.get<{ single_session_enabled: boolean; max_login_attempts?: number; lockout_duration?: number; session_timeout?: number }>('/settings/api/security-settings'),
});
}
export function useUpdateSecuritySettings() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { single_session_enabled?: boolean; max_login_attempts?: number; lockout_duration?: number; session_timeout?: number }) =>
apiClient.put<{ message: string }>('/settings/api/security-settings', data),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.security }),
});
}
export function useNotificationChannels() {
return useQuery({
queryKey: settingsKeys.channels,
queryFn: () => apiClient.get<{ smtp: Record<string, unknown>; telegram: Record<string, unknown> }>('/settings/api/notification-channels'),
});
}
export function useUpdateSMTP() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Record<string, unknown>) =>
apiClient.put<{ message: string }>('/settings/api/notification-channels/smtp', data),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.channels }),
});
}
export function useUpdateTelegram() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { telegram_bot_token?: string }) =>
apiClient.put<{ message: string }>('/settings/api/notification-channels/telegram', data),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.channels }),
});
}
export function useAppVersion() {
return useQuery({
queryKey: settingsKeys.version,
queryFn: () => apiClient.get<{ version: string }>('/settings/api/version'),
staleTime: Infinity,
});
}
export function useCheckUpdates() {
return useMutation({
mutationFn: () => apiClient.get<Record<string, unknown>>('/settings/api/updates/check'),
});
}

View File

@@ -0,0 +1,65 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export const taskKeys = {
all: (params?: Record<string, unknown>) => ['all-tasks', params] as const,
activeCount: ['tasks-active-count'] as const,
bulk: (limit?: number) => ['bulk-tasks', limit] as const,
};
interface TaskItem {
id?: number;
upid?: string;
type: string;
status: string;
action?: string;
items_count?: number;
completed_count?: number;
failed_count?: number;
created_at?: string;
started_at?: string;
completed_at?: string;
node?: string;
server_id?: number;
server_name?: string;
exitstatus?: string;
starttime?: number;
endtime?: number;
user?: string;
[key: string]: unknown;
}
export function useAllTasks(params?: { limit?: number; status?: string }) {
const qs = new URLSearchParams();
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.status) qs.set('status', params.status);
return useQuery({
queryKey: taskKeys.all(params),
queryFn: () => apiClient.get<{ tasks: TaskItem[] }>(`/proxmox/api/all-tasks?${qs}`),
refetchInterval: 5000,
});
}
export function useActiveTaskCount() {
return useQuery({
queryKey: taskKeys.activeCount,
queryFn: () => apiClient.get<{ count: number }>('/proxmox/api/all-tasks/active-count'),
refetchInterval: 5000,
});
}
export function useCancelTask() {
const qc = useQueryClient();
return useMutation({
mutationFn: (taskId: number) => apiClient.post<{ success: boolean }>(`/proxmox/api/tasks/${taskId}/cancel`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['all-tasks'] }),
});
}
export function useClearCompletedTasks() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiClient.delete('/proxmox/api/all-tasks/completed'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['all-tasks'] }),
});
}

View File

@@ -0,0 +1,80 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { OSTemplate, OSTemplateGroup } from '@/types';
export const templateKeys = {
groups: ['template-groups'] as const,
templates: (groupId?: number, serverId?: number) => ['templates', groupId, serverId] as const,
template: (id: number) => ['template', id] as const,
discover: (serverId: number) => ['template-discover', serverId] as const,
};
export function useTemplateGroups() {
return useQuery({
queryKey: templateKeys.groups,
queryFn: () => apiClient.get<OSTemplateGroup[]>('/templates/api/groups'),
});
}
export function useTemplates(groupId?: number, serverId?: number) {
const params = new URLSearchParams();
if (groupId) params.set('group_id', String(groupId));
if (serverId) params.set('server_id', String(serverId));
const qs = params.toString();
return useQuery({
queryKey: templateKeys.templates(groupId, serverId),
queryFn: () => apiClient.get<OSTemplate[]>(`/templates/api/templates${qs ? `?${qs}` : ''}`),
});
}
export function useTemplate(templateId: number) {
return useQuery({
queryKey: templateKeys.template(templateId),
queryFn: () => apiClient.get<OSTemplate>(`/templates/api/templates/${templateId}`),
enabled: templateId > 0,
});
}
export function useDiscoverTemplates(serverId: number) {
return useQuery({
queryKey: templateKeys.discover(serverId),
queryFn: () => apiClient.get<{ server_id: number; server_name: string; templates: unknown[] }>(`/templates/api/discover/${serverId}`),
enabled: false, // manual trigger
});
}
export function useAutoImportTemplates() {
const qc = useQueryClient();
return useMutation({
mutationFn: (serverId: number) => apiClient.post<{ success: boolean; count: number }>(`/templates/api/auto-import/${serverId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: templateKeys.groups });
qc.invalidateQueries({ queryKey: ['templates'] });
},
});
}
export function useCreateTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<OSTemplate>) => apiClient.post<OSTemplate>('/templates/api/templates', data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
});
}
export function useUpdateTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: Partial<OSTemplate> & { id: number }) =>
apiClient.put<OSTemplate>(`/templates/api/templates/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
});
}
export function useDeleteTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/templates/api/templates/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
});
}

View File

@@ -0,0 +1,140 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { User } from '@/types';
export const userKeys = {
users: ['users'] as const,
user: (id: number) => ['user', id] as const,
roles: ['roles'] as const,
permissions: ['permissions-v2'] as const,
sessions: ['sessions'] as const,
blockedIps: ['blocked-ips'] as const,
securitySettings: ['admin-security-settings'] as const,
securityEvents: ['security-events'] as const,
};
interface Role {
id: number;
name: string;
display_name: string;
description?: string;
permissions: string[];
is_system: boolean;
is_active: boolean;
user_count: number;
}
export function useUsers() {
return useQuery({
queryKey: userKeys.users,
queryFn: () => apiClient.get<User[]>('/admin/api/users'),
});
}
export function useUser(userId: number) {
return useQuery({
queryKey: userKeys.user(userId),
queryFn: () => apiClient.get<User>(`/admin/api/users/${userId}`),
enabled: userId > 0,
});
}
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),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
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 }) =>
apiClient.put<User>(`/admin/api/users/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/admin/api/users/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export function useResetPassword() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/reset-password`, {}),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export function useUnlockUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) => apiClient.post<{ message: string }>(`/admin/api/users/${userId}/unlock`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export function useRoles() {
return useQuery({
queryKey: userKeys.roles,
queryFn: () => apiClient.get<Role[]>('/admin/api/roles'),
});
}
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),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
});
}
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),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
});
}
export function useDeleteRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/admin/api/roles/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
});
}
export function usePermissions() {
return useQuery({
queryKey: userKeys.permissions,
queryFn: () => apiClient.get<Record<string, unknown>>('/admin/api/permissions/v2'),
staleTime: Infinity,
});
}
export function useSessions() {
return useQuery({
queryKey: userKeys.sessions,
queryFn: () => apiClient.get<any[]>('/admin/api/sessions'),
refetchInterval: 15000,
});
}
export function useTerminateSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (sessionId: string) => apiClient.delete(`/admin/api/sessions/${sessionId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.sessions }),
});
}

View File

@@ -0,0 +1,75 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { Workspace } from '@/types';
export const workspaceKeys = {
all: ['workspaces'] as const,
detail: (id: number) => ['workspace', id] as const,
};
interface WorkspaceDetail extends Omit<Workspace, 'servers' | 'users'> {
servers: { id: number; name: string; ip_address: string; is_online: boolean; cluster_name?: string }[];
users: { id: number; username: string; email: string; full_name?: string }[];
}
export function useWorkspaces() {
return useQuery({
queryKey: workspaceKeys.all,
queryFn: () => apiClient.get<Workspace[]>('/api/workspaces'),
});
}
export function useWorkspace(id: number) {
return useQuery({
queryKey: workspaceKeys.detail(id),
queryFn: () => apiClient.get<WorkspaceDetail>(`/api/workspaces/${id}`),
enabled: id > 0,
});
}
export function useCreateWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; description?: string; color?: string; server_ids?: number[]; user_ids?: number[] }) =>
apiClient.post<WorkspaceDetail>('/api/workspaces', data),
onSuccess: () => qc.invalidateQueries({ queryKey: workspaceKeys.all }),
});
}
export function useUpdateWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number; name?: string; description?: string; color?: string }) =>
apiClient.put<WorkspaceDetail>(`/api/workspaces/${id}`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.all });
qc.invalidateQueries({ queryKey: ['workspace'] });
},
});
}
export function useDeleteWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/api/workspaces/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: workspaceKeys.all }),
});
}
export function useUpdateWorkspaceServers() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
apiClient.post<WorkspaceDetail>(`/api/workspaces/${id}/servers`, { ids }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['workspace'] }),
});
}
export function useUpdateWorkspaceUsers() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
apiClient.post<WorkspaceDetail>(`/api/workspaces/${id}/users`, { ids }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['workspace'] }),
});
}