feat(cluster): add cluster configuration options and update system settings

This commit is contained in:
samanhappy
2025-10-31 22:26:57 +08:00
parent 8945b583a7
commit 16112a78c9
7 changed files with 980 additions and 135 deletions

View File

@@ -5,6 +5,7 @@ export const PERMISSIONS = {
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
SETTINGS_CLUSTER_CONFIG: 'settings:cluster_config',
} as const;
export default PERMISSIONS;

View File

@@ -34,6 +34,35 @@ interface MCPRouterConfig {
baseUrl: string;
}
interface ClusterNodeConfig {
id?: string;
name?: string;
coordinatorUrl: string;
heartbeatInterval?: number;
registerOnStartup?: boolean;
}
interface ClusterCoordinatorConfig {
nodeTimeout?: number;
cleanupInterval?: number;
stickySessionTimeout?: number;
}
interface ClusterStickySessionConfig {
enabled: boolean;
strategy: 'consistent-hash' | 'cookie' | 'header';
cookieName?: string;
headerName?: string;
}
interface ClusterConfig {
enabled: boolean;
mode: 'standalone' | 'node' | 'coordinator';
node?: ClusterNodeConfig;
coordinator?: ClusterCoordinatorConfig;
stickySession?: ClusterStickySessionConfig;
}
interface SystemSettings {
systemConfig?: {
routing?: RoutingConfig;
@@ -41,6 +70,7 @@ interface SystemSettings {
smartRouting?: SmartRoutingConfig;
mcpRouter?: MCPRouterConfig;
nameSeparator?: string;
cluster?: ClusterConfig;
};
}
@@ -85,6 +115,27 @@ export const useSettingsData = () => {
baseUrl: 'https://api.mcprouter.to/v1',
});
const [clusterConfig, setClusterConfig] = useState<ClusterConfig>({
enabled: false,
mode: 'standalone',
node: {
coordinatorUrl: '',
heartbeatInterval: 5000,
registerOnStartup: true,
},
coordinator: {
nodeTimeout: 15000,
cleanupInterval: 30000,
stickySessionTimeout: 3600000,
},
stickySession: {
enabled: true,
strategy: 'consistent-hash',
cookieName: 'MCPHUB_NODE',
headerName: 'X-MCPHub-Node',
},
});
const [nameSeparator, setNameSeparator] = useState<string>('-');
const [loading, setLoading] = useState(false);
@@ -141,6 +192,28 @@ export const useSettingsData = () => {
if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) {
setNameSeparator(data.data.systemConfig.nameSeparator);
}
if (data.success && data.data?.systemConfig?.cluster) {
setClusterConfig({
enabled: data.data.systemConfig.cluster.enabled ?? false,
mode: data.data.systemConfig.cluster.mode || 'standalone',
node: data.data.systemConfig.cluster.node || {
coordinatorUrl: '',
heartbeatInterval: 5000,
registerOnStartup: true,
},
coordinator: data.data.systemConfig.cluster.coordinator || {
nodeTimeout: 15000,
cleanupInterval: 30000,
stickySessionTimeout: 3600000,
},
stickySession: data.data.systemConfig.cluster.stickySession || {
enabled: true,
strategy: 'consistent-hash',
cookieName: 'MCPHUB_NODE',
headerName: 'X-MCPHub-Node',
},
});
}
} catch (error) {
console.error('Failed to fetch settings:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
@@ -420,6 +493,39 @@ export const useSettingsData = () => {
}
};
// Update cluster configuration
const updateClusterConfig = async (updates: Partial<ClusterConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
cluster: updates,
});
if (data.success) {
setClusterConfig({
...clusterConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update cluster config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update cluster config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
const exportMCPSettings = async (serverName?: string) => {
setLoading(true);
setError(null);
@@ -455,6 +561,7 @@ export const useSettingsData = () => {
installConfig,
smartRoutingConfig,
mcpRouterConfig,
clusterConfig,
nameSeparator,
loading,
error,
@@ -468,6 +575,7 @@ export const useSettingsData = () => {
updateRoutingConfigBatch,
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
updateClusterConfig,
updateNameSeparator,
exportMCPSettings,
};

View File

@@ -1,55 +1,99 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import ChangePasswordForm from '@/components/ChangePasswordForm'
import { Switch } from '@/components/ui/ToggleGroup'
import { useSettingsData } from '@/hooks/useSettingsData'
import { useToast } from '@/contexts/ToastContext'
import { generateRandomKey } from '@/utils/key'
import { PermissionChecker } from '@/components/PermissionChecker'
import { PERMISSIONS } from '@/constants/permissions'
import { Copy, Check, Download } from 'lucide-react'
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ChangePasswordForm from '@/components/ChangePasswordForm';
import { Switch } from '@/components/ui/ToggleGroup';
import { useSettingsData } from '@/hooks/useSettingsData';
import { useToast } from '@/contexts/ToastContext';
import { generateRandomKey } from '@/utils/key';
import { PermissionChecker } from '@/components/PermissionChecker';
import { PERMISSIONS } from '@/constants/permissions';
import { Copy, Check, Download } from 'lucide-react';
const SettingsPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { showToast } = useToast()
const { t } = useTranslation();
const navigate = useNavigate();
const { showToast } = useToast();
const [installConfig, setInstallConfig] = useState<{
pythonIndexUrl: string
npmRegistry: string
baseUrl: string
pythonIndexUrl: string;
npmRegistry: string;
baseUrl: string;
}>({
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
})
});
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
dbUrl: string
openaiApiBaseUrl: string
openaiApiKey: string
openaiApiEmbeddingModel: string
dbUrl: string;
openaiApiBaseUrl: string;
openaiApiKey: string;
openaiApiEmbeddingModel: string;
}>({
dbUrl: '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
})
});
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
apiKey: string
referer: string
title: string
baseUrl: string
apiKey: string;
referer: string;
title: string;
baseUrl: string;
}>({
apiKey: '',
referer: 'https://www.mcphubx.com',
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
})
});
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
const [tempClusterConfig, setTempClusterConfig] = useState<{
enabled: boolean;
mode: 'standalone' | 'node' | 'coordinator';
node: {
id?: string;
name?: string;
coordinatorUrl: string;
heartbeatInterval?: number;
registerOnStartup?: boolean;
};
coordinator: {
nodeTimeout?: number;
cleanupInterval?: number;
stickySessionTimeout?: number;
};
stickySession: {
enabled: boolean;
strategy: 'consistent-hash' | 'cookie' | 'header';
cookieName?: string;
headerName?: string;
};
}>({
enabled: false,
mode: 'standalone',
node: {
id: '',
name: '',
coordinatorUrl: '',
heartbeatInterval: 5000,
registerOnStartup: true,
},
coordinator: {
nodeTimeout: 15000,
cleanupInterval: 30000,
stickySessionTimeout: 3600000,
},
stickySession: {
enabled: true,
strategy: 'consistent-hash',
cookieName: 'MCPHUB_NODE',
headerName: 'X-MCPHub-Node',
},
});
const {
routingConfig,
@@ -58,6 +102,7 @@ const SettingsPage: React.FC = () => {
installConfig: savedInstallConfig,
smartRoutingConfig,
mcpRouterConfig,
clusterConfig,
nameSeparator,
loading,
updateRoutingConfig,
@@ -66,16 +111,17 @@ const SettingsPage: React.FC = () => {
updateSmartRoutingConfig,
updateSmartRoutingConfigBatch,
updateMCPRouterConfig,
updateClusterConfig,
updateNameSeparator,
exportMCPSettings,
} = useSettingsData()
} = useSettingsData();
// Update local installConfig when savedInstallConfig changes
useEffect(() => {
if (savedInstallConfig) {
setInstallConfig(savedInstallConfig)
setInstallConfig(savedInstallConfig);
}
}, [savedInstallConfig])
}, [savedInstallConfig]);
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
useEffect(() => {
@@ -85,9 +131,9 @@ const SettingsPage: React.FC = () => {
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
})
});
}
}, [smartRoutingConfig])
}, [smartRoutingConfig]);
// Update local tempMCPRouterConfig when mcpRouterConfig changes
useEffect(() => {
@@ -97,24 +143,53 @@ const SettingsPage: React.FC = () => {
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
title: mcpRouterConfig.title || 'MCPHub',
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
})
});
}
}, [mcpRouterConfig])
}, [mcpRouterConfig]);
// Update local tempNameSeparator when nameSeparator changes
useEffect(() => {
setTempNameSeparator(nameSeparator)
}, [nameSeparator])
setTempNameSeparator(nameSeparator);
}, [nameSeparator]);
// Update local tempClusterConfig when clusterConfig changes
useEffect(() => {
if (clusterConfig) {
setTempClusterConfig({
enabled: clusterConfig.enabled ?? false,
mode: clusterConfig.mode || 'standalone',
node: clusterConfig.node || {
id: '',
name: '',
coordinatorUrl: '',
heartbeatInterval: 5000,
registerOnStartup: true,
},
coordinator: clusterConfig.coordinator || {
nodeTimeout: 15000,
cleanupInterval: 30000,
stickySessionTimeout: 3600000,
},
stickySession: clusterConfig.stickySession || {
enabled: true,
strategy: 'consistent-hash',
cookieName: 'MCPHUB_NODE',
headerName: 'X-MCPHub-Node',
},
});
}
}, [clusterConfig]);
const [sectionsVisible, setSectionsVisible] = useState({
routingConfig: false,
installConfig: false,
smartRoutingConfig: false,
mcpRouterConfig: false,
clusterConfig: false,
nameSeparator: false,
password: false,
exportConfig: false,
})
});
const toggleSection = (
section:
@@ -122,6 +197,7 @@ const SettingsPage: React.FC = () => {
| 'installConfig'
| 'smartRoutingConfig'
| 'mcpRouterConfig'
| 'clusterConfig'
| 'nameSeparator'
| 'password'
| 'exportConfig',
@@ -129,8 +205,8 @@ const SettingsPage: React.FC = () => {
setSectionsVisible((prev) => ({
...prev,
[section]: !prev[section],
}))
}
}));
};
const handleRoutingConfigChange = async (
key:
@@ -144,39 +220,39 @@ const SettingsPage: React.FC = () => {
// If enableBearerAuth is turned on and there's no key, generate one first
if (key === 'enableBearerAuth' && value === true) {
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
const newKey = generateRandomKey()
handleBearerAuthKeyChange(newKey)
const newKey = generateRandomKey();
handleBearerAuthKeyChange(newKey);
// Update both enableBearerAuth and bearerAuthKey in a single call
const success = await updateRoutingConfigBatch({
enableBearerAuth: true,
bearerAuthKey: newKey,
})
});
if (success) {
// Update tempRoutingConfig to reflect the saved values
setTempRoutingConfig((prev) => ({
...prev,
bearerAuthKey: newKey,
}))
}));
}
return
return;
}
}
await updateRoutingConfig(key, value)
}
await updateRoutingConfig(key, value);
};
const handleBearerAuthKeyChange = (value: string) => {
setTempRoutingConfig((prev) => ({
...prev,
bearerAuthKey: value,
}))
}
}));
};
const saveBearerAuthKey = async () => {
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey)
}
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
};
const handleInstallConfigChange = (
key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl',
@@ -185,12 +261,12 @@ const SettingsPage: React.FC = () => {
setInstallConfig({
...installConfig,
[key]: value,
})
}
});
};
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
await updateInstallConfig(key, installConfig[key])
}
await updateInstallConfig(key, installConfig[key]);
};
const handleSmartRoutingConfigChange = (
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
@@ -199,14 +275,14 @@ const SettingsPage: React.FC = () => {
setTempSmartRoutingConfig({
...tempSmartRoutingConfig,
[key]: value,
})
}
});
};
const saveSmartRoutingConfig = async (
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
) => {
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key])
}
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
};
const handleMCPRouterConfigChange = (
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
@@ -215,141 +291,141 @@ const SettingsPage: React.FC = () => {
setTempMCPRouterConfig({
...tempMCPRouterConfig,
[key]: value,
})
}
});
};
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
}
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
};
const saveNameSeparator = async () => {
await updateNameSeparator(tempNameSeparator)
}
await updateNameSeparator(tempNameSeparator);
};
const handleSmartRoutingEnabledChange = async (value: boolean) => {
// If enabling Smart Routing, validate required fields and save any unsaved changes
if (value) {
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
const currentOpenaiApiKey =
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
if (!currentDbUrl || !currentOpenaiApiKey) {
const missingFields = []
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'))
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'))
const missingFields = [];
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
showToast(
t('settings.smartRoutingValidationError', {
fields: missingFields.join(', '),
}),
)
return
);
return;
}
// Prepare updates object with unsaved changes and enabled status
const updates: any = { enabled: value }
const updates: any = { enabled: value };
// Check for unsaved changes and include them in the batch update
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
updates.dbUrl = tempSmartRoutingConfig.dbUrl
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
}
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
}
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
}
if (
tempSmartRoutingConfig.openaiApiEmbeddingModel !==
smartRoutingConfig.openaiApiEmbeddingModel
) {
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
}
// Save all changes in a single batch update
await updateSmartRoutingConfigBatch(updates)
await updateSmartRoutingConfigBatch(updates);
} else {
// If disabling, just update the enabled status
await updateSmartRoutingConfig('enabled', value)
await updateSmartRoutingConfig('enabled', value);
}
}
};
const handlePasswordChangeSuccess = () => {
setTimeout(() => {
navigate('/')
}, 2000)
}
navigate('/');
}, 2000);
};
const [copiedConfig, setCopiedConfig] = useState(false)
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('')
const [copiedConfig, setCopiedConfig] = useState(false);
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('');
const fetchMcpSettings = async () => {
try {
const result = await exportMCPSettings()
console.log('Fetched MCP settings:', result)
const configJson = JSON.stringify(result.data, null, 2)
setMcpSettingsJson(configJson)
const result = await exportMCPSettings();
console.log('Fetched MCP settings:', result);
const configJson = JSON.stringify(result.data, null, 2);
setMcpSettingsJson(configJson);
} catch (error) {
console.error('Error fetching MCP settings:', error)
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error')
console.error('Error fetching MCP settings:', error);
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error');
}
}
};
useEffect(() => {
if (sectionsVisible.exportConfig && !mcpSettingsJson) {
fetchMcpSettings()
fetchMcpSettings();
}
}, [sectionsVisible.exportConfig])
}, [sectionsVisible.exportConfig]);
const handleCopyConfig = async () => {
if (!mcpSettingsJson) return
if (!mcpSettingsJson) return;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(mcpSettingsJson)
setCopiedConfig(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopiedConfig(false), 2000)
await navigator.clipboard.writeText(mcpSettingsJson);
setCopiedConfig(true);
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
setTimeout(() => setCopiedConfig(false), 2000);
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = mcpSettingsJson
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const textArea = document.createElement('textarea');
textArea.value = mcpSettingsJson;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy')
setCopiedConfig(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopiedConfig(false), 2000)
document.execCommand('copy');
setCopiedConfig(true);
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
setTimeout(() => setCopiedConfig(false), 2000);
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
showToast(t('common.copyFailed') || 'Copy failed', 'error');
console.error('Copy to clipboard failed:', err);
}
document.body.removeChild(textArea)
document.body.removeChild(textArea);
}
} catch (error) {
console.error('Error copying configuration:', error)
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Error copying configuration:', error);
showToast(t('common.copyFailed') || 'Copy failed', 'error');
}
}
};
const handleDownloadConfig = () => {
if (!mcpSettingsJson) return
if (!mcpSettingsJson) return;
const blob = new Blob([mcpSettingsJson], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'mcp_settings.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success')
}
const blob = new Blob([mcpSettingsJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'mcp_settings.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success');
};
return (
<div className="container mx-auto">
@@ -563,6 +639,432 @@ const SettingsPage: React.FC = () => {
</div>
</PermissionChecker>
{/* Cluster Configuration Settings */}
<PermissionChecker permissions={PERMISSIONS.SETTINGS_CLUSTER_CONFIG}>
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card dashboard-card">
<div
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
onClick={() => toggleSection('clusterConfig')}
>
<h2 className="font-semibold text-gray-800">{t('settings.clusterConfig')}</h2>
<span className="text-gray-500 transition-transform duration-200">
{sectionsVisible.clusterConfig ? '▼' : '►'}
</span>
</div>
{sectionsVisible.clusterConfig && (
<div className="space-y-4 mt-4">
{/* Enable Cluster Mode */}
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.clusterEnabled')}</h3>
<p className="text-sm text-gray-500">{t('settings.clusterEnabledDescription')}</p>
</div>
<Switch
disabled={loading}
checked={tempClusterConfig.enabled}
onCheckedChange={(checked) => {
setTempClusterConfig((prev) => ({ ...prev, enabled: checked }));
updateClusterConfig({ enabled: checked });
}}
/>
</div>
{/* Cluster Mode Selection */}
{tempClusterConfig.enabled && (
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.clusterMode')}</h3>
<p className="text-sm text-gray-500">{t('settings.clusterModeDescription')}</p>
</div>
<select
value={tempClusterConfig.mode}
onChange={(e) => {
const mode = e.target.value as 'standalone' | 'node' | 'coordinator';
setTempClusterConfig((prev) => ({ ...prev, mode }));
updateClusterConfig({ mode });
}}
className="mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
>
<option value="standalone">{t('settings.clusterModeStandalone')}</option>
<option value="node">{t('settings.clusterModeNode')}</option>
<option value="coordinator">{t('settings.clusterModeCoordinator')}</option>
</select>
</div>
)}
{/* Node Configuration */}
{tempClusterConfig.enabled && tempClusterConfig.mode === 'node' && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md space-y-3">
<h3 className="font-semibold text-gray-800 mb-2">{t('settings.nodeConfig')}</h3>
{/* Coordinator URL */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.coordinatorUrl')} <span className="text-red-500">*</span>
</label>
<p className="text-xs text-gray-500 mb-2">
{t('settings.coordinatorUrlDescription')}
</p>
<input
type="text"
value={tempClusterConfig.node.coordinatorUrl}
onChange={(e) => {
const coordinatorUrl = e.target.value;
setTempClusterConfig((prev) => ({
...prev,
node: { ...prev.node, coordinatorUrl },
}));
}}
onBlur={() => updateClusterConfig({ node: { ...tempClusterConfig.node } })}
placeholder={t('settings.coordinatorUrlPlaceholder')}
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
/>
</div>
{/* Node ID */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.nodeId')}
</label>
<p className="text-xs text-gray-500 mb-2">{t('settings.nodeIdDescription')}</p>
<input
type="text"
value={tempClusterConfig.node.id || ''}
onChange={(e) => {
const id = e.target.value;
setTempClusterConfig((prev) => ({
...prev,
node: { ...prev.node, id },
}));
}}
onBlur={() => updateClusterConfig({ node: { ...tempClusterConfig.node } })}
placeholder={t('settings.nodeIdPlaceholder')}
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
/>
</div>
{/* Node Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.nodeName')}
</label>
<p className="text-xs text-gray-500 mb-2">
{t('settings.nodeNameDescription')}
</p>
<input
type="text"
value={tempClusterConfig.node.name || ''}
onChange={(e) => {
const name = e.target.value;
setTempClusterConfig((prev) => ({
...prev,
node: { ...prev.node, name },
}));
}}
onBlur={() => updateClusterConfig({ node: { ...tempClusterConfig.node } })}
placeholder={t('settings.nodeNamePlaceholder')}
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
/>
</div>
{/* Heartbeat Interval */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.heartbeatInterval')}
</label>
<p className="text-xs text-gray-500 mb-2">
{t('settings.heartbeatIntervalDescription')}
</p>
<input
type="number"
value={tempClusterConfig.node.heartbeatInterval || 5000}
onChange={(e) => {
const heartbeatInterval = parseInt(e.target.value);
setTempClusterConfig((prev) => ({
...prev,
node: { ...prev.node, heartbeatInterval },
}));
}}
onBlur={() => updateClusterConfig({ node: { ...tempClusterConfig.node } })}
placeholder={t('settings.heartbeatIntervalPlaceholder')}
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
min="1000"
step="1000"
/>
</div>
{/* Register on Startup */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">
{t('settings.registerOnStartup')}
</label>
<p className="text-xs text-gray-500">
{t('settings.registerOnStartupDescription')}
</p>
</div>
<Switch
disabled={loading}
checked={tempClusterConfig.node.registerOnStartup ?? true}
onCheckedChange={(checked) => {
setTempClusterConfig((prev) => ({
...prev,
node: { ...prev.node, registerOnStartup: checked },
}));
updateClusterConfig({
node: { ...tempClusterConfig.node, registerOnStartup: checked },
});
}}
/>
</div>
</div>
)}
{/* Coordinator Configuration */}
{tempClusterConfig.enabled && tempClusterConfig.mode === 'coordinator' && (
<div className="p-3 bg-purple-50 border border-purple-200 rounded-md space-y-3">
<h3 className="font-semibold text-gray-800 mb-2">
{t('settings.coordinatorConfig')}
</h3>
{/* Node Timeout */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.nodeTimeout')}
</label>
<p className="text-xs text-gray-500 mb-2">
{t('settings.nodeTimeoutDescription')}
</p>
<input
type="number"
value={tempClusterConfig.coordinator.nodeTimeout || 15000}
onChange={(e) => {
const nodeTimeout = parseInt(e.target.value);
setTempClusterConfig((prev) => ({
...prev,
coordinator: { ...prev.coordinator, nodeTimeout },
}));
}}
onBlur={() =>
updateClusterConfig({ coordinator: { ...tempClusterConfig.coordinator } })
}
placeholder={t('settings.nodeTimeoutPlaceholder')}
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
min="5000"
step="1000"
/>
</div>
{/* Cleanup Interval */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.cleanupInterval')}
</label>
<p className="text-xs text-gray-500 mb-2">
{t('settings.cleanupIntervalDescription')}
</p>
<input
type="number"
value={tempClusterConfig.coordinator.cleanupInterval || 30000}
onChange={(e) => {
const cleanupInterval = parseInt(e.target.value);
setTempClusterConfig((prev) => ({
...prev,
coordinator: { ...prev.coordinator, cleanupInterval },
}));
}}
onBlur={() =>
updateClusterConfig({ coordinator: { ...tempClusterConfig.coordinator } })
}
placeholder={t('settings.cleanupIntervalPlaceholder')}
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
min="10000"
step="5000"
/>
</div>
{/* Sticky Session Timeout */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.stickySessionTimeout')}
</label>
<p className="text-xs text-gray-500 mb-2">
{t('settings.stickySessionTimeoutDescription')}
</p>
<input
type="number"
value={tempClusterConfig.coordinator.stickySessionTimeout || 3600000}
onChange={(e) => {
const stickySessionTimeout = parseInt(e.target.value);
setTempClusterConfig((prev) => ({
...prev,
coordinator: { ...prev.coordinator, stickySessionTimeout },
}));
}}
onBlur={() =>
updateClusterConfig({ coordinator: { ...tempClusterConfig.coordinator } })
}
placeholder={t('settings.stickySessionTimeoutPlaceholder')}
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
min="60000"
step="60000"
/>
</div>
</div>
)}
{/* Sticky Session Configuration */}
{tempClusterConfig.enabled &&
(tempClusterConfig.mode === 'coordinator' || tempClusterConfig.mode === 'node') && (
<div className="p-3 bg-green-50 border border-green-200 rounded-md space-y-3">
<h3 className="font-semibold text-gray-800 mb-2">
{t('settings.stickySessionConfig')}
</h3>
{/* Enable Sticky Sessions */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">
{t('settings.stickySessionEnabled')}
</label>
<p className="text-xs text-gray-500">
{t('settings.stickySessionEnabledDescription')}
</p>
</div>
<Switch
disabled={loading}
checked={tempClusterConfig.stickySession.enabled}
onCheckedChange={(checked) => {
setTempClusterConfig((prev) => ({
...prev,
stickySession: { ...prev.stickySession, enabled: checked },
}));
updateClusterConfig({
stickySession: { ...tempClusterConfig.stickySession, enabled: checked },
});
}}
/>
</div>
{tempClusterConfig.stickySession.enabled && (
<>
{/* Session Strategy */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.stickySessionStrategy')}
</label>
<p className="text-xs text-gray-500 mb-2">
{t('settings.stickySessionStrategyDescription')}
</p>
<select
value={tempClusterConfig.stickySession.strategy}
onChange={(e) => {
const strategy = e.target.value as
| 'consistent-hash'
| 'cookie'
| 'header';
setTempClusterConfig((prev) => ({
...prev,
stickySession: { ...prev.stickySession, strategy },
}));
updateClusterConfig({
stickySession: { ...tempClusterConfig.stickySession, strategy },
});
}}
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
>
<option value="consistent-hash">
{t('settings.stickySessionStrategyConsistentHash')}
</option>
<option value="cookie">
{t('settings.stickySessionStrategyCookie')}
</option>
<option value="header">
{t('settings.stickySessionStrategyHeader')}
</option>
</select>
</div>
{/* Cookie Name (only for cookie strategy) */}
{tempClusterConfig.stickySession.strategy === 'cookie' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.cookieName')}
</label>
<p className="text-xs text-gray-500 mb-2">
{t('settings.cookieNameDescription')}
</p>
<input
type="text"
value={tempClusterConfig.stickySession.cookieName || 'MCPHUB_NODE'}
onChange={(e) => {
const cookieName = e.target.value;
setTempClusterConfig((prev) => ({
...prev,
stickySession: { ...prev.stickySession, cookieName },
}));
}}
onBlur={() =>
updateClusterConfig({
stickySession: { ...tempClusterConfig.stickySession },
})
}
placeholder={t('settings.cookieNamePlaceholder')}
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
/>
</div>
)}
{/* Header Name (only for header strategy) */}
{tempClusterConfig.stickySession.strategy === 'header' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.headerName')}
</label>
<p className="text-xs text-gray-500 mb-2">
{t('settings.headerNameDescription')}
</p>
<input
type="text"
value={tempClusterConfig.stickySession.headerName || 'X-MCPHub-Node'}
onChange={(e) => {
const headerName = e.target.value;
setTempClusterConfig((prev) => ({
...prev,
stickySession: { ...prev.stickySession, headerName },
}));
}}
onBlur={() =>
updateClusterConfig({
stickySession: { ...tempClusterConfig.stickySession },
})
}
placeholder={t('settings.headerNamePlaceholder')}
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
/>
</div>
)}
</>
)}
</div>
)}
</div>
)}
</div>
</PermissionChecker>
{/* System Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
<div
@@ -794,7 +1296,10 @@ const SettingsPage: React.FC = () => {
</PermissionChecker>
{/* Change Password */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card" data-section="password">
<div
className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card"
data-section="password"
>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('password')}
@@ -864,7 +1369,7 @@ const SettingsPage: React.FC = () => {
</div>
</PermissionChecker>
</div>
)
}
);
};
export default SettingsPage
export default SettingsPage;

View File

@@ -574,6 +574,53 @@
"systemSettings": "System Settings",
"nameSeparatorLabel": "Name Separator",
"nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)",
"clusterConfig": "Cluster Configuration",
"clusterEnabled": "Enable Cluster Mode",
"clusterEnabledDescription": "Enable distributed cluster deployment for high availability and scalability",
"clusterMode": "Cluster Mode",
"clusterModeDescription": "Select the operating mode for this instance",
"clusterModeStandalone": "Standalone",
"clusterModeNode": "Node",
"clusterModeCoordinator": "Coordinator",
"nodeConfig": "Node Configuration",
"nodeId": "Node ID",
"nodeIdDescription": "Unique identifier for this node (auto-generated if not provided)",
"nodeIdPlaceholder": "e.g. node-1",
"nodeName": "Node Name",
"nodeNameDescription": "Human-readable name for this node (defaults to hostname)",
"nodeNamePlaceholder": "e.g. mcp-node-1",
"coordinatorUrl": "Coordinator URL",
"coordinatorUrlDescription": "URL of the coordinator node to register with",
"coordinatorUrlPlaceholder": "http://coordinator:3000",
"heartbeatInterval": "Heartbeat Interval (ms)",
"heartbeatIntervalDescription": "Interval in milliseconds between heartbeat signals (default: 5000)",
"heartbeatIntervalPlaceholder": "5000",
"registerOnStartup": "Register on Startup",
"registerOnStartupDescription": "Automatically register with coordinator when node starts (default: true)",
"coordinatorConfig": "Coordinator Configuration",
"nodeTimeout": "Node Timeout (ms)",
"nodeTimeoutDescription": "Time in milliseconds before marking a node as unhealthy (default: 15000)",
"nodeTimeoutPlaceholder": "15000",
"cleanupInterval": "Cleanup Interval (ms)",
"cleanupIntervalDescription": "Interval for cleaning up inactive nodes in milliseconds (default: 30000)",
"cleanupIntervalPlaceholder": "30000",
"stickySessionTimeout": "Sticky Session Timeout (ms)",
"stickySessionTimeoutDescription": "Session timeout in milliseconds (default: 3600000 = 1 hour)",
"stickySessionTimeoutPlaceholder": "3600000",
"stickySessionConfig": "Sticky Session Configuration",
"stickySessionEnabled": "Enable Sticky Sessions",
"stickySessionEnabledDescription": "Enable session affinity to route requests from the same client to the same node",
"stickySessionStrategy": "Session Strategy",
"stickySessionStrategyDescription": "Strategy for maintaining session affinity",
"stickySessionStrategyConsistentHash": "Consistent Hash",
"stickySessionStrategyCookie": "Cookie",
"stickySessionStrategyHeader": "Header",
"cookieName": "Cookie Name",
"cookieNameDescription": "Cookie name for cookie-based sticky sessions (default: MCPHUB_NODE)",
"cookieNamePlaceholder": "MCPHUB_NODE",
"headerName": "Header Name",
"headerNameDescription": "Header name for header-based sticky sessions (default: X-MCPHub-Node)",
"headerNamePlaceholder": "X-MCPHub-Node",
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly.",
"exportMcpSettings": "Export Settings",
"mcpSettingsJson": "MCP Settings JSON",

View File

@@ -574,6 +574,53 @@
"systemSettings": "Paramètres système",
"nameSeparatorLabel": "Séparateur de noms",
"nameSeparatorDescription": "Caractère utilisé pour séparer le nom du serveur et le nom de l'outil/prompt (par défaut : -)",
"clusterConfig": "Configuration du cluster",
"clusterEnabled": "Activer le mode cluster",
"clusterEnabledDescription": "Activer le déploiement en cluster distribué pour la haute disponibilité et l'évolutivité",
"clusterMode": "Mode cluster",
"clusterModeDescription": "Sélectionnez le mode de fonctionnement pour cette instance",
"clusterModeStandalone": "Autonome",
"clusterModeNode": "Nœud",
"clusterModeCoordinator": "Coordinateur",
"nodeConfig": "Configuration du nœud",
"nodeId": "ID du nœud",
"nodeIdDescription": "Identifiant unique pour ce nœud (généré automatiquement si non fourni)",
"nodeIdPlaceholder": "ex. node-1",
"nodeName": "Nom du nœud",
"nodeNameDescription": "Nom lisible par l'homme pour ce nœud (par défaut, nom d'hôte)",
"nodeNamePlaceholder": "ex. mcp-node-1",
"coordinatorUrl": "URL du coordinateur",
"coordinatorUrlDescription": "URL du nœud coordinateur auquel s'inscrire",
"coordinatorUrlPlaceholder": "http://coordinator:3000",
"heartbeatInterval": "Intervalle de battement de cœur (ms)",
"heartbeatIntervalDescription": "Intervalle en millisecondes entre les signaux de battement de cœur (par défaut : 5000)",
"heartbeatIntervalPlaceholder": "5000",
"registerOnStartup": "S'inscrire au démarrage",
"registerOnStartupDescription": "S'inscrire automatiquement auprès du coordinateur au démarrage du nœud (par défaut : true)",
"coordinatorConfig": "Configuration du coordinateur",
"nodeTimeout": "Délai d'expiration du nœud (ms)",
"nodeTimeoutDescription": "Temps en millisecondes avant de marquer un nœud comme non sain (par défaut : 15000)",
"nodeTimeoutPlaceholder": "15000",
"cleanupInterval": "Intervalle de nettoyage (ms)",
"cleanupIntervalDescription": "Intervalle de nettoyage des nœuds inactifs en millisecondes (par défaut : 30000)",
"cleanupIntervalPlaceholder": "30000",
"stickySessionTimeout": "Délai d'expiration de la session persistante (ms)",
"stickySessionTimeoutDescription": "Délai d'expiration de la session en millisecondes (par défaut : 3600000 = 1 heure)",
"stickySessionTimeoutPlaceholder": "3600000",
"stickySessionConfig": "Configuration de la session persistante",
"stickySessionEnabled": "Activer les sessions persistantes",
"stickySessionEnabledDescription": "Activer l'affinité de session pour acheminer les requêtes du même client vers le même nœud",
"stickySessionStrategy": "Stratégie de session",
"stickySessionStrategyDescription": "Stratégie pour maintenir l'affinité de session",
"stickySessionStrategyConsistentHash": "Hachage cohérent",
"stickySessionStrategyCookie": "Cookie",
"stickySessionStrategyHeader": "En-tête",
"cookieName": "Nom du cookie",
"cookieNameDescription": "Nom du cookie pour les sessions persistantes basées sur les cookies (par défaut : MCPHUB_NODE)",
"cookieNamePlaceholder": "MCPHUB_NODE",
"headerName": "Nom de l'en-tête",
"headerNameDescription": "Nom de l'en-tête pour les sessions persistantes basées sur les en-têtes (par défaut : X-MCPHub-Node)",
"headerNamePlaceholder": "X-MCPHub-Node",
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres.",
"exportMcpSettings": "Exporter les paramètres",
"mcpSettingsJson": "JSON des paramètres MCP",

View File

@@ -576,6 +576,53 @@
"systemSettings": "系统设置",
"nameSeparatorLabel": "名称分隔符",
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-",
"clusterConfig": "集群配置",
"clusterEnabled": "启用集群模式",
"clusterEnabledDescription": "启用分布式集群部署,实现高可用和可扩展性",
"clusterMode": "集群模式",
"clusterModeDescription": "选择此实例的运行模式",
"clusterModeStandalone": "独立模式",
"clusterModeNode": "节点模式",
"clusterModeCoordinator": "协调器模式",
"nodeConfig": "节点配置",
"nodeId": "节点 ID",
"nodeIdDescription": "节点的唯一标识符(如果未提供则自动生成)",
"nodeIdPlaceholder": "例如: node-1",
"nodeName": "节点名称",
"nodeNameDescription": "节点的可读名称(默认为主机名)",
"nodeNamePlaceholder": "例如: mcp-node-1",
"coordinatorUrl": "协调器地址",
"coordinatorUrlDescription": "要注册的协调器节点的地址",
"coordinatorUrlPlaceholder": "http://coordinator:3000",
"heartbeatInterval": "心跳间隔(毫秒)",
"heartbeatIntervalDescription": "心跳信号的发送间隔单位为毫秒默认5000",
"heartbeatIntervalPlaceholder": "5000",
"registerOnStartup": "启动时注册",
"registerOnStartupDescription": "节点启动时自动向协调器注册默认true",
"coordinatorConfig": "协调器配置",
"nodeTimeout": "节点超时(毫秒)",
"nodeTimeoutDescription": "将节点标记为不健康之前的超时时间单位为毫秒默认15000",
"nodeTimeoutPlaceholder": "15000",
"cleanupInterval": "清理间隔(毫秒)",
"cleanupIntervalDescription": "清理非活动节点的间隔时间单位为毫秒默认30000",
"cleanupIntervalPlaceholder": "30000",
"stickySessionTimeout": "会话超时(毫秒)",
"stickySessionTimeoutDescription": "会话的超时时间单位为毫秒默认3600000 = 1 小时)",
"stickySessionTimeoutPlaceholder": "3600000",
"stickySessionConfig": "会话保持配置",
"stickySessionEnabled": "启用会话保持",
"stickySessionEnabledDescription": "启用会话亲和性,将来自同一客户端的请求路由到同一节点",
"stickySessionStrategy": "会话策略",
"stickySessionStrategyDescription": "维护会话亲和性的策略",
"stickySessionStrategyConsistentHash": "一致性哈希",
"stickySessionStrategyCookie": "Cookie",
"stickySessionStrategyHeader": "Header",
"cookieName": "Cookie 名称",
"cookieNameDescription": "基于 Cookie 的会话保持使用的 Cookie 名称默认MCPHUB_NODE",
"cookieNamePlaceholder": "MCPHUB_NODE",
"headerName": "Header 名称",
"headerNameDescription": "基于 Header 的会话保持使用的 Header 名称默认X-MCPHub-Node",
"headerNamePlaceholder": "X-MCPHub-Node",
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。",
"exportMcpSettings": "导出配置",
"mcpSettingsJson": "MCP 配置 JSON",

View File

@@ -508,7 +508,7 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
export const updateSystemConfig = (req: Request, res: Response): void => {
try {
const { routing, install, smartRouting, mcpRouter, nameSeparator } = req.body;
const { routing, install, smartRouting, mcpRouter, nameSeparator, cluster } = req.body;
const currentUser = (req as any).user;
if (
@@ -533,7 +533,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
typeof mcpRouter.referer !== 'string' &&
typeof mcpRouter.title !== 'string' &&
typeof mcpRouter.baseUrl !== 'string')) &&
typeof nameSeparator !== 'string'
typeof nameSeparator !== 'string' &&
!cluster
) {
res.status(400).json({
success: false,
@@ -610,6 +611,13 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
};
}
if (!settings.systemConfig.cluster) {
settings.systemConfig.cluster = {
enabled: false,
mode: 'standalone',
};
}
if (routing) {
if (typeof routing.enableGlobalRoute === 'boolean') {
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
@@ -719,6 +727,88 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
settings.systemConfig.nameSeparator = nameSeparator;
}
if (cluster) {
if (typeof cluster.enabled === 'boolean') {
settings.systemConfig.cluster.enabled = cluster.enabled;
}
if (
typeof cluster.mode === 'string' &&
['standalone', 'node', 'coordinator'].includes(cluster.mode)
) {
settings.systemConfig.cluster.mode = cluster.mode as 'standalone' | 'node' | 'coordinator';
}
// Node configuration
if (cluster.node) {
if (!settings.systemConfig.cluster.node) {
settings.systemConfig.cluster.node = {
coordinatorUrl: '',
};
}
if (typeof cluster.node.id === 'string') {
settings.systemConfig.cluster.node.id = cluster.node.id;
}
if (typeof cluster.node.name === 'string') {
settings.systemConfig.cluster.node.name = cluster.node.name;
}
if (typeof cluster.node.coordinatorUrl === 'string') {
settings.systemConfig.cluster.node.coordinatorUrl = cluster.node.coordinatorUrl;
}
if (typeof cluster.node.heartbeatInterval === 'number') {
settings.systemConfig.cluster.node.heartbeatInterval = cluster.node.heartbeatInterval;
}
if (typeof cluster.node.registerOnStartup === 'boolean') {
settings.systemConfig.cluster.node.registerOnStartup = cluster.node.registerOnStartup;
}
}
// Coordinator configuration
if (cluster.coordinator) {
if (!settings.systemConfig.cluster.coordinator) {
settings.systemConfig.cluster.coordinator = {};
}
if (typeof cluster.coordinator.nodeTimeout === 'number') {
settings.systemConfig.cluster.coordinator.nodeTimeout = cluster.coordinator.nodeTimeout;
}
if (typeof cluster.coordinator.cleanupInterval === 'number') {
settings.systemConfig.cluster.coordinator.cleanupInterval =
cluster.coordinator.cleanupInterval;
}
if (typeof cluster.coordinator.stickySessionTimeout === 'number') {
settings.systemConfig.cluster.coordinator.stickySessionTimeout =
cluster.coordinator.stickySessionTimeout;
}
}
// Sticky session configuration
if (cluster.stickySession) {
if (!settings.systemConfig.cluster.stickySession) {
settings.systemConfig.cluster.stickySession = {
enabled: true,
strategy: 'consistent-hash',
};
}
if (typeof cluster.stickySession.enabled === 'boolean') {
settings.systemConfig.cluster.stickySession.enabled = cluster.stickySession.enabled;
}
if (
typeof cluster.stickySession.strategy === 'string' &&
['consistent-hash', 'cookie', 'header'].includes(cluster.stickySession.strategy)
) {
settings.systemConfig.cluster.stickySession.strategy = cluster.stickySession.strategy as
| 'consistent-hash'
| 'cookie'
| 'header';
}
if (typeof cluster.stickySession.cookieName === 'string') {
settings.systemConfig.cluster.stickySession.cookieName = cluster.stickySession.cookieName;
}
if (typeof cluster.stickySession.headerName === 'string') {
settings.systemConfig.cluster.stickySession.headerName = cluster.stickySession.headerName;
}
}
}
if (saveSettings(settings, currentUser)) {
res.json({
success: true,