서버 관리

This commit is contained in:
현성필
2025-01-31 17:32:52 +09:00
parent c36a446f7c
commit aace8759b8
8 changed files with 701 additions and 78 deletions
+32 -26
View File
@@ -1,26 +1,26 @@
import React, { useEffect, useState } from 'react';
import { APIProvider, useAPI } from './providers/APIProvider';
import { LoadTestProvider } from './providers/LoadTestProvider';
import DialogManager from './components/shared/DialogManager';
import { monacoConfig } from '@/config/monaco.js';
import React, {useEffect, useState} from 'react';
import {APIProvider, useAPI} from './providers/APIProvider';
import {LoadTestProvider} from './providers/LoadTestProvider';
import DialogManager from '@/components/shared/DialogManager';
import {monacoConfig} from '@/config/monaco.js';
import LoginDialog from '@/components/account/LoginDialog.jsx';
import AppMenu from './components/app-menu/AppMenu';
import { SCREENS } from './config/screenConfig';
import {SCREENS} from './config/screenConfig';
window.MonacoEnvironment = monacoConfig;
const App = ({ options = { contextPath: '/' } }) => {
const App = ({options = {contextPath: '/'}}) => {
const [showLogin, setShowLogin] = useState(false);
return (
<APIProvider contextPath={options.contextPath}>
<AppContent options={options} showLogin={showLogin} setShowLogin={setShowLogin} />
<AppContent options={options} showLogin={showLogin} setShowLogin={setShowLogin}/>
</APIProvider>
);
};
const AppContent = ({ options, showLogin, setShowLogin }) => {
const { isAuthenticated, setIsAuthenticated, handleLogout } = useAPI();
const AppContent = ({options, showLogin, setShowLogin}) => {
const {isAuthenticated, setIsAuthenticated, handleLogout} = useAPI();
const [isInitialized, setIsInitialized] = useState(false);
const [currentScreen, setCurrentScreen] = useState('api-tester');
const [dialogState, setDialogState] = useState({
@@ -33,7 +33,8 @@ const AppContent = ({ options, showLogin, setShowLogin }) => {
showQueueEdit: false,
showUserEdit: false,
showPasswordChange: false,
selectedItem: null,
showServerEdit: false,
selectedItem: null
});
const handleMenuClick = async (action, screen, item = null) => {
@@ -50,41 +51,45 @@ const AppContent = ({ options, showLogin, setShowLogin }) => {
// Handle modal actions
switch (action) {
case 'request-add':
setDialogState(prev => ({ ...prev, showAddApi: true }));
setDialogState(prev => ({...prev, showAddApi: true}));
break;
case 'response-add':
case 'response-edit':
case 'response-clone':
setDialogState(prev => ({...prev, showMockRouteEdit: true, selectedItem: item }));
setDialogState(prev => ({...prev, showMockRouteEdit: true, selectedItem: item}));
break;
case 'queue-add':
case 'queue-edit':
setDialogState(prev => ({ ...prev, showQueueEdit: true , selectedItem: item}));
setDialogState(prev => ({...prev, showQueueEdit: true, selectedItem: item}));
break;
case 'user-add':
case 'user-edit':
setDialogState(prev => ({ ...prev, showUserEdit: true , selectedItem: item}));
setDialogState(prev => ({...prev, showUserEdit: true, selectedItem: item}));
break;
case 'socket-add':
case 'socket-edit':
setDialogState(prev => ({ ...prev, showTCPServerEdit: true , selectedItem: item}));
setDialogState(prev => ({...prev, showTCPServerEdit: true, selectedItem: item}));
break;
case 'server-add':
case 'server-edit':
setDialogState(prev => ({...prev, showServerEdit: true, selectedItem: item}));
break;
case 'collection-add':
setDialogState(prev => ({ ...prev, showAddCollection: true }));
setDialogState(prev => ({...prev, showAddCollection: true}));
break;
case 'scenario-add':
setDialogState(prev => ({ ...prev, showAddScenario: true }));
setDialogState(prev => ({...prev, showAddScenario: true}));
break;
case 'variables-show':
setDialogState(prev => ({ ...prev, showVariableModal: true }));
setDialogState(prev => ({...prev, showVariableModal: true}));
break;
case 'change-password':
setDialogState(prev => ({ ...prev, showPasswordChange: true }));
setDialogState(prev => ({...prev, showPasswordChange: true}));
break;
case 'logout':
const success = await handleLogout();
if (success) {
window.location.href = "/actionLogout.do";
window.location.href = `${options.contextPath}/actionLogout.do`;
}
break;
}
@@ -117,14 +122,14 @@ const AppContent = ({ options, showLogin, setShowLogin }) => {
setIsInitialized(true);
checkAuth();
}
}, [isInitialized]);
}, [isInitialized, checkAuth]);
const handleDialogChange = (dialogName, isOpen) => {
setDialogState(prev => ({ ...prev, [dialogName]: isOpen }));
setDialogState(prev => ({...prev, [dialogName]: isOpen}));
};
if (!isAuthenticated) {
return <LoginDialog open={showLogin} onOpenChange={setShowLogin} />;
return <LoginDialog open={showLogin} onOpenChange={setShowLogin}/>;
}
const CurrentScreenComponent = SCREENS[currentScreen] || SCREENS['api-tester'];
@@ -132,7 +137,7 @@ const AppContent = ({ options, showLogin, setShowLogin }) => {
return (
<LoadTestProvider contextPath={options.contextPath}>
<div className="flex flex-col h-screen bg-background">
<AppMenu onMenuClick={handleMenuClick} />
<AppMenu onMenuClick={handleMenuClick}/>
<DialogManager
showAddApi={dialogState.showAddApi}
showAddCollection={dialogState.showAddCollection}
@@ -144,10 +149,11 @@ const AppContent = ({ options, showLogin, setShowLogin }) => {
showUserEdit={dialogState.showUserEdit}
selectedItem={dialogState.selectedItem}
showPasswordChange={dialogState.showPasswordChange}
showServerEdit={dialogState.showServerEdit}
onDialogChange={handleDialogChange}
/>
<div className="flex-1 mt-14">
<CurrentScreenComponent onMenuClick={handleMenuClick} />
<CurrentScreenComponent onMenuClick={handleMenuClick}/>
</div>
</div>
</LoadTestProvider>
@@ -0,0 +1,295 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import { useAPI } from '@/providers/APIProvider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Plus, Trash2 } from 'lucide-react';
const DEFAULT_SERVER = {
id: null,
name: '',
scheme: 'http',
hostname: '',
port: 80,
basePath: '/',
enabled: true,
serverOptions: []
};
const ServerEditDialog = ({ open, onOpenChange, item }) => {
const { createServer, updateServer } = useAPI();
const [server, setServer] = useState(DEFAULT_SERVER);
// useEffect(() => {
// if (item) {
// // Ensure serverOptions is always an array
// setServer({
// ...item,
// serverOptions: item.serverOptions || []
// });
// } else {
// setServer(DEFAULT_SERVER);
// }
// }, [item]);
useEffect(() => {
console.log('Dialog useEffect - item:', item, 'open:', open); // Debug log
if (!open) {
setServer(DEFAULT_SERVER);
return;
}
if (item) {
setServer({
...item,
serverOptions: item.serverOptions || []
});
} else {
setServer(DEFAULT_SERVER);
}
}, [item, open]);
const handleChange = (field, value) => {
setServer(prev => ({ ...prev, [field]: value }));
};
const addOption = () => {
setServer(prev => ({
...prev,
serverOptions: [
...(prev.serverOptions || []),
{
optionKey: '',
optionName: '',
offset: 0,
length: 0,
defaultValue: ''
}
]
}));
};
const removeOption = (index) => {
setServer(prev => {
const newOptions = [...prev.serverOptions];
newOptions.splice(index, 1);
return { ...prev, serverOptions: newOptions };
});
};
const updateOptionField = (index, field, value) => {
setServer(prev => {
const newOptions = [...prev.serverOptions];
newOptions[index] = { ...newOptions[index], [field]: value };
return { ...prev, serverOptions: newOptions };
});
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (server.id) {
await updateServer(server);
} else {
await createServer(server);
}
onOpenChange(false);
} catch (error) {
console.error('Failed to save server:', error);
alert('Failed to save server');
}
};
const handleDialogChange = (open) => {
if (!open) {
setServer(DEFAULT_SERVER);
}
onOpenChange(open);
};
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
{server.id == null ? '서버 등록' : '서버 수정'}
</DialogTitle>
<DialogDescription>
서버의 정보를 입력하거나 수정할 있습니다. 필수 항목을 모두 입력해주세요.
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[80vh]">
<form id="serverForm" onSubmit={handleSubmit} className="space-y-4 p-1">
{/* Server Name */}
<div className="space-y-2">
<Label htmlFor="name" className="must">서버명</Label>
<Input
id="name"
required
value={server.name}
onChange={(e) => handleChange('name', e.target.value)}
/>
</div>
{/* Server Address */}
<div className="space-y-2">
<Label className="must">서버 주소</Label>
<div className="flex gap-2">
<Select
value={server.scheme}
onValueChange={(value) => handleChange('scheme', value)}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Scheme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="tcp">tcp</SelectItem>
</SelectContent>
</Select>
<span className="flex items-center">://</span>
<Input
placeholder="hostname"
required
value={server.hostname}
onChange={(e) => handleChange('hostname', e.target.value)}
/>
<span className="flex items-center">:</span>
<Input
type="number"
required
className="w-[120px]"
value={server.port}
onChange={(e) => handleChange('port', parseInt(e.target.value))}
/>
<Input
placeholder="base path"
required
value={server.basePath}
onChange={(e) => handleChange('basePath', e.target.value)}
/>
</div>
</div>
{/* Enabled Status */}
<div className="flex items-center space-x-2">
<Checkbox
id="enabled"
checked={server.enabled}
onCheckedChange={(checked) => handleChange('enabled', checked)}
/>
<Label htmlFor="enabled">사용</Label>
</div>
{/* Server Options */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label>서버 메시지 옵션</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addOption}
>
<Plus className="h-4 w-4 mr-1" />
옵션 추가
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[150px]">이름</TableHead>
<TableHead className="w-[120px]">메시지 위치</TableHead>
<TableHead className="w-[120px]">메시지 길이</TableHead>
<TableHead>기본 </TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(server.serverOptions || []).map((option, index) => (
<TableRow key={index}>
<TableCell>
<Input
value={option.optionKey}
onChange={(e) => updateOptionField(index, 'optionKey', e.target.value)}
/>
</TableCell>
<TableCell>
<Input
value={option.optionName}
onChange={(e) => updateOptionField(index, 'optionName', e.target.value)}
/>
</TableCell>
<TableCell>
<Input
type="number"
value={option.offset}
onChange={(e) => updateOptionField(index, 'offset', parseInt(e.target.value))}
/>
</TableCell>
<TableCell>
<Input
type="number"
value={option.length}
onChange={(e) => updateOptionField(index, 'length', parseInt(e.target.value))}
/>
</TableCell>
<TableCell>
<Input
value={option.defaultValue}
onChange={(e) => updateOptionField(index, 'defaultValue', e.target.value)}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => removeOption(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</form>
</ScrollArea>
<DialogFooter className="flex justify-end space-x-2">
<Button variant="secondary" onClick={() => handleDialogChange(false)}>
닫기
</Button>
<Button type="submit" variant="default" onClick={handleSubmit}>
저장
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ServerEditDialog;
@@ -0,0 +1,127 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import DataTable from '@/components/shared/DataTable';
import { useAPI } from '@/providers/APIProvider';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
const ServerList = ({ onMenuClick }) => {
const {
serverList,
loadServerList,
deleteServers,
loading
} = useAPI();
const [searchTerm, setSearchTerm] = React.useState('');
const [selectedIds, setSelectedIds] = React.useState([]);
const [page, setPage] = React.useState(0);
React.useEffect(() => {
loadServerList({ page });
}, [page, loadServerList]);
const columns = [
{ key: 'name', label: '이름' },
{
key: 'address',
label: '주소',
render: (_, row) => `${row.scheme}://${row.hostname}:${row.port}${row.basePath}`
},
{
key: 'enabled',
label: '사용 중',
render: (value) => (
<Badge variant={value ? 'success' : 'destructive'}>
{value ? '사용' : '미사용'}
</Badge>
)
}
];
const handleSearch = () => {
setPage(0);
loadServerList({ name: searchTerm, page: 0 });
};
const handleDelete = async () => {
const success = await deleteServers(selectedIds);
if (success) {
setSelectedIds([]);
}
};
const renderRowActions = (row) => (
<>
<Button
variant="outline"
size="sm"
className="mr-2"
// onClick={() => onMenuClick('server-edit', null, {...row})}
onClick={() => {
console.log('Before onMenuClick:', row); // Debug log
onMenuClick('server-edit', null, row);
}}
>
수정
</Button>
</>
);
const tableActions = (
<>
<Button variant="destructive" onClick={handleSearch}>조회</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={selectedIds.length === 0}
>
삭제
</Button>
<Button onClick={() => onMenuClick('server-add')}>등록</Button>
</>
);
return (
<div className="container mx-auto p-4">
<Card>
<CardHeader>
<CardTitle>서버 목록</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-4 mb-4">
<div className="flex-1 max-w-sm">
<Input
placeholder="이름으로 검색"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyUp={(e) => {
if (e.key === 'Enter') {
handleSearch();
}
}}
/>
</div>
{tableActions}
</div>
<DataTable
data={serverList}
columns={columns}
loading={loading}
selectable
selectedIds={selectedIds}
onSelectIds={setSelectedIds}
onPageChange={setPage}
onDelete={handleDelete}
renderRowActions={renderRowActions}
deleteDialogTitle="서버 삭제"
deleteDialogDescription="선택한 서버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
/>
</CardContent>
</Card>
</div>
);
};
export default ServerList;
@@ -1,5 +1,5 @@
import AddAPIDialog from '@/components/api-tester/AddAPIDialog.jsx';
import React from 'react';
import AddAPIDialog from '@/components/api-tester/AddAPIDialog.jsx';
import AddCollectionDialog from '@/components/api-tester/AddCollectionDialog.jsx';
import AddScenarioDialog from '@/components/api-tester/AddScenarioDialog.jsx';
import VariableModal from '@/components/api-tester/VariableModal.jsx';
@@ -8,7 +8,71 @@ import TCPServerEditDialog from '@/components/tcp-servers/TCPServerEditDialog.js
import QueueEditDialog from '@/components/queues/QueueEditDialog.jsx';
import UserEditDialog from '@/components/users/UserEditDialog.jsx';
import PasswordChangeDialog from '@/components/account/PasswordChangeDialog.jsx';
import ServerEditDialog from '@/components/servers/ServerEditDialog.jsx';
// const DialogManager = ({
// showAddApi,
// showAddCollection,
// showAddScenario,
// showVariableModal,
// showMockRouteEdit,
// showTCPServerEdit,
// showQueueEdit,
// showUserEdit,
// selectedItem,
// showPasswordChange,
// showServerEdit,
// onDialogChange
// }) => {
//
// return <>
// <AddCollectionDialog
// open={showAddCollection}
// onOpenChange={(isOpen) => onDialogChange('showAddCollection', isOpen)}
// />
// <AddAPIDialog
// open={showAddApi}
// onOpenChange={(isOpen) => onDialogChange('showAddApi', isOpen)}
// />
// <AddScenarioDialog
// open={showAddScenario}
// onOpenChange={(isOpen) => onDialogChange('showAddScenario', isOpen)}
// />
// <VariableModal
// open={showVariableModal}
// onOpenChange={(isOpen) => onDialogChange('showVariableModal', isOpen)}
// />
// <MockRouteEditDialog
// open={showMockRouteEdit}
// onOpenChange={(isOpen) => onDialogChange('showMockRouteEdit', isOpen)}
// item={selectedItem}
// />
// <TCPServerEditDialog
// open={showTCPServerEdit}
// onOpenChange={(isOpen) => onDialogChange('showTCPServerEdit', isOpen)}
// item={selectedItem}
// />
// <QueueEditDialog
// open={showQueueEdit}
// onOpenChange={(isOpen) => onDialogChange('showQueueEdit', isOpen)}
// item={selectedItem}
// />
// <UserEditDialog
// open={showUserEdit}
// onOpenChange={(isOpen) => onDialogChange('showUserEdit', isOpen)}
// item={selectedItem}
// />
// <PasswordChangeDialog
// open={showPasswordChange}
// onOpenChange={(isOpen) => onDialogChange('showPasswordChange', isOpen)}
// />
// <ServerEditDialog
// open={showServerEdit}
// onOpenChange={(isOpen) => onDialogChange('showServerEdit', isOpen)}
// item={selectedItem}
// />
// </>;
// }
const DialogManager = ({
showAddApi,
showAddCollection,
@@ -18,12 +82,12 @@ const DialogManager = ({
showTCPServerEdit,
showQueueEdit,
showUserEdit,
showServerEdit,
selectedItem,
showPasswordChange,
onDialogChange
}) => {
return <>
return (
<>
<AddCollectionDialog
open={showAddCollection}
onOpenChange={(isOpen) => onDialogChange('showAddCollection', isOpen)}
@@ -60,11 +124,13 @@ const DialogManager = ({
onOpenChange={(isOpen) => onDialogChange('showUserEdit', isOpen)}
item={selectedItem}
/>
<PasswordChangeDialog
open={showPasswordChange}
onOpenChange={(isOpen) => onDialogChange('showPasswordChange', isOpen)}
<ServerEditDialog
open={showServerEdit}
onOpenChange={(isOpen) => onDialogChange('showServerEdit', isOpen)}
item={selectedItem}
/>
</>;
}
</>
);
};
export default DialogManager;
+4 -6
View File
@@ -5,10 +5,10 @@ export const menuConfig = [
label: '응답관리',
items: [
{id: 'response-list', label: 'API 응답 목록', action: 'response-list', screen: 'response-list'},
{id: 'response-add', label: 'API 응답 추가', action: 'response-add'},
{type: 'separator'},
// {id: 'response-add', label: 'API 응답 추가', action: 'response-add'},
// {type: 'separator'},
{id: 'socket-list', label: 'TCP 서버 목록', action: 'socket-list', screen: 'socket-list'},
{id: 'socket-add', label: 'TCP 서버 추가', action: 'socket-add'},
// {id: 'socket-add', label: 'TCP 서버 추가', action: 'socket-add'},
]
},
{
@@ -47,9 +47,7 @@ export const menuConfig = [
label: '시스템 관리',
items: [
{id: 'user-list', label: '사용자 관리', action: 'user-list', screen: 'user-list'},
{type: 'separator'},
{id: 'server-management', label: '서버 관리', action: 'server-management', screen: 'server-management'},
{id: 'server-add', label: '서버 추가', action: 'server-add', screen: 'server-add'},
{id: 'server-list', label: '서버 관리', action: 'server-list', screen: 'server-list'},
]
},
{
+2
View File
@@ -5,6 +5,7 @@ import MockRouteList from '../components/mock-route/MockRouteList.jsx';
import TCPServerList from '@/components/tcp-servers/TCPServerList.jsx';
import QueueList from '@/components/queues/QueueList.jsx';
import UserList from '@/components/users/UserList.jsx';
import ServerList from '@/components/servers/ServerList.jsx';
export const SCREENS = {
'api-tester': APITester,
@@ -13,4 +14,5 @@ export const SCREENS = {
'socket-list': TCPServerList,
'queue-list': QueueList,
'user-list': UserList,
'server-list': ServerList
};
@@ -52,6 +52,14 @@ export const APIProvider = ({
last: true
});
const [serverList, setServerList] = useState({
content: [],
totalPages: 0,
number: 0,
first: true,
last: true
});
// Initialize services
const collections$ = collectionService(contextPath);
@@ -447,6 +455,66 @@ export const APIProvider = ({
}
}, []);
const loadServerList = useCallback(async (params = { page: 0 }) => {
setLoading(true);
setError(null);
try {
const data = await servers$.loadServerList(params);
setServerList(data);
return data;
} catch (err) {
setError(err.message);
return null;
} finally {
setLoading(false);
}
}, []);
const deleteServers = useCallback(async (ids) => {
setLoading(true);
setError(null);
try {
await servers$.deleteServers(ids);
await loadServerList();
return true;
} catch (err) {
setError(err.message);
return false;
} finally {
setLoading(false);
}
}, [loadServerList]);
const createServer = useCallback(async (server) => {
setLoading(true);
setError(null);
try {
const newServer = await servers$.createServer(server);
await loadServerList();
return newServer;
} catch (err) {
setError(err.message);
return null;
} finally {
setLoading(false);
}
}, [loadServerList]);
const updateServer = useCallback(async (server) => {
setLoading(true);
setError(null);
try {
const updatedServer = await servers$.updateServer(server.id, server);
await loadServerList();
return updatedServer;
} catch (err) {
setError(err.message);
return null;
} finally {
setLoading(false);
}
}, [loadServerList]);
const contextValue = {
apiClient,
collections,
@@ -454,6 +522,11 @@ export const APIProvider = ({
servers,
loadServers,
getServer,
serverList,
loadServerList,
deleteServers,
createServer,
updateServer,
loading,
error,
loadCollections,
@@ -1,3 +1,5 @@
import {getCSRFToken} from '@/services/token.js';
export const serverService = () => {
return {
async loadServers() {
@@ -13,6 +15,60 @@ export const serverService = () => {
basePath: server?.basePath || '',
environment: server?.environment || 'unknown'
}));
},
async loadServerList(params = { page: 0, name: '' }) {
const searchParams = new URLSearchParams({
page: params.page,
size: 10,
...(params.name && { name: params.name })
});
const response = await fetch(`/servers?${searchParams}`);
if (!response.ok) throw new Error('Failed to load servers');
return response.json();
},
async createServer(server) {
const response = await fetch('/servers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': getCSRFToken()
},
body: JSON.stringify(server)
});
if (!response.ok) throw new Error('Failed to create server');
return response.json();
},
async updateServer(id, server) {
const response = await fetch(`/servers/update/${id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': getCSRFToken()
},
body: JSON.stringify(server)
});
if (!response.ok) throw new Error('Failed to update server');
return response.json();
},
async deleteServers(ids) {
const response = await fetch('/servers/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': getCSRFToken()
},
body: JSON.stringify(ids)
});
if (!response.ok) throw new Error('Failed to delete servers');
return response.json();
}
};
};