feat: implement bulk ACL application feature for proxy hosts

This commit is contained in:
Wikid82
2025-11-27 14:55:00 +00:00
parent 429de10f0f
commit 05321e3a59
8 changed files with 863 additions and 8 deletions
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { bulkUpdateACL } from '../proxyHosts';
import type { BulkUpdateACLResponse } from '../proxyHosts';
// Mock the client module
const mockPut = vi.fn();
vi.mock('../client', () => ({
default: {
put: (...args: unknown[]) => mockPut(...args),
},
}));
describe('proxyHosts bulk operations', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('bulkUpdateACL', () => {
it('should apply ACL to multiple hosts', async () => {
const mockResponse: BulkUpdateACLResponse = {
updated: 3,
errors: [],
};
mockPut.mockResolvedValue({ data: mockResponse });
const hostUUIDs = ['uuid-1', 'uuid-2', 'uuid-3'];
const accessListID = 42;
const result = await bulkUpdateACL(hostUUIDs, accessListID);
expect(mockPut).toHaveBeenCalledWith('/proxy-hosts/bulk-update-acl', {
host_uuids: hostUUIDs,
access_list_id: accessListID,
});
expect(result).toEqual(mockResponse);
});
it('should remove ACL from hosts when accessListID is null', async () => {
const mockResponse: BulkUpdateACLResponse = {
updated: 2,
errors: [],
};
mockPut.mockResolvedValue({ data: mockResponse });
const hostUUIDs = ['uuid-1', 'uuid-2'];
const result = await bulkUpdateACL(hostUUIDs, null);
expect(mockPut).toHaveBeenCalledWith('/proxy-hosts/bulk-update-acl', {
host_uuids: hostUUIDs,
access_list_id: null,
});
expect(result).toEqual(mockResponse);
});
it('should handle partial failures', async () => {
const mockResponse: BulkUpdateACLResponse = {
updated: 1,
errors: [
{ uuid: 'invalid-uuid', error: 'proxy host not found' },
],
};
mockPut.mockResolvedValue({ data: mockResponse });
const hostUUIDs = ['valid-uuid', 'invalid-uuid'];
const accessListID = 10;
const result = await bulkUpdateACL(hostUUIDs, accessListID);
expect(result.updated).toBe(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].uuid).toBe('invalid-uuid');
});
it('should handle empty host list', async () => {
const mockResponse: BulkUpdateACLResponse = {
updated: 0,
errors: [],
};
mockPut.mockResolvedValue({ data: mockResponse });
const result = await bulkUpdateACL([], 5);
expect(mockPut).toHaveBeenCalledWith('/proxy-hosts/bulk-update-acl', {
host_uuids: [],
access_list_id: 5,
});
expect(result.updated).toBe(0);
});
it('should propagate API errors', async () => {
const error = new Error('Network error');
mockPut.mockRejectedValue(error);
await expect(bulkUpdateACL(['uuid-1'], 1)).rejects.toThrow('Network error');
});
});
});
+21
View File
@@ -70,3 +70,24 @@ export const deleteProxyHost = async (uuid: string): Promise<void> => {
export const testProxyHostConnection = async (host: string, port: number): Promise<void> => {
await client.post('/proxy-hosts/test', { forward_host: host, forward_port: port });
};
export interface BulkUpdateACLRequest {
host_uuids: string[];
access_list_id: number | null;
}
export interface BulkUpdateACLResponse {
updated: number;
errors: { uuid: string; error: string }[];
}
export const bulkUpdateACL = async (
hostUUIDs: string[],
accessListID: number | null
): Promise<BulkUpdateACLResponse> => {
const { data } = await client.put<BulkUpdateACLResponse>('/proxy-hosts/bulk-update-acl', {
host_uuids: hostUUIDs,
access_list_id: accessListID,
});
return data;
};
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useProxyHosts } from '../useProxyHosts';
import * as proxyHostsApi from '../../api/proxyHosts';
// Mock the API module
vi.mock('../../api/proxyHosts');
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useProxyHosts bulk operations', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('bulkUpdateACL', () => {
it('should apply ACL to multiple hosts', async () => {
const mockResponse = {
updated: 2,
errors: [],
};
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([]);
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useProxyHosts(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.loading).toBe(false));
const hostUUIDs = ['uuid-1', 'uuid-2'];
const accessListID = 5;
const response = await result.current.bulkUpdateACL(hostUUIDs, accessListID);
expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalledWith(hostUUIDs, accessListID);
expect(response.updated).toBe(2);
expect(response.errors).toEqual([]);
});
it('should remove ACL from hosts', async () => {
const mockResponse = {
updated: 1,
errors: [],
};
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([]);
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useProxyHosts(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.loading).toBe(false));
const response = await result.current.bulkUpdateACL(['uuid-1'], null);
expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalledWith(['uuid-1'], null);
expect(response.updated).toBe(1);
});
it('should invalidate queries after successful bulk update', async () => {
const mockHosts = [
{
uuid: 'uuid-1',
name: 'Host 1',
domain_names: 'host1.example.com',
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 8001,
ssl_forced: false,
http2_support: false,
hsts_enabled: false,
hsts_subdomains: false,
block_exploits: true,
websocket_support: false,
application: 'none' as const,
locations: [],
enabled: true,
access_list_id: null,
certificate_id: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
vi.mocked(proxyHostsApi.getProxyHosts)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(mockHosts);
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({
updated: 1,
errors: [],
});
const { result } = renderHook(() => useProxyHosts(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hosts).toEqual([]);
await result.current.bulkUpdateACL(['uuid-1'], 10);
// Query should be invalidated and refetch
await waitFor(() => expect(result.current.hosts).toEqual(mockHosts));
});
it('should handle bulk update errors', async () => {
const error = new Error('Bulk update failed');
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([]);
vi.mocked(proxyHostsApi.bulkUpdateACL).mockRejectedValue(error);
const { result } = renderHook(() => useProxyHosts(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.loading).toBe(false));
await expect(result.current.bulkUpdateACL(['uuid-1'], 5)).rejects.toThrow(
'Bulk update failed'
);
});
it('should track bulk updating state', async () => {
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([]);
vi.mocked(proxyHostsApi.bulkUpdateACL).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ updated: 1, errors: [] }), 100))
);
const { result } = renderHook(() => useProxyHosts(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isBulkUpdating).toBe(false);
const promise = result.current.bulkUpdateACL(['uuid-1'], 1);
await waitFor(() => expect(result.current.isBulkUpdating).toBe(true));
await promise;
await waitFor(() => expect(result.current.isBulkUpdating).toBe(false));
});
});
});
+12
View File
@@ -4,6 +4,7 @@ import {
createProxyHost,
updateProxyHost,
deleteProxyHost,
bulkUpdateACL,
ProxyHost
} from '../api/proxyHosts';
@@ -39,6 +40,14 @@ export function useProxyHosts() {
},
});
const bulkUpdateACLMutation = useMutation({
mutationFn: ({ hostUUIDs, accessListID }: { hostUUIDs: string[]; accessListID: number | null }) =>
bulkUpdateACL(hostUUIDs, accessListID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
return {
hosts: query.data || [],
loading: query.isLoading,
@@ -47,9 +56,12 @@ export function useProxyHosts() {
createHost: createMutation.mutateAsync,
updateHost: (uuid: string, data: Partial<ProxyHost>) => updateMutation.mutateAsync({ uuid, data }),
deleteHost: deleteMutation.mutateAsync,
bulkUpdateACL: (hostUUIDs: string[], accessListID: number | null) =>
bulkUpdateACLMutation.mutateAsync({ hostUUIDs, accessListID }),
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
isBulkUpdating: bulkUpdateACLMutation.isPending,
};
}
+157 -8
View File
@@ -1,23 +1,29 @@
import { useState, useMemo } from 'react'
import { Loader2, ExternalLink, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react'
import { Loader2, ExternalLink, AlertTriangle, ChevronUp, ChevronDown, CheckSquare, Square } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useProxyHosts } from '../hooks/useProxyHosts'
import { useCertificates } from '../hooks/useCertificates'
import { useAccessLists } from '../hooks/useAccessLists'
import { getSettings } from '../api/settings'
import type { ProxyHost } from '../api/proxyHosts'
import type { AccessList } from '../api/accessLists'
import ProxyHostForm from '../components/ProxyHostForm'
import { Switch } from '../components/ui/Switch'
import { toast } from 'react-hot-toast'
type SortColumn = 'name' | 'domain' | 'forward'
type SortDirection = 'asc' | 'desc'
export default function ProxyHosts() {
const { hosts, loading, isFetching, error, createHost, updateHost, deleteHost } = useProxyHosts()
const { hosts, loading, isFetching, error, createHost, updateHost, deleteHost, bulkUpdateACL, isBulkUpdating } = useProxyHosts()
const { certificates } = useCertificates()
const { data: accessLists } = useAccessLists()
const [showForm, setShowForm] = useState(false)
const [editingHost, setEditingHost] = useState<ProxyHost | undefined>()
const [sortColumn, setSortColumn] = useState<SortColumn>('name')
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
const [selectedHosts, setSelectedHosts] = useState<Set<string>>(new Set())
const [showBulkACLModal, setShowBulkACLModal] = useState(false)
const { data: settings } = useQuery({
queryKey: ['settings'],
@@ -123,6 +129,45 @@ export default function ProxyHosts() {
}
}
const toggleHostSelection = (uuid: string) => {
setSelectedHosts(prev => {
const next = new Set(prev)
if (next.has(uuid)) {
next.delete(uuid)
} else {
next.add(uuid)
}
return next
})
}
const toggleSelectAll = () => {
if (selectedHosts.size === hosts.length) {
setSelectedHosts(new Set())
} else {
setSelectedHosts(new Set(hosts.map(h => h.uuid)))
}
}
const handleBulkApplyACL = async (accessListID: number | null) => {
const hostUUIDs = Array.from(selectedHosts)
try {
const result = await bulkUpdateACL(hostUUIDs, accessListID)
if (result.errors.length > 0) {
toast.error(`Updated ${result.updated} host(s), ${result.errors.length} failed`)
} else {
const action = accessListID ? 'applied to' : 'removed from'
toast.success(`Access list ${action} ${result.updated} host(s)`)
}
setSelectedHosts(new Set())
setShowBulkACLModal(false)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update hosts')
}
}
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
@@ -130,12 +175,26 @@ export default function ProxyHosts() {
<h1 className="text-3xl font-bold text-white">Proxy Hosts</h1>
{isFetching && !loading && <Loader2 className="animate-spin text-blue-400" size={24} />}
</div>
<button
onClick={handleAdd}
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
>
Add Proxy Host
</button>
<div className="flex gap-3">
{selectedHosts.size > 0 && (
<div className="flex items-center gap-2">
<span className="text-gray-400 text-sm">{selectedHosts.size} selected</span>
<button
onClick={() => setShowBulkACLModal(true)}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
disabled={isBulkUpdating}
>
{isBulkUpdating ? 'Updating...' : 'Bulk Actions'}
</button>
</div>
)}
<button
onClick={handleAdd}
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
>
Add Proxy Host
</button>
</div>
</div>
{error && (
@@ -156,6 +215,19 @@ export default function ProxyHosts() {
<table className="w-full">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
<button
onClick={toggleSelectAll}
className="text-gray-400 hover:text-white transition-colors"
title={selectedHosts.size === hosts.length ? 'Deselect all' : 'Select all'}
>
{selectedHosts.size === hosts.length ? (
<CheckSquare size={18} />
) : (
<Square size={18} />
)}
</button>
</th>
<th
onClick={() => handleSort('name')}
className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors"
@@ -197,6 +269,18 @@ export default function ProxyHosts() {
<tbody className="divide-y divide-gray-800">
{sortedHosts.map((host) => (
<tr key={host.uuid} className="hover:bg-gray-900/50">
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => toggleHostSelection(host.uuid)}
className="text-gray-400 hover:text-white transition-colors"
>
{selectedHosts.has(host.uuid) ? (
<CheckSquare size={18} className="text-blue-400" />
) : (
<Square size={18} />
)}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-white">
{host.name || <span className="text-gray-500 italic">Unnamed</span>}
@@ -325,6 +409,71 @@ export default function ProxyHosts() {
}}
/>
)}
{/* Bulk ACL Modal */}
{showBulkACLModal && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setShowBulkACLModal(false)}
>
<div
className="bg-dark-card border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-bold text-white mb-4">Apply Access List</h2>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-400 mb-4">
Applying to {selectedHosts.size} selected host(s)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Access List
</label>
<select
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => {
const value = e.target.value
if (value === 'remove') {
if (confirm(`Remove access list from ${selectedHosts.size} host(s)?`)) {
handleBulkApplyACL(null)
}
} else if (value !== '') {
handleBulkApplyACL(parseInt(value, 10))
}
}}
defaultValue=""
disabled={isBulkUpdating}
>
<option value="">Select an access list...</option>
<option value="remove" className="text-red-400">
🚫 Remove Access List
</option>
<optgroup label="Available Access Lists">
{accessLists
?.filter((acl: AccessList) => acl.enabled)
.map((acl: AccessList) => (
<option key={acl.id} value={acl.id}>
{acl.name}
</option>
))}
</optgroup>
</select>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowBulkACLModal(false)}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
disabled={isBulkUpdating}
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}