mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 18:59:30 -05:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9aa4a9a08 | ||
|
|
48bcf9f5f0 | ||
|
|
f63f06d879 | ||
|
|
63b356b8d7 | ||
|
|
a6cea2ad3f | ||
|
|
5bb2715094 | ||
|
|
9b40f7e101 | ||
|
|
df872823c1 |
@@ -21,6 +21,9 @@ ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT
|
||||
ARG BASE_PATH=""
|
||||
ENV BASE_PATH=$BASE_PATH
|
||||
|
||||
ARG READONLY=false
|
||||
ENV READONLY=$READONLY
|
||||
|
||||
ENV PNPM_HOME=/usr/local/share/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN mkdir -p $PNPM_HOME && \
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGroupData } from '@/hooks/useGroupData'
|
||||
import { useServerData } from '@/hooks/useServerData'
|
||||
import { GroupFormData, Server } from '@/types'
|
||||
import { ToggleGroup } from './ui/ToggleGroup'
|
||||
import { GroupFormData, Server, IGroupServerConfig } from '@/types'
|
||||
import { ServerToolConfig } from './ServerToolConfig'
|
||||
|
||||
interface AddGroupFormProps {
|
||||
onAdd: () => void
|
||||
@@ -21,7 +21,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
const [formData, setFormData] = useState<GroupFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
servers: []
|
||||
servers: [] as IGroupServerConfig[]
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,9 +50,8 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
}
|
||||
|
||||
const result = await createGroup(formData.name, formData.description, formData.servers)
|
||||
|
||||
if (!result) {
|
||||
setError(t('groups.createError'))
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.createError'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
@@ -66,64 +65,68 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
|
||||
<div className="p-6 flex-shrink-0">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md border border-gray-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
{t('groups.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToggleGroup
|
||||
className="mb-6"
|
||||
label={t('groups.servers')}
|
||||
noOptionsText={t('groups.noServerOptions')}
|
||||
values={formData.servers}
|
||||
options={availableServers.map(server => ({
|
||||
value: server.name,
|
||||
label: server.name
|
||||
}))}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
{t('groups.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
{t('groups.configureTools')}
|
||||
</label>
|
||||
<ServerToolConfig
|
||||
servers={availableServers}
|
||||
value={formData.servers as IGroupServerConfig[]}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 p-6 pt-4 border-t border-gray-200 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ServerForm from './ServerForm'
|
||||
import { getApiUrl } from '../utils/runtime'
|
||||
import { apiPost } from '../utils/fetchInterceptor'
|
||||
import { detectVariables } from '../utils/variableDetection'
|
||||
|
||||
interface AddServerFormProps {
|
||||
@@ -34,26 +34,12 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
||||
const submitServer = async (payload: any) => {
|
||||
try {
|
||||
setError(null)
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const result = await apiPost('/servers', payload)
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
if (!result.success) {
|
||||
// Use specific error message from the response if available
|
||||
if (result && result.message) {
|
||||
setError(result.message)
|
||||
} else if (response.status === 400) {
|
||||
setError(t('server.invalidData'))
|
||||
} else if (response.status === 409) {
|
||||
setError(t('server.alreadyExists', { serverName: payload.name }))
|
||||
} else {
|
||||
setError(t('server.addError'))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiPost, apiGet, apiPut, fetchWithInterceptors } from '@/utils/fetchInterceptor';
|
||||
import { getApiUrl } from '@/utils/runtime';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
|
||||
@@ -81,12 +82,8 @@ const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) =>
|
||||
const formData = new FormData();
|
||||
formData.append('dxtFile', selectedFile);
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/dxt/upload'), {
|
||||
const response = await fetchWithInterceptors(getApiUrl('/dxt/upload'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@@ -119,19 +116,11 @@ const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) =>
|
||||
// Convert DXT manifest to MCPHub stdio server configuration
|
||||
const serverConfig = convertDxtToMcpConfig(manifestData, extractDir, serverName);
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
|
||||
// First, check if server exists
|
||||
if (!forceOverride) {
|
||||
const checkResponse = await fetch(getApiUrl('/servers'), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const checkResult = await apiGet('/servers');
|
||||
|
||||
if (checkResponse.ok) {
|
||||
const checkResult = await checkResponse.json();
|
||||
if (checkResult.success) {
|
||||
const existingServer = checkResult.data?.find((server: any) => server.name === serverName);
|
||||
|
||||
if (existingServer) {
|
||||
@@ -145,25 +134,17 @@ const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) =>
|
||||
}
|
||||
|
||||
// Install or override the server
|
||||
const method = forceOverride ? 'PUT' : 'POST';
|
||||
const url = forceOverride ? getApiUrl(`/servers/${encodeURIComponent(serverName)}`) : getApiUrl('/servers');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
let result;
|
||||
if (forceOverride) {
|
||||
result = await apiPut(`/servers/${encodeURIComponent(serverName)}`, {
|
||||
name: serverName,
|
||||
config: serverConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
|
||||
});
|
||||
} else {
|
||||
result = await apiPost('/servers', {
|
||||
name: serverName,
|
||||
config: serverConfig,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Group, GroupFormData, Server } from '@/types'
|
||||
import { Group, GroupFormData, Server, IGroupServerConfig } from '@/types'
|
||||
import { useGroupData } from '@/hooks/useGroupData'
|
||||
import { useServerData } from '@/hooks/useServerData'
|
||||
import { ToggleGroup } from './ui/ToggleGroup'
|
||||
import { ServerToolConfig } from './ServerToolConfig'
|
||||
|
||||
interface EditGroupFormProps {
|
||||
group: Group
|
||||
@@ -56,8 +56,8 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
servers: formData.servers
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
setError(t('groups.updateError'))
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.updateError'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
@@ -71,64 +71,68 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
|
||||
<div className="p-6 flex-shrink-0">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md border border-gray-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
{t('groups.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToggleGroup
|
||||
className="mb-6"
|
||||
label={t('groups.servers')}
|
||||
noOptionsText={t('groups.noServerOptions')}
|
||||
values={formData.servers}
|
||||
options={availableServers.map(server => ({
|
||||
value: server.name,
|
||||
label: server.name
|
||||
}))}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
{t('groups.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
{t('groups.configureTools')}
|
||||
</label>
|
||||
<ServerToolConfig
|
||||
servers={availableServers}
|
||||
value={formData.servers as IGroupServerConfig[]}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 p-6 pt-4 border-t border-gray-200 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { getApiUrl } from '../utils/runtime'
|
||||
import { apiPut } from '../utils/fetchInterceptor'
|
||||
import ServerForm from './ServerForm'
|
||||
|
||||
interface EditServerFormProps {
|
||||
@@ -17,26 +17,12 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
const handleSubmit = async (payload: any) => {
|
||||
try {
|
||||
setError(null)
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/servers/${server.name}`), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const result = await apiPut(`/servers/${server.name}`, payload)
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
if (!result.success) {
|
||||
// Use specific error message from the response if available
|
||||
if (result && result.message) {
|
||||
setError(result.message)
|
||||
} else if (response.status === 404) {
|
||||
setError(t('server.notFound', { serverName: server.name }))
|
||||
} else if (response.status === 400) {
|
||||
setError(t('server.invalidData'))
|
||||
} else {
|
||||
setError(t('server.updateError', { serverName: server.name }))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Group, Server } from '@/types'
|
||||
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon } from '@/components/icons/LucideIcons'
|
||||
import { Group, Server, IGroupServerConfig } from '@/types'
|
||||
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench } from '@/components/icons/LucideIcons'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
@@ -25,6 +25,7 @@ const GroupCard = ({
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showCopyDropdown, setShowCopyDropdown] = useState(false)
|
||||
const [expandedServer, setExpandedServer] = useState<string | null>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
@@ -108,8 +109,25 @@ const GroupCard = ({
|
||||
copyToClipboard(JSON.stringify(jsonConfig, null, 2))
|
||||
}
|
||||
|
||||
// Helper function to normalize group servers to get server names
|
||||
const getServerNames = (servers: string[] | IGroupServerConfig[]): string[] => {
|
||||
return servers.map(server => typeof server === 'string' ? server : server.name);
|
||||
};
|
||||
|
||||
// Helper function to get server configuration
|
||||
const getServerConfig = (serverName: string): IGroupServerConfig | undefined => {
|
||||
const server = group.servers.find(s =>
|
||||
typeof s === 'string' ? s === serverName : s.name === serverName
|
||||
);
|
||||
if (typeof server === 'string') {
|
||||
return { name: server, tools: 'all' };
|
||||
}
|
||||
return server;
|
||||
};
|
||||
|
||||
// Get servers that belong to this group
|
||||
const groupServers = servers.filter(server => group.servers.includes(server.name))
|
||||
const serverNames = getServerNames(group.servers);
|
||||
const groupServers = servers.filter(server => serverNames.includes(server.name));
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6 ">
|
||||
@@ -186,18 +204,68 @@ const GroupCard = ({
|
||||
{groupServers.length === 0 ? (
|
||||
<p className="text-gray-500 italic">{t('groups.noServers')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{groupServers.map(server => (
|
||||
<div
|
||||
key={server.name}
|
||||
className="inline-flex items-center px-3 py-1 bg-gray-50 rounded"
|
||||
>
|
||||
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
|
||||
<span className={`ml-2 inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
|
||||
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groupServers.map(server => {
|
||||
const serverConfig = getServerConfig(server.name);
|
||||
const hasToolRestrictions = serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools);
|
||||
const toolCount = hasToolRestrictions && Array.isArray(serverConfig?.tools)
|
||||
? serverConfig.tools.length
|
||||
: (server.tools?.length || 0); // Show total tool count when all tools are selected
|
||||
|
||||
const isExpanded = expandedServer === server.name;
|
||||
|
||||
// Get tools list for display
|
||||
const getToolsList = () => {
|
||||
if (hasToolRestrictions && Array.isArray(serverConfig?.tools)) {
|
||||
return serverConfig.tools;
|
||||
} else if (server.tools && server.tools.length > 0) {
|
||||
return server.tools.map(tool => tool.name);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const handleServerClick = () => {
|
||||
setExpandedServer(isExpanded ? null : server.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={server.name} className="relative">
|
||||
<div
|
||||
className="flex items-center space-x-2 bg-gray-50 rounded-lg px-3 py-2 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={handleServerClick}
|
||||
>
|
||||
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
|
||||
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
{toolCount > 0 && (
|
||||
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-0.5 rounded flex items-center gap-1">
|
||||
<Wrench size={12} />
|
||||
{toolCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white shadow-lg rounded-md border border-gray-200 p-3 z-10 min-w-[300px] max-w-[400px]">
|
||||
<div className="text-gray-600 text-xs mb-2">
|
||||
{hasToolRestrictions ? t('groups.selectedTools') : t('groups.allTools')}:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{getToolsList().map((toolName, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs"
|
||||
>
|
||||
{toolName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
317
frontend/src/components/ServerToolConfig.tsx
Normal file
317
frontend/src/components/ServerToolConfig.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IGroupServerConfig, Server, Tool } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface ServerToolConfigProps {
|
||||
servers: Server[];
|
||||
value: string[] | IGroupServerConfig[];
|
||||
onChange: (value: IGroupServerConfig[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
|
||||
servers,
|
||||
value,
|
||||
onChange,
|
||||
className
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||
|
||||
// Normalize current value to IGroupServerConfig[] format
|
||||
const normalizedValue: IGroupServerConfig[] = React.useMemo(() => {
|
||||
return value.map(item => {
|
||||
if (typeof item === 'string') {
|
||||
return { name: item, tools: 'all' as const };
|
||||
}
|
||||
return { ...item, tools: item.tools || 'all' as const };
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
// Get available servers (enabled only)
|
||||
const availableServers = React.useMemo(() =>
|
||||
servers.filter(server => server.enabled !== false),
|
||||
[servers]
|
||||
);
|
||||
|
||||
// Clean up expanded servers when servers are removed from configuration
|
||||
// But keep servers that were explicitly expanded even if they have no configuration
|
||||
React.useEffect(() => {
|
||||
const configuredServerNames = new Set(normalizedValue.map(config => config.name));
|
||||
const availableServerNames = new Set(availableServers.map(server => server.name));
|
||||
|
||||
setExpandedServers(prev => {
|
||||
const newSet = new Set<string>();
|
||||
prev.forEach(serverName => {
|
||||
// Keep expanded if server is configured OR if server exists and user manually expanded it
|
||||
if (configuredServerNames.has(serverName) || availableServerNames.has(serverName)) {
|
||||
newSet.add(serverName);
|
||||
}
|
||||
});
|
||||
return newSet;
|
||||
});
|
||||
}, [normalizedValue, availableServers]);
|
||||
|
||||
const toggleServer = (serverName: string) => {
|
||||
const existingIndex = normalizedValue.findIndex(config => config.name === serverName);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Remove server - this will also remove all its tools
|
||||
const newValue = normalizedValue.filter(config => config.name !== serverName);
|
||||
onChange(newValue);
|
||||
// Don't auto-collapse the server when it's unchecked - let user control expansion manually
|
||||
} else {
|
||||
// Add server with all tools by default
|
||||
const newValue = [...normalizedValue, { name: serverName, tools: 'all' as const }];
|
||||
onChange(newValue);
|
||||
// Don't auto-expand the server when it's checked - let user control expansion manually
|
||||
}
|
||||
};
|
||||
|
||||
const toggleServerExpanded = (serverName: string) => {
|
||||
setExpandedServers(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(serverName)) {
|
||||
newSet.delete(serverName);
|
||||
} else {
|
||||
newSet.add(serverName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const updateServerTools = (serverName: string, tools: string[] | 'all', keepExpanded = false) => {
|
||||
if (Array.isArray(tools) && tools.length === 0) {
|
||||
// If no tools are selected, remove the server entirely
|
||||
const newValue = normalizedValue.filter(config => config.name !== serverName);
|
||||
onChange(newValue);
|
||||
// Only collapse the server if not explicitly asked to keep it expanded
|
||||
if (!keepExpanded) {
|
||||
setExpandedServers(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(serverName);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Update server tools or add server if it doesn't exist
|
||||
const existingServerIndex = normalizedValue.findIndex(config => config.name === serverName);
|
||||
|
||||
if (existingServerIndex >= 0) {
|
||||
// Update existing server
|
||||
const newValue = normalizedValue.map(config =>
|
||||
config.name === serverName ? { ...config, tools } : config
|
||||
);
|
||||
onChange(newValue);
|
||||
} else {
|
||||
// Add new server with specified tools
|
||||
const newValue = [...normalizedValue, { name: serverName, tools }];
|
||||
onChange(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTool = (serverName: string, toolName: string) => {
|
||||
const server = availableServers.find(s => s.name === serverName);
|
||||
if (!server) return;
|
||||
|
||||
const allToolNames = server.tools?.map(tool => tool.name.replace(`${serverName}-`, '')) || [];
|
||||
const serverConfig = normalizedValue.find(config => config.name === serverName);
|
||||
|
||||
if (!serverConfig) {
|
||||
// Server not selected yet, add it with only this tool
|
||||
const newValue = [...normalizedValue, { name: serverName, tools: [toolName] }];
|
||||
onChange(newValue);
|
||||
// Don't auto-expand - let user control expansion manually
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverConfig.tools === 'all') {
|
||||
// Switch from 'all' to specific tools, excluding the toggled tool
|
||||
const newTools = allToolNames.filter(name => name !== toolName);
|
||||
updateServerTools(serverName, newTools);
|
||||
// If all tools are deselected, the server will be removed and collapsed in updateServerTools
|
||||
} else if (Array.isArray(serverConfig.tools)) {
|
||||
const currentTools = serverConfig.tools;
|
||||
if (currentTools.includes(toolName)) {
|
||||
// Remove tool
|
||||
const newTools = currentTools.filter(name => name !== toolName);
|
||||
updateServerTools(serverName, newTools);
|
||||
// If all tools are deselected, the server will be removed and collapsed in updateServerTools
|
||||
} else {
|
||||
// Add tool
|
||||
const newTools = [...currentTools, toolName];
|
||||
|
||||
// If all tools are selected, switch to 'all'
|
||||
if (newTools.length === allToolNames.length) {
|
||||
updateServerTools(serverName, 'all');
|
||||
} else {
|
||||
updateServerTools(serverName, newTools);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isServerSelected = (serverName: string) => {
|
||||
const serverConfig = normalizedValue.find(config => config.name === serverName);
|
||||
if (!serverConfig) return false;
|
||||
|
||||
// Server is considered "fully selected" if tools is 'all'
|
||||
return serverConfig.tools === 'all';
|
||||
};
|
||||
|
||||
const isServerPartiallySelected = (serverName: string) => {
|
||||
const serverConfig = normalizedValue.find(config => config.name === serverName);
|
||||
if (!serverConfig) return false;
|
||||
|
||||
// Server is partially selected if it has specific tools selected (not 'all' and not empty)
|
||||
return Array.isArray(serverConfig.tools) && serverConfig.tools.length > 0;
|
||||
};
|
||||
|
||||
const isToolSelected = (serverName: string, toolName: string) => {
|
||||
const serverConfig = normalizedValue.find(config => config.name === serverName);
|
||||
if (!serverConfig) return false;
|
||||
|
||||
if (serverConfig.tools === 'all') return true;
|
||||
if (Array.isArray(serverConfig.tools)) {
|
||||
return serverConfig.tools.includes(toolName);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getServerTools = (serverName: string): Tool[] => {
|
||||
const server = availableServers.find(s => s.name === serverName);
|
||||
return server?.tools || [];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="space-y-3">
|
||||
{availableServers.map(server => {
|
||||
const isSelected = isServerSelected(server.name);
|
||||
const isPartiallySelected = isServerPartiallySelected(server.name);
|
||||
const isExpanded = expandedServers.has(server.name);
|
||||
const serverTools = getServerTools(server.name);
|
||||
const serverConfig = normalizedValue.find(config => config.name === server.name);
|
||||
|
||||
return (
|
||||
<div key={server.name} className="border border-gray-200 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer rounded-lg transition-colors"
|
||||
onClick={() => toggleServerExpanded(server.name)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center space-x-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleServer(server.name);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected || isPartiallySelected}
|
||||
onChange={() => toggleServer(server.name)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="font-medium text-gray-900 cursor-pointer select-none">
|
||||
{server.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools) && (
|
||||
<span className="text-sm text-green-600">
|
||||
({t('groups.toolsSelected')} {serverConfig.tools.length}/{serverTools.length})
|
||||
</span>
|
||||
)}
|
||||
{serverConfig && serverConfig.tools === 'all' && (
|
||||
<span className="text-sm text-green-600">
|
||||
({t('groups.allTools')} {serverTools.length}/{serverTools.length})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{serverTools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={cn("w-5 h-5 transition-transform", isExpanded && "rotate-180")}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && serverTools.length > 0 && (
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{t('groups.toolSelection')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const isAllSelected = serverConfig?.tools === 'all';
|
||||
if (isAllSelected || (Array.isArray(serverConfig?.tools) && serverConfig.tools.length === serverTools.length)) {
|
||||
// If all tools are selected, deselect all (remove server) but keep expanded
|
||||
updateServerTools(server.name, [], true);
|
||||
} else {
|
||||
// Select all tools (add server if not present)
|
||||
updateServerTools(server.name, 'all');
|
||||
// Don't auto-expand - let user control expansion manually
|
||||
}
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
{(serverConfig?.tools === 'all' ||
|
||||
(Array.isArray(serverConfig?.tools) && serverConfig.tools.length === serverTools.length))
|
||||
? t('groups.selectNone')
|
||||
: t('groups.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 max-h-32 overflow-y-auto">
|
||||
{serverTools.map(tool => {
|
||||
const toolName = tool.name.replace(`${server.name}-`, '');
|
||||
const isToolChecked = isToolSelected(server.name, toolName);
|
||||
|
||||
return (
|
||||
<label key={tool.name} className="flex items-center space-x-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isToolChecked}
|
||||
onChange={() => toggleTool(server.name, toolName)}
|
||||
className="w-3 h-3 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-700">
|
||||
{toolName}
|
||||
</span>
|
||||
{tool.description && (
|
||||
<span className="text-gray-400 text-xs truncate">
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{availableServers.length === 0 && (
|
||||
<p className="text-gray-500 text-sm">{t('groups.noServerOptions')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
frontend/src/components/icons/LanguageIcon.tsx
Normal file
27
frontend/src/components/icons/LanguageIcon.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const LanguageIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
{...props}
|
||||
>
|
||||
<title>{t('common.language')}</title>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2 12h20" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageIcon;
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
AlertCircle,
|
||||
Link,
|
||||
FileCode,
|
||||
ChevronDown as DropdownIcon
|
||||
ChevronDown as DropdownIcon,
|
||||
Wrench
|
||||
} from 'lucide-react'
|
||||
|
||||
export {
|
||||
@@ -37,7 +38,8 @@ export {
|
||||
AlertCircle,
|
||||
Link,
|
||||
FileCode,
|
||||
DropdownIcon
|
||||
DropdownIcon,
|
||||
Wrench
|
||||
}
|
||||
|
||||
const LucideIcons = {
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import LanguageSwitch from '@/components/ui/LanguageSwitch';
|
||||
import GitHubIcon from '@/components/icons/GitHubIcon';
|
||||
import SponsorIcon from '@/components/icons/SponsorIcon';
|
||||
import WeChatIcon from '@/components/icons/WeChatIcon';
|
||||
import DiscordIcon from '@/components/icons/DiscordIcon';
|
||||
import SponsorDialog from '@/components/ui/SponsorDialog';
|
||||
import WeChatDialog from '@/components/ui/WeChatDialog';
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
|
||||
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
|
||||
@@ -36,53 +30,27 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
<h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Theme Switch and Version */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{/* Theme Switch and Language Switcher and Version */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 mr-2">
|
||||
{import.meta.env.PACKAGE_VERSION === 'dev'
|
||||
? import.meta.env.PACKAGE_VERSION
|
||||
: `v${import.meta.env.PACKAGE_VERSION}`}
|
||||
</span>
|
||||
|
||||
<a
|
||||
href="https://github.com/samanhappy/mcphub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="GitHub Repository"
|
||||
>
|
||||
<GitHubIcon className="h-5 w-5" />
|
||||
</a>
|
||||
{i18n.language === 'zh' ? (
|
||||
<button
|
||||
onClick={() => setWechatDialogOpen(true)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
|
||||
aria-label={t('wechat.label')}
|
||||
>
|
||||
<WeChatIcon className="h-5 w-5" />
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="https://discord.gg/qMKNsn5Q"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
aria-label={t('discord.label')}
|
||||
>
|
||||
<DiscordIcon className="h-5 w-5" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSponsorDialogOpen(true)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
|
||||
aria-label={t('sponsor.label')}
|
||||
>
|
||||
<SponsorIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<ThemeSwitch />
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
</div>
|
||||
<SponsorDialog open={sponsorDialogOpen} onOpenChange={setSponsorDialogOpen} />
|
||||
<WeChatDialog open={wechatDialogOpen} onOpenChange={setWechatDialogOpen} />
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
83
frontend/src/components/ui/LanguageSwitch.tsx
Normal file
83
frontend/src/components/ui/LanguageSwitch.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LanguageIcon from '@/components/icons/LanguageIcon';
|
||||
|
||||
const LanguageSwitch: React.FC = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false);
|
||||
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
|
||||
|
||||
// Available languages
|
||||
const availableLanguages = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'zh', label: '中文' }
|
||||
];
|
||||
|
||||
// Update current language when it changes
|
||||
useEffect(() => {
|
||||
setCurrentLanguage(i18n.language);
|
||||
}, [i18n.language]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.language-dropdown')) {
|
||||
setLanguageDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (languageDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [languageDropdownOpen]);
|
||||
|
||||
const handleLanguageChange = (lang: string) => {
|
||||
localStorage.setItem('i18nextLng', lang);
|
||||
setLanguageDropdownOpen(false);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// Always show dropdown for language selection
|
||||
const handleLanguageToggle = () => {
|
||||
setLanguageDropdownOpen(!languageDropdownOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative language-dropdown">
|
||||
<button
|
||||
onClick={handleLanguageToggle}
|
||||
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
aria-label="Language Switcher"
|
||||
>
|
||||
<LanguageIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Show dropdown when opened */}
|
||||
{languageDropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-24 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||
<div>
|
||||
{availableLanguages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
className={`flex items-center w-full px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${currentLanguage.startsWith(lang.code)
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitch;
|
||||
@@ -7,44 +7,19 @@ const ThemeSwitch: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'light'
|
||||
? 'bg-white text-yellow-600 shadow'
|
||||
: 'text-black dark:text-gray-300 hover:text-yellow-600 dark:hover:text-yellow-500'
|
||||
}`}
|
||||
title={t('theme.light')}
|
||||
aria-label={t('theme.light')}
|
||||
>
|
||||
<Sun size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'dark'
|
||||
? 'bg-gray-800 text-blue-400 shadow'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400'
|
||||
}`}
|
||||
title={t('theme.dark')}
|
||||
aria-label={t('theme.dark')}
|
||||
>
|
||||
<Moon size={18} />
|
||||
</button>
|
||||
{/* <button
|
||||
onClick={() => setTheme('system')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'system'
|
||||
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow'
|
||||
: 'text-black dark:text-gray-300 hover:text-green-600 dark:hover:text-green-400'
|
||||
}`}
|
||||
title={t('theme.system')}
|
||||
aria-label={t('theme.system')}
|
||||
>
|
||||
<Monitor size={18} />
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
title={theme === 'light' ? t('theme.dark') : t('theme.light')}
|
||||
aria-label={theme === 'light' ? t('theme.dark') : t('theme.light')}
|
||||
>
|
||||
{theme === 'light' ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { User, Settings, LogOut, Info } from 'lucide-react';
|
||||
import AboutDialog from './AboutDialog';
|
||||
import SponsorDialog from './SponsorDialog';
|
||||
import WeChatDialog from './WeChatDialog';
|
||||
import WeChatIcon from '@/components/icons/WeChatIcon';
|
||||
import DiscordIcon from '@/components/icons/DiscordIcon';
|
||||
import SponsorIcon from '@/components/icons/SponsorIcon';
|
||||
import { checkLatestVersion, compareVersions } from '@/utils/version';
|
||||
|
||||
interface UserProfileMenuProps {
|
||||
@@ -12,12 +17,14 @@ interface UserProfileMenuProps {
|
||||
}
|
||||
|
||||
const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version }) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showNewVersionInfo, setShowNewVersionInfo] = useState(false);
|
||||
const [showAboutDialog, setShowAboutDialog] = useState(false);
|
||||
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
|
||||
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Check for new version on login and component mount
|
||||
@@ -65,6 +72,16 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSponsorClick = () => {
|
||||
setSponsorDialogOpen(true);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleWeChatClick = () => {
|
||||
setWechatDialogOpen(true);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
@@ -90,7 +107,35 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-0 transform -translate-y-full left-0 w-full min-w-max bg-white border border-gray-200 dark:bg-gray-800 py-1 z-50">
|
||||
<div className="absolute top-0 transform -translate-y-full left-0 w-full min-w-max bg-white border border-gray-200 dark:bg-gray-800 z-50">
|
||||
<button
|
||||
onClick={handleSponsorClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<SponsorIcon className="h-4 w-4 mr-2" />
|
||||
{t('sponsor.label')}
|
||||
</button>
|
||||
|
||||
{i18n.language === 'zh' ? (
|
||||
<button
|
||||
onClick={handleWeChatClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<WeChatIcon className="h-4 w-4 mr-2" />
|
||||
{t('wechat.label')}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="https://discord.gg/qMKNsn5Q"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<DiscordIcon className="h-4 w-4 mr-2" />
|
||||
{t('discord.label')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSettingsClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@@ -108,6 +153,9 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
<span className="absolute top-2 right-4 block w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-600"></div>
|
||||
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@@ -124,6 +172,12 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
onClose={() => setShowAboutDialog(false)}
|
||||
version={version}
|
||||
/>
|
||||
|
||||
{/* Sponsor dialog */}
|
||||
<SponsorDialog open={sponsorDialogOpen} onOpenChange={setSponsorDialogOpen} />
|
||||
|
||||
{/* WeChat dialog */}
|
||||
<WeChatDialog open={wechatDialogOpen} onOpenChange={setWechatDialogOpen} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { AuthState } from '../types';
|
||||
import * as authService from '../services/authService';
|
||||
import { shouldSkipAuth } from '../services/configService';
|
||||
import { getPublicConfig } from '../services/configService';
|
||||
|
||||
// Initial auth state
|
||||
const initialState: AuthState = {
|
||||
@@ -32,7 +32,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
// First check if authentication should be skipped
|
||||
const skipAuth = await shouldSkipAuth();
|
||||
const { skipAuth, permissions } = await getPublicConfig();
|
||||
|
||||
if (skipAuth) {
|
||||
// If authentication is disabled, set user as authenticated with a dummy user
|
||||
@@ -42,6 +42,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
user: {
|
||||
username: 'guest',
|
||||
isAdmin: true,
|
||||
permissions,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, ApiResponse } from '@/types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { Group, ApiResponse, IGroupServerConfig } from '@/types';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../utils/fetchInterceptor';
|
||||
|
||||
export const useGroupData = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,18 +13,7 @@ export const useGroupData = () => {
|
||||
const fetchGroups = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/groups'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<Group[]> = await response.json();
|
||||
const data: ApiResponse<Group[]> = await apiGet('/groups');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setGroups(data.data);
|
||||
@@ -49,27 +38,22 @@ export const useGroupData = () => {
|
||||
}, []);
|
||||
|
||||
// Create a new group with server associations
|
||||
const createGroup = async (name: string, description?: string, servers: string[] = []) => {
|
||||
const createGroup = async (
|
||||
name: string,
|
||||
description?: string,
|
||||
servers: string[] | IGroupServerConfig[] = [],
|
||||
) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/groups'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({ name, description, servers }),
|
||||
});
|
||||
const result: ApiResponse<Group> = await apiPost('/groups', { name, description, servers });
|
||||
console.log('Group created successfully:', result);
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.createError'));
|
||||
return null;
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.createError'));
|
||||
return result;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return result.data || null;
|
||||
return result || null;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create group');
|
||||
return null;
|
||||
@@ -79,28 +63,17 @@ export const useGroupData = () => {
|
||||
// Update an existing group with server associations
|
||||
const updateGroup = async (
|
||||
id: string,
|
||||
data: { name?: string; description?: string; servers?: string[] },
|
||||
data: { name?: string; description?: string; servers?: string[] | IGroupServerConfig[] },
|
||||
) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${id}`), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.updateError'));
|
||||
return null;
|
||||
const result: ApiResponse<Group> = await apiPut(`/groups/${id}`, data);
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.updateError'));
|
||||
return result;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return result.data || null;
|
||||
return result || null;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update group');
|
||||
return null;
|
||||
@@ -108,22 +81,14 @@ export const useGroupData = () => {
|
||||
};
|
||||
|
||||
// Update servers in a group (for batch updates)
|
||||
const updateGroupServers = async (groupId: string, servers: string[]) => {
|
||||
const updateGroupServers = async (groupId: string, servers: string[] | IGroupServerConfig[]) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/batch`), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({ servers }),
|
||||
const result: ApiResponse<Group> = await apiPut(`/groups/${groupId}/servers/batch`, {
|
||||
servers,
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.updateError'));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.updateError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -138,46 +103,29 @@ export const useGroupData = () => {
|
||||
// Delete a group
|
||||
const deleteGroup = async (id: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${id}`), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.deleteError'));
|
||||
return false;
|
||||
const result = await apiDelete(`/groups/${id}`);
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.deleteError'));
|
||||
return result;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return true;
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete group');
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Add server to a group
|
||||
const addServerToGroup = async (groupId: string, serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${groupId}/servers`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({ serverName }),
|
||||
const result: ApiResponse<Group> = await apiPost(`/groups/${groupId}/servers`, {
|
||||
serverName,
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.serverAddError'));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.serverAddError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -192,18 +140,12 @@ export const useGroupData = () => {
|
||||
// Remove server from group
|
||||
const removeServerFromGroup = async (groupId: string, serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/${serverName}`), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const result: ApiResponse<Group> = await apiDelete(
|
||||
`/groups/${groupId}/servers/${serverName}`,
|
||||
);
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.serverRemoveError'));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.serverRemoveError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarketServer, ApiResponse, ServerConfig } from '@/types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiGet, apiPost } from '../utils/fetchInterceptor';
|
||||
|
||||
export const useMarketData = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,18 +26,7 @@ export const useMarketData = () => {
|
||||
const fetchMarketServers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/market/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
const data: ApiResponse<MarketServer[]> = await apiGet('/market/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
@@ -87,18 +76,7 @@ export const useMarketData = () => {
|
||||
// Fetch all categories
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/market/categories'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<string[]> = await response.json();
|
||||
const data: ApiResponse<string[]> = await apiGet('/market/categories');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setCategories(data.data);
|
||||
@@ -113,18 +91,7 @@ export const useMarketData = () => {
|
||||
// Fetch all tags
|
||||
const fetchTags = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/market/tags'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<string[]> = await response.json();
|
||||
const data: ApiResponse<string[]> = await apiGet('/market/tags');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setTags(data.data);
|
||||
@@ -141,18 +108,7 @@ export const useMarketData = () => {
|
||||
async (name: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/market/servers/${name}`), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer> = await response.json();
|
||||
const data: ApiResponse<MarketServer> = await apiGet(`/market/servers/${name}`);
|
||||
|
||||
if (data && data.success && data.data) {
|
||||
setCurrentServer(data.data);
|
||||
@@ -186,22 +142,10 @@ export const useMarketData = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(
|
||||
getApiUrl(`/market/servers/search?query=${encodeURIComponent(query)}`),
|
||||
{
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
},
|
||||
const data: ApiResponse<MarketServer[]> = await apiGet(
|
||||
`/market/servers/search?query=${encodeURIComponent(query)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
@@ -233,22 +177,10 @@ export const useMarketData = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(
|
||||
getApiUrl(`/market/categories/${encodeURIComponent(category)}`),
|
||||
{
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
},
|
||||
const data: ApiResponse<MarketServer[]> = await apiGet(
|
||||
`/market/categories/${encodeURIComponent(category)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
@@ -280,18 +212,9 @@ export const useMarketData = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/market/tags/${encodeURIComponent(tag)}`), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
const data: ApiResponse<MarketServer[]> = await apiGet(
|
||||
`/market/tags/${encodeURIComponent(tag)}`,
|
||||
);
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
@@ -314,18 +237,7 @@ export const useMarketData = () => {
|
||||
// Fetch installed servers
|
||||
const fetchInstalledServers = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await apiGet<{ success: boolean; data: any[] }>('/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
// Extract server names
|
||||
@@ -365,27 +277,24 @@ export const useMarketData = () => {
|
||||
// Prepare server configuration, merging with customConfig
|
||||
const serverConfig = {
|
||||
name: server.name,
|
||||
config: customConfig.type === 'stdio' ? {
|
||||
command: customConfig.command || installation.command || '',
|
||||
args: customConfig.args || installation.args || [],
|
||||
env: { ...installation.env, ...customConfig.env },
|
||||
} : customConfig
|
||||
config:
|
||||
customConfig.type === 'stdio'
|
||||
? {
|
||||
command: customConfig.command || installation.command || '',
|
||||
args: customConfig.args || installation.args || [],
|
||||
env: { ...installation.env, ...customConfig.env },
|
||||
}
|
||||
: customConfig,
|
||||
};
|
||||
|
||||
// Call the createServer API
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify(serverConfig),
|
||||
});
|
||||
const result = await apiPost<{ success: boolean; message?: string }>(
|
||||
'/servers',
|
||||
serverConfig,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Status: ${response.status}`);
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Failed to install server');
|
||||
}
|
||||
|
||||
// Update installed servers list after successful installation
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
|
||||
|
||||
// Configuration options
|
||||
const CONFIG = {
|
||||
@@ -44,13 +44,7 @@ export const useServerData = () => {
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
@@ -97,13 +91,7 @@ export const useServerData = () => {
|
||||
// Initialization phase request function
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
@@ -203,14 +191,8 @@ export const useServerData = () => {
|
||||
const handleServerEdit = async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/settings'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> = await response.json();
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
@@ -240,17 +222,10 @@ export const useServerData = () => {
|
||||
|
||||
const handleServerRemove = async (serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/servers/${serverName}`), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('server.deleteError', { serverName }));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -264,21 +239,11 @@ export const useServerData = () => {
|
||||
|
||||
const handleServerToggle = async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/servers/${server.name}/toggle`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(t('server.toggleError', { serverName: server.name }));
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ApiResponse } from '@/types';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiGet, apiPut } from '../utils/fetchInterceptor';
|
||||
|
||||
// Define types for the settings data
|
||||
interface RoutingConfig {
|
||||
@@ -84,18 +84,7 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/settings'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<SystemSettings> = await response.json();
|
||||
const data: ApiResponse<SystemSettings> = await apiGet('/settings');
|
||||
|
||||
if (data.success && data.data?.systemConfig?.routing) {
|
||||
setRoutingConfig({
|
||||
@@ -134,34 +123,17 @@ export const useSettingsData = () => {
|
||||
}, [t]); // 移除 showToast 依赖
|
||||
|
||||
// Update routing configuration
|
||||
const updateRoutingConfig = async <T extends keyof RoutingConfig>(
|
||||
key: T,
|
||||
value: RoutingConfig[T],
|
||||
) => {
|
||||
const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
const data = await apiPut('/system-config', {
|
||||
routing: {
|
||||
[key]: value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
routing: {
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setRoutingConfig({
|
||||
...routingConfig,
|
||||
@@ -170,7 +142,7 @@ export const useSettingsData = () => {
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -189,26 +161,12 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
const data = await apiPut('/system-config', {
|
||||
install: {
|
||||
[key]: value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
install: {
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
@@ -217,7 +175,7 @@ export const useSettingsData = () => {
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateSystemConfig'));
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -239,27 +197,12 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
const data = await apiPut('/system-config', {
|
||||
smartRouting: {
|
||||
[key]: value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
smartRouting: {
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setSmartRoutingConfig({
|
||||
...smartRoutingConfig,
|
||||
@@ -289,25 +232,10 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
smartRouting: updates,
|
||||
}),
|
||||
const data = await apiPut('/system-config', {
|
||||
smartRouting: updates,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setSmartRoutingConfig({
|
||||
...smartRoutingConfig,
|
||||
@@ -337,24 +265,10 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
routing: updates,
|
||||
}),
|
||||
const data = await apiPut('/system-config', {
|
||||
routing: updates,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setRoutingConfig({
|
||||
...routingConfig,
|
||||
@@ -363,7 +277,7 @@ export const useSettingsData = () => {
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,9 +2,9 @@ import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
// Import translations
|
||||
import enTranslation from './locales/en.json';
|
||||
import zhTranslation from './locales/zh.json';
|
||||
// Import shared translations from root locales directory
|
||||
import enTranslation from '../../locales/en.json';
|
||||
import zhTranslation from '../../locales/zh.json';
|
||||
|
||||
i18n
|
||||
// Detect user language
|
||||
@@ -15,18 +15,18 @@ i18n
|
||||
.init({
|
||||
resources: {
|
||||
en: {
|
||||
translation: enTranslation
|
||||
translation: enTranslation,
|
||||
},
|
||||
zh: {
|
||||
translation: zhTranslation
|
||||
}
|
||||
translation: zhTranslation,
|
||||
},
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
|
||||
// Common namespace used for all translations
|
||||
defaultNS: 'translation',
|
||||
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // React already safe from XSS
|
||||
},
|
||||
@@ -36,7 +36,7 @@ i18n
|
||||
order: ['localStorage', 'cookie', 'htmlTag', 'navigator'],
|
||||
// Cache the language in localStorage
|
||||
caches: ['localStorage', 'cookie'],
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
export default i18n;
|
||||
|
||||
@@ -4,6 +4,8 @@ import App from './App';
|
||||
import './index.css';
|
||||
// Import the i18n configuration
|
||||
import './i18n';
|
||||
// Setup fetch interceptors
|
||||
import './utils/setupInterceptors';
|
||||
import { loadRuntimeConfig } from './utils/runtime';
|
||||
|
||||
// Load runtime configuration before starting the app
|
||||
|
||||
@@ -32,9 +32,9 @@ const GroupsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeleteGroup = async (groupId: string) => {
|
||||
const success = await deleteGroup(groupId);
|
||||
if (!success) {
|
||||
setGroupError(t('groups.deleteError'));
|
||||
const result = await deleteGroup(groupId);
|
||||
if (!result || !result.success) {
|
||||
setGroupError(result?.message || t('groups.deleteError'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import LanguageSwitch from '@/components/ui/LanguageSwitch';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -41,8 +42,9 @@ const LoginPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8 login-container">
|
||||
<div className="absolute top-4 right-4">
|
||||
<div className="absolute top-4 right-4 w-full max-w-xs flex justify-end">
|
||||
<ThemeSwitch />
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
<div className="max-w-md w-full space-y-8 login-card p-8">
|
||||
<div>
|
||||
|
||||
@@ -103,7 +103,6 @@ const ServersPage: React.FC = () => {
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
|
||||
<p className="text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -10,15 +10,9 @@ import { PermissionChecker } from '@/components/PermissionChecker';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
|
||||
|
||||
// Update current language when it changes
|
||||
useEffect(() => {
|
||||
setCurrentLanguage(i18n.language);
|
||||
}, [i18n.language]);
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string;
|
||||
@@ -197,42 +191,10 @@ const SettingsPage: React.FC = () => {
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: string) => {
|
||||
localStorage.setItem('i18nextLng', lang);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
|
||||
|
||||
{/* Language Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.language')}</h2>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('en')
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('zh')
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('zh')}
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Smart Routing Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SMART_ROUTING}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
|
||||
|
||||
@@ -4,45 +4,27 @@ import {
|
||||
RegisterCredentials,
|
||||
ChangePasswordCredentials,
|
||||
} from '../types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiPost, apiGet } from '../utils/fetchInterceptor';
|
||||
import { getToken, setToken, removeToken } from '../utils/interceptors';
|
||||
|
||||
// Token key in localStorage
|
||||
const TOKEN_KEY = 'mcphub_token';
|
||||
|
||||
// Get token from localStorage
|
||||
export const getToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// Set token in localStorage
|
||||
export const setToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
};
|
||||
|
||||
// Remove token from localStorage
|
||||
export const removeToken = (): void => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
};
|
||||
// Export token management functions
|
||||
export { getToken, setToken, removeToken };
|
||||
|
||||
// Login user
|
||||
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
||||
try {
|
||||
console.log(getApiUrl('/auth/login'));
|
||||
const response = await fetch(getApiUrl('/auth/login'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
const response = await apiPost<AuthResponse>('/auth/login', credentials);
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
|
||||
if (data.success && data.token) {
|
||||
setToken(data.token);
|
||||
// The auth API returns data directly, not wrapped in a data field
|
||||
if (response.success && response.token) {
|
||||
setToken(response.token);
|
||||
return response;
|
||||
}
|
||||
|
||||
return data;
|
||||
return {
|
||||
success: false,
|
||||
message: response.message || 'Login failed',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return {
|
||||
@@ -55,21 +37,17 @@ export const login = async (credentials: LoginCredentials): Promise<AuthResponse
|
||||
// Register user
|
||||
export const register = async (credentials: RegisterCredentials): Promise<AuthResponse> => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/auth/register'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
const response = await apiPost<AuthResponse>('/auth/register', credentials);
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
|
||||
if (data.success && data.token) {
|
||||
setToken(data.token);
|
||||
if (response.success && response.token) {
|
||||
setToken(response.token);
|
||||
return response;
|
||||
}
|
||||
|
||||
return data;
|
||||
return {
|
||||
success: false,
|
||||
message: response.message || 'Registration failed',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
return {
|
||||
@@ -91,14 +69,8 @@ export const getCurrentUser = async (): Promise<AuthResponse> => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/auth/user'), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-auth-token': token,
|
||||
},
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
const response = await apiGet<AuthResponse>('/auth/user');
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Get current user error:', error);
|
||||
return {
|
||||
@@ -122,16 +94,8 @@ export const changePassword = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/auth/change-password'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token,
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
const response = await apiPost<AuthResponse>('/auth/change-password', credentials);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error);
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getApiUrl, getBasePath } from '../utils/runtime';
|
||||
import { apiGet, fetchWithInterceptors } from '../utils/fetchInterceptor';
|
||||
import { getBasePath } from '../utils/runtime';
|
||||
|
||||
export interface SystemConfig {
|
||||
routing?: {
|
||||
@@ -25,6 +26,7 @@ export interface PublicConfigResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
skipAuth?: boolean;
|
||||
permissions?: any;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
@@ -40,10 +42,10 @@ export interface SystemConfigResponse {
|
||||
/**
|
||||
* Get public configuration (skipAuth setting) without authentication
|
||||
*/
|
||||
export const getPublicConfig = async (): Promise<{ skipAuth: boolean }> => {
|
||||
export const getPublicConfig = async (): Promise<{ skipAuth: boolean; permissions?: any }> => {
|
||||
try {
|
||||
const basePath = getBasePath();
|
||||
const response = await fetch(`${basePath}/public-config`, {
|
||||
const response = await fetchWithInterceptors(`${basePath}/public-config`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -52,7 +54,7 @@ export const getPublicConfig = async (): Promise<{ skipAuth: boolean }> => {
|
||||
|
||||
if (response.ok) {
|
||||
const data: PublicConfigResponse = await response.json();
|
||||
return { skipAuth: data.data?.skipAuth === true };
|
||||
return { skipAuth: data.data?.skipAuth === true, permissions: data.data?.permissions || {} };
|
||||
}
|
||||
|
||||
return { skipAuth: false };
|
||||
@@ -69,16 +71,10 @@ export const getPublicConfig = async (): Promise<{ skipAuth: boolean }> => {
|
||||
*/
|
||||
export const getSystemConfigPublic = async (): Promise<SystemConfig | null> => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/settings'), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
const response = await apiGet<SystemConfigResponse>('/settings');
|
||||
|
||||
if (response.ok) {
|
||||
const data: SystemConfigResponse = await response.json();
|
||||
return data.data?.systemConfig || null;
|
||||
if (response.success) {
|
||||
return response.data?.systemConfig || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getToken } from './authService'; // Import getToken function
|
||||
import { apiGet, apiDelete } from '../utils/fetchInterceptor';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { getToken } from '../utils/interceptors';
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
@@ -13,21 +14,13 @@ export interface LogEntry {
|
||||
// Fetch all logs
|
||||
export const fetchLogs = async (): Promise<LogEntry[]> => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = getToken();
|
||||
const response = await fetch(getApiUrl('/logs'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const response = await apiGet<{ success: boolean; data: LogEntry[]; error?: string }>('/logs');
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch logs');
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to fetch logs');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
throw error;
|
||||
@@ -37,19 +30,10 @@ export const fetchLogs = async (): Promise<LogEntry[]> => {
|
||||
// Clear all logs
|
||||
export const clearLogs = async (): Promise<void> => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = getToken();
|
||||
const response = await fetch(getApiUrl('/logs'), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const response = await apiDelete<{ success: boolean; error?: string }>('/logs');
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to clear logs');
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to clear logs');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing logs:', error);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { getToken } from './authService';
|
||||
import { apiPost, apiPut } from '../utils/fetchInterceptor';
|
||||
|
||||
export interface ToolCallRequest {
|
||||
toolName: string;
|
||||
@@ -25,38 +24,32 @@ export const callTool = async (
|
||||
server?: string,
|
||||
): Promise<ToolCallResult> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
// Construct the URL with optional server parameter
|
||||
const url = server ? `/tools/call/${server}` : '/tools/call';
|
||||
|
||||
const response = await fetch(getApiUrl(url), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '', // Include token for authentication
|
||||
Authorization: `Bearer ${token}`, // Add bearer auth for MCP routing
|
||||
},
|
||||
body: JSON.stringify({
|
||||
const response = await apiPost<any>(
|
||||
url,
|
||||
{
|
||||
toolName: request.toolName,
|
||||
arguments: request.arguments,
|
||||
}),
|
||||
});
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
if (!response.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || 'Tool call failed',
|
||||
error: response.message || 'Tool call failed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: data.data.content || [],
|
||||
content: response.data?.content || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calling tool:', error);
|
||||
@@ -76,25 +69,19 @@ export const toggleTool = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
const response = await fetch(getApiUrl(`/servers/${serverName}/tools/${toolName}/toggle`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
Authorization: `Bearer ${token}`,
|
||||
const response = await apiPost<any>(
|
||||
`/servers/${serverName}/tools/${toolName}/toggle`,
|
||||
{ enabled },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
|
||||
},
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: data.success,
|
||||
error: data.success ? undefined : data.message,
|
||||
success: response.success,
|
||||
error: response.success ? undefined : response.message,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error toggling tool:', error);
|
||||
@@ -114,28 +101,19 @@ export const updateToolDescription = async (
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
const response = await fetch(
|
||||
getApiUrl(`/servers/${serverName}/tools/${toolName}/description`),
|
||||
const response = await apiPut<any>(
|
||||
`/servers/${serverName}/tools/${toolName}/description`,
|
||||
{ description },
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
Authorization: `Bearer ${token || ''}`,
|
||||
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
|
||||
},
|
||||
body: JSON.stringify({ description }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: data.success,
|
||||
error: data.success ? undefined : data.message,
|
||||
success: response.success,
|
||||
error: response.success ? undefined : response.message,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating tool description:', error);
|
||||
|
||||
@@ -137,11 +137,17 @@ export interface Server {
|
||||
}
|
||||
|
||||
// Group types
|
||||
// Group server configuration - supports tool selection
|
||||
export interface IGroupServerConfig {
|
||||
name: string; // Server name
|
||||
tools?: string[] | 'all'; // Array of specific tool names to include, or 'all' for all tools (default: 'all')
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
servers: string[];
|
||||
servers: string[] | IGroupServerConfig[]; // Supports both old and new format
|
||||
}
|
||||
|
||||
// Environment variable types
|
||||
@@ -196,7 +202,7 @@ export interface ServerFormData {
|
||||
export interface GroupFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
servers: string[]; // Added servers array to include in form data
|
||||
servers: string[] | IGroupServerConfig[]; // Updated to support new format
|
||||
}
|
||||
|
||||
// API response types
|
||||
|
||||
174
frontend/src/utils/fetchInterceptor.ts
Normal file
174
frontend/src/utils/fetchInterceptor.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { getApiUrl } from './runtime';
|
||||
|
||||
// Define the interceptor interface
|
||||
export interface FetchInterceptor {
|
||||
request?: (url: string, config: RequestInit) => Promise<{ url: string; config: RequestInit }>;
|
||||
response?: (response: Response) => Promise<Response>;
|
||||
error?: (error: Error) => Promise<Error>;
|
||||
}
|
||||
|
||||
// Define the enhanced fetch response interface
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Global interceptors store
|
||||
const interceptors: FetchInterceptor[] = [];
|
||||
|
||||
// Add an interceptor
|
||||
export const addInterceptor = (interceptor: FetchInterceptor): void => {
|
||||
interceptors.push(interceptor);
|
||||
};
|
||||
|
||||
// Remove an interceptor
|
||||
export const removeInterceptor = (interceptor: FetchInterceptor): void => {
|
||||
const index = interceptors.indexOf(interceptor);
|
||||
if (index > -1) {
|
||||
interceptors.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all interceptors
|
||||
export const clearInterceptors = (): void => {
|
||||
interceptors.length = 0;
|
||||
};
|
||||
|
||||
// Enhanced fetch function with interceptors
|
||||
export const fetchWithInterceptors = async (
|
||||
input: string | URL | Request,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> => {
|
||||
let url = input.toString();
|
||||
let config = { ...init };
|
||||
|
||||
try {
|
||||
// Apply request interceptors
|
||||
for (const interceptor of interceptors) {
|
||||
if (interceptor.request) {
|
||||
const result = await interceptor.request(url, config);
|
||||
url = result.url;
|
||||
config = result.config;
|
||||
}
|
||||
}
|
||||
|
||||
// Make the actual fetch request
|
||||
let response = await fetch(url, config);
|
||||
|
||||
// Apply response interceptors
|
||||
for (const interceptor of interceptors) {
|
||||
if (interceptor.response) {
|
||||
response = await interceptor.response(response);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
let processedError = error as Error;
|
||||
|
||||
// Apply error interceptors
|
||||
for (const interceptor of interceptors) {
|
||||
if (interceptor.error) {
|
||||
processedError = await interceptor.error(processedError);
|
||||
}
|
||||
}
|
||||
|
||||
throw processedError;
|
||||
}
|
||||
};
|
||||
|
||||
// Convenience function for API calls with automatic URL construction
|
||||
export const apiRequest = async <T = any>(endpoint: string, init: RequestInit = {}): Promise<T> => {
|
||||
try {
|
||||
const url = getApiUrl(endpoint);
|
||||
const response = await fetchWithInterceptors(url, init);
|
||||
|
||||
// Try to parse JSON response
|
||||
let data: T;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (parseError) {
|
||||
// If JSON parsing fails, create a generic response
|
||||
const genericResponse = {
|
||||
success: response.ok,
|
||||
message: response.ok
|
||||
? 'Request successful'
|
||||
: `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
data = genericResponse as T;
|
||||
}
|
||||
|
||||
// If response is not ok, but no explicit error in parsed data
|
||||
if (!response.ok && typeof data === 'object' && data !== null) {
|
||||
const responseObj = data as any;
|
||||
if (responseObj.success !== false) {
|
||||
responseObj.success = false;
|
||||
responseObj.message =
|
||||
responseObj.message || `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('API request error:', error);
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
};
|
||||
return errorResponse as T;
|
||||
}
|
||||
};
|
||||
|
||||
// Convenience methods for common HTTP methods
|
||||
export const apiGet = <T = any>(endpoint: string, init: Omit<RequestInit, 'method'> = {}) =>
|
||||
apiRequest<T>(endpoint, { ...init, method: 'GET' });
|
||||
|
||||
export const apiPost = <T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
init: Omit<RequestInit, 'method' | 'body'> = {},
|
||||
) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...init,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
|
||||
export const apiPut = <T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
init: Omit<RequestInit, 'method' | 'body'> = {},
|
||||
) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...init,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
|
||||
export const apiDelete = <T = any>(endpoint: string, init: Omit<RequestInit, 'method'> = {}) =>
|
||||
apiRequest<T>(endpoint, { ...init, method: 'DELETE' });
|
||||
|
||||
export const apiPatch = <T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
init: Omit<RequestInit, 'method' | 'body'> = {},
|
||||
) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...init,
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
99
frontend/src/utils/interceptors.ts
Normal file
99
frontend/src/utils/interceptors.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { addInterceptor, removeInterceptor, type FetchInterceptor } from './fetchInterceptor';
|
||||
|
||||
// Token key in localStorage
|
||||
const TOKEN_KEY = 'mcphub_token';
|
||||
|
||||
// Get token from localStorage
|
||||
export const getToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// Set token in localStorage
|
||||
export const setToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
};
|
||||
|
||||
// Remove token from localStorage
|
||||
export const removeToken = (): void => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// Auth interceptor for automatically adding authorization headers
|
||||
export const authInterceptor: FetchInterceptor = {
|
||||
request: async (url: string, config: RequestInit) => {
|
||||
const headers = new Headers(config.headers);
|
||||
const language = localStorage.getItem('i18nextLng') || 'en';
|
||||
headers.set('Accept-Language', language);
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.set('x-auth-token', token);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
config: {
|
||||
...config,
|
||||
headers,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
response: async (response: Response) => {
|
||||
// Handle unauthorized responses
|
||||
if (response.status === 401) {
|
||||
// Token might be expired or invalid, remove it
|
||||
removeToken();
|
||||
|
||||
// You could also trigger a redirect to login page here
|
||||
// window.location.href = '/login';
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
error: async (error: Error) => {
|
||||
console.error('Auth interceptor error:', error);
|
||||
return error;
|
||||
},
|
||||
};
|
||||
|
||||
// Install the auth interceptor
|
||||
export const installAuthInterceptor = (): void => {
|
||||
addInterceptor(authInterceptor);
|
||||
};
|
||||
|
||||
// Uninstall the auth interceptor
|
||||
export const uninstallAuthInterceptor = (): void => {
|
||||
removeInterceptor(authInterceptor);
|
||||
};
|
||||
|
||||
// Logging interceptor for development
|
||||
export const loggingInterceptor: FetchInterceptor = {
|
||||
request: async (url: string, config: RequestInit) => {
|
||||
console.log(`🚀 [${config.method || 'GET'}] ${url}`, config);
|
||||
return { url, config };
|
||||
},
|
||||
|
||||
response: async (response: Response) => {
|
||||
console.log(`✅ [${response.status}] ${response.url}`);
|
||||
return response;
|
||||
},
|
||||
|
||||
error: async (error: Error) => {
|
||||
console.error(`❌ Fetch error:`, error);
|
||||
return error;
|
||||
},
|
||||
};
|
||||
|
||||
// Install the logging interceptor (only in development)
|
||||
export const installLoggingInterceptor = (): void => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
addInterceptor(loggingInterceptor);
|
||||
}
|
||||
};
|
||||
|
||||
// Uninstall the logging interceptor
|
||||
export const uninstallLoggingInterceptor = (): void => {
|
||||
removeInterceptor(loggingInterceptor);
|
||||
};
|
||||
19
frontend/src/utils/setupInterceptors.ts
Normal file
19
frontend/src/utils/setupInterceptors.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { installAuthInterceptor, installLoggingInterceptor } from './interceptors';
|
||||
|
||||
/**
|
||||
* Setup all default interceptors for the application
|
||||
* This should be called once when the app initializes
|
||||
*/
|
||||
export const setupInterceptors = (): void => {
|
||||
// Install auth interceptor for automatic token handling
|
||||
installAuthInterceptor();
|
||||
|
||||
// Install logging interceptor in development mode
|
||||
installLoggingInterceptor();
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize interceptors automatically when this module is imported
|
||||
* This ensures interceptors are set up as early as possible
|
||||
*/
|
||||
setupInterceptors();
|
||||
@@ -186,7 +186,8 @@
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm"
|
||||
"confirm": "Confirm",
|
||||
"language": "Language"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -270,7 +271,14 @@
|
||||
"noGroups": "No groups available. Create a new group to get started.",
|
||||
"noServers": "No servers in this group.",
|
||||
"noServerOptions": "No servers available",
|
||||
"serverCount": "{{count}} Servers"
|
||||
"serverCount": "{{count}} Servers",
|
||||
"toolSelection": "Tool Selection",
|
||||
"toolsSelected": "Selected",
|
||||
"allTools": "All",
|
||||
"selectedTools": "Selected tools",
|
||||
"selectAll": "Select All",
|
||||
"selectNone": "Select None",
|
||||
"configureTools": "Configure Tools"
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market",
|
||||
@@ -448,5 +456,63 @@
|
||||
"deleteConfirmation": "Are you sure you want to delete user '{{username}}'? This action cannot be undone.",
|
||||
"confirmDelete": "Delete User",
|
||||
"deleteWarning": "Are you sure you want to delete user '{{username}}'? This action cannot be undone."
|
||||
},
|
||||
"api": {
|
||||
"errors": {
|
||||
"readonly": "Readonly for demo environment",
|
||||
"serverNameRequired": "Server name is required",
|
||||
"serverConfigRequired": "Server configuration is required",
|
||||
"serverConfigInvalid": "Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments",
|
||||
"serverTypeInvalid": "Server type must be one of: stdio, sse, streamable-http, openapi",
|
||||
"urlRequiredForType": "URL is required for {{type}} server type",
|
||||
"openapiSpecRequired": "OpenAPI specification URL or schema is required for openapi server type",
|
||||
"headersInvalidFormat": "Headers must be an object",
|
||||
"headersNotSupportedForStdio": "Headers are not supported for stdio server type",
|
||||
"serverNotFound": "Server not found",
|
||||
"failedToRemoveServer": "Server not found or failed to remove",
|
||||
"internalServerError": "Internal server error",
|
||||
"failedToGetServers": "Failed to get servers information",
|
||||
"failedToGetServerSettings": "Failed to get server settings",
|
||||
"failedToGetServerConfig": "Failed to get server configuration",
|
||||
"failedToSaveSettings": "Failed to save settings",
|
||||
"toolNameRequired": "Server name and tool name are required",
|
||||
"descriptionMustBeString": "Description must be a string",
|
||||
"groupIdRequired": "Group ID is required",
|
||||
"groupNameRequired": "Group name is required",
|
||||
"groupNotFound": "Group not found",
|
||||
"groupIdAndServerNameRequired": "Group ID and server name are required",
|
||||
"groupOrServerNotFound": "Group or server not found",
|
||||
"toolsMustBeAllOrArray": "Tools must be \"all\" or an array of strings",
|
||||
"serverNameAndToolNameRequired": "Server name and tool name are required",
|
||||
"usernameRequired": "Username is required",
|
||||
"userNotFound": "User not found",
|
||||
"failedToGetUsers": "Failed to get users information",
|
||||
"failedToGetUserInfo": "Failed to get user information",
|
||||
"failedToGetUserStats": "Failed to get user statistics",
|
||||
"marketServerNameRequired": "Server name is required",
|
||||
"marketServerNotFound": "Market server not found",
|
||||
"failedToGetMarketServers": "Failed to get market servers information",
|
||||
"failedToGetMarketServer": "Failed to get market server information",
|
||||
"failedToGetMarketCategories": "Failed to get market categories",
|
||||
"failedToGetMarketTags": "Failed to get market tags",
|
||||
"failedToSearchMarketServers": "Failed to search market servers",
|
||||
"failedToFilterMarketServers": "Failed to filter market servers",
|
||||
"failedToProcessDxtFile": "Failed to process DXT file"
|
||||
},
|
||||
"success": {
|
||||
"serverCreated": "Server created successfully",
|
||||
"serverUpdated": "Server updated successfully",
|
||||
"serverRemoved": "Server removed successfully",
|
||||
"serverToggled": "Server status toggled successfully",
|
||||
"toolToggled": "Tool {{name}} {{action}} successfully",
|
||||
"toolDescriptionUpdated": "Tool {{name}} description updated successfully",
|
||||
"systemConfigUpdated": "System configuration updated successfully",
|
||||
"groupCreated": "Group created successfully",
|
||||
"groupUpdated": "Group updated successfully",
|
||||
"groupDeleted": "Group deleted successfully",
|
||||
"serverAddedToGroup": "Server added to group successfully",
|
||||
"serverRemovedFromGroup": "Server removed from group successfully",
|
||||
"serverToolsUpdated": "Server tools updated successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,8 @@
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"close": "关闭",
|
||||
"confirm": "确认"
|
||||
"confirm": "确认",
|
||||
"language": "语言"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -271,7 +272,14 @@
|
||||
"noGroups": "暂无可用分组。创建一个新分组以开始使用。",
|
||||
"noServers": "此分组中没有服务器。",
|
||||
"noServerOptions": "没有可用的服务器",
|
||||
"serverCount": "{{count}} 台服务器"
|
||||
"serverCount": "{{count}} 台服务器",
|
||||
"toolSelection": "工具选择",
|
||||
"toolsSelected": "选择",
|
||||
"allTools": "全部",
|
||||
"selectedTools": "选中的工具",
|
||||
"selectAll": "全选",
|
||||
"selectNone": "全不选",
|
||||
"configureTools": "配置工具"
|
||||
},
|
||||
"market": {
|
||||
"title": "服务器市场",
|
||||
@@ -421,7 +429,7 @@
|
||||
"edit": "编辑用户",
|
||||
"delete": "删除用户",
|
||||
"create": "创建",
|
||||
"update": "用户",
|
||||
"update": "更新",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"newPassword": "新密码",
|
||||
@@ -450,5 +458,63 @@
|
||||
"deleteConfirmation": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。",
|
||||
"confirmDelete": "删除用户",
|
||||
"deleteWarning": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。"
|
||||
},
|
||||
"api": {
|
||||
"errors": {
|
||||
"readonly": "演示环境无法修改数据",
|
||||
"serverNameRequired": "服务器名称是必需的",
|
||||
"serverConfigRequired": "服务器配置是必需的",
|
||||
"serverConfigInvalid": "服务器配置必须包含 URL、OpenAPI 规范 URL 或模式,或者带参数的命令",
|
||||
"serverTypeInvalid": "服务器类型必须是以下之一:stdio、sse、streamable-http、openapi",
|
||||
"urlRequiredForType": "{{type}} 服务器类型需要 URL",
|
||||
"openapiSpecRequired": "openapi 服务器类型需要 OpenAPI 规范 URL 或模式",
|
||||
"headersInvalidFormat": "请求头必须是对象格式",
|
||||
"headersNotSupportedForStdio": "stdio 服务器类型不支持请求头",
|
||||
"serverNotFound": "找不到服务器",
|
||||
"failedToRemoveServer": "找不到服务器或删除失败",
|
||||
"internalServerError": "服务器内部错误",
|
||||
"failedToGetServers": "获取服务器信息失败",
|
||||
"failedToGetServerSettings": "获取服务器设置失败",
|
||||
"failedToGetServerConfig": "获取服务器配置失败",
|
||||
"failedToSaveSettings": "保存设置失败",
|
||||
"toolNameRequired": "服务器名称和工具名称是必需的",
|
||||
"descriptionMustBeString": "描述必须是字符串",
|
||||
"groupIdRequired": "分组 ID 是必需的",
|
||||
"groupNameRequired": "分组名称是必需的",
|
||||
"groupNotFound": "找不到分组",
|
||||
"groupIdAndServerNameRequired": "分组 ID 和服务器名称是必需的",
|
||||
"groupOrServerNotFound": "找不到分组或服务器",
|
||||
"toolsMustBeAllOrArray": "工具必须是 \"all\" 或字符串数组",
|
||||
"serverNameAndToolNameRequired": "服务器名称和工具名称是必需的",
|
||||
"usernameRequired": "用户名是必需的",
|
||||
"userNotFound": "找不到用户",
|
||||
"failedToGetUsers": "获取用户信息失败",
|
||||
"failedToGetUserInfo": "获取用户信息失败",
|
||||
"failedToGetUserStats": "获取用户统计信息失败",
|
||||
"marketServerNameRequired": "服务器名称是必需的",
|
||||
"marketServerNotFound": "找不到市场服务器",
|
||||
"failedToGetMarketServers": "获取市场服务器信息失败",
|
||||
"failedToGetMarketServer": "获取市场服务器信息失败",
|
||||
"failedToGetMarketCategories": "获取市场类别失败",
|
||||
"failedToGetMarketTags": "获取市场标签失败",
|
||||
"failedToSearchMarketServers": "搜索市场服务器失败",
|
||||
"failedToFilterMarketServers": "过滤市场服务器失败",
|
||||
"failedToProcessDxtFile": "处理 DXT 文件失败"
|
||||
},
|
||||
"success": {
|
||||
"serverCreated": "服务器创建成功",
|
||||
"serverUpdated": "服务器更新成功",
|
||||
"serverRemoved": "服务器删除成功",
|
||||
"serverToggled": "服务器状态切换成功",
|
||||
"toolToggled": "工具 {{name}} {{action}} 成功",
|
||||
"toolDescriptionUpdated": "工具 {{name}} 描述更新成功",
|
||||
"systemConfigUpdated": "系统配置更新成功",
|
||||
"groupCreated": "分组创建成功",
|
||||
"groupUpdated": "分组更新成功",
|
||||
"groupDeleted": "分组删除成功",
|
||||
"serverAddedToGroup": "服务器添加到分组成功",
|
||||
"serverRemovedFromGroup": "服务器从分组移除成功",
|
||||
"serverToolsUpdated": "服务器工具更新成功"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
"dotenv-expand": "^12.0.2",
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.1",
|
||||
"openai": "^4.103.0",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -44,6 +44,9 @@ importers:
|
||||
express-validator:
|
||||
specifier: ^7.2.1
|
||||
version: 7.2.1
|
||||
i18next-fs-backend:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
@@ -2669,6 +2672,9 @@ packages:
|
||||
i18next-browser-languagedetector@8.2.0:
|
||||
resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==}
|
||||
|
||||
i18next-fs-backend@2.6.0:
|
||||
resolution: {integrity: sha512-3ZlhNoF9yxnM8pa8bWp5120/Ob6t4lVl1l/tbLmkml/ei3ud8IWySCHt2lrY5xWRlSU5D9IV2sm5bEbGuTqwTw==}
|
||||
|
||||
i18next@24.2.3:
|
||||
resolution: {integrity: sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==}
|
||||
peerDependencies:
|
||||
@@ -6902,6 +6908,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.0
|
||||
|
||||
i18next-fs-backend@2.6.0: {}
|
||||
|
||||
i18next@24.2.3(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import { McpSettings } from '../types/index.js';
|
||||
import { McpSettings, IUser } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
@@ -13,6 +13,7 @@ const defaultConfig = {
|
||||
initTimeout: process.env.INIT_TIMEOUT || 300000,
|
||||
timeout: process.env.REQUEST_TIMEOUT || 60000,
|
||||
basePath: process.env.BASE_PATH || '',
|
||||
readonly: 'true' === process.env.READONLY || false,
|
||||
mcpHubName: 'mcphub',
|
||||
mcpHubVersion: getPackageVersion(),
|
||||
};
|
||||
@@ -53,14 +54,14 @@ export const loadOriginalSettings = (): McpSettings => {
|
||||
}
|
||||
};
|
||||
|
||||
export const loadSettings = (): McpSettings => {
|
||||
return dataService.filterSettings!(loadOriginalSettings());
|
||||
export const loadSettings = (user?: IUser): McpSettings => {
|
||||
return dataService.filterSettings!(loadOriginalSettings(), user);
|
||||
};
|
||||
|
||||
export const saveSettings = (settings: McpSettings): boolean => {
|
||||
export const saveSettings = (settings: McpSettings, user?: IUser): boolean => {
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings);
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
|
||||
|
||||
// Update cache after successful save
|
||||
|
||||
@@ -18,10 +18,17 @@ const TOKEN_EXPIRY = '24h';
|
||||
|
||||
// Login user
|
||||
export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
// Get translation function from request
|
||||
const t = (req as any).t;
|
||||
|
||||
// Validate request
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
res.status(400).json({ success: false, errors: errors.array() });
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: t('api.errors.validation_failed'),
|
||||
errors: errors.array(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,7 +39,10 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
const user = findUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ success: false, message: 'Invalid credentials' });
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: t('api.errors.invalid_credentials'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -40,7 +50,10 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
const isPasswordValid = await verifyPassword(password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
res.status(401).json({ success: false, message: 'Invalid credentials' });
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: t('api.errors.invalid_credentials'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,6 +69,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
success: true,
|
||||
message: t('api.success.login_successful'),
|
||||
token,
|
||||
user: {
|
||||
username: user.username,
|
||||
@@ -66,16 +80,26 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ success: false, message: 'Server error' });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: t('api.errors.server_error'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Register new user
|
||||
export const register = async (req: Request, res: Response): Promise<void> => {
|
||||
// Get translation function from request
|
||||
const t = (req as any).t;
|
||||
|
||||
// Validate request
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
res.status(400).json({ success: false, errors: errors.array() });
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: t('api.errors.validation_failed'),
|
||||
errors: errors.array(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Request, Response } from 'express';
|
||||
import config from '../config/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
/**
|
||||
* Get runtime configuration for frontend
|
||||
@@ -38,6 +43,15 @@ export const getPublicConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const skipAuth = settings.systemConfig?.routing?.skipAuth || false;
|
||||
let permissions = {};
|
||||
if (skipAuth) {
|
||||
const user: IUser = {
|
||||
username: 'guest',
|
||||
password: '',
|
||||
isAdmin: true,
|
||||
};
|
||||
permissions = dataService.getPermissions(user);
|
||||
}
|
||||
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
@@ -47,6 +61,7 @@ export const getPublicConfig = (req: Request, res: Response): void => {
|
||||
success: true,
|
||||
data: {
|
||||
skipAuth,
|
||||
permissions,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
deleteGroup,
|
||||
addServerToGroup,
|
||||
removeServerFromGroup,
|
||||
getServerConfigInGroup,
|
||||
getServerConfigsInGroup,
|
||||
updateServerToolsInGroup,
|
||||
} from '../services/groupService.js';
|
||||
|
||||
// Get all groups
|
||||
@@ -153,7 +156,7 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
}
|
||||
};
|
||||
|
||||
// Update servers in a group (batch update)
|
||||
// Update servers in a group (batch update) - supports both string[] and server config format
|
||||
export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -170,11 +173,36 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
if (!Array.isArray(servers)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Servers must be an array of server names',
|
||||
message: 'Servers must be an array of server names or server configurations',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate server configurations if provided in new format
|
||||
for (const server of servers) {
|
||||
if (typeof server === 'object' && server !== null) {
|
||||
if (!server.name || typeof server.name !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Each server configuration must have a valid name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
server.tools &&
|
||||
server.tools !== 'all' &&
|
||||
(!Array.isArray(server.tools) ||
|
||||
!server.tools.every((tool: any) => typeof tool === 'string'))
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Tools must be "all" or an array of strings',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedGroup = updateGroupServers(id, servers);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
@@ -343,3 +371,112 @@ export const getGroupServers = (req: Request, res: Response): void => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get server configurations in a group (including tool selections)
|
||||
export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Group ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfigs = getServerConfigsInGroup(id);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: serverConfigs,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get group server configurations',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get specific server configuration in a group
|
||||
export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
if (!id || !serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Group ID and server name are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfig = getServerConfigInGroup(id, serverName);
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found in group',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: serverConfig,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get server configuration',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update tools for a specific server in a group
|
||||
export const updateGroupServerTools = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
const { tools } = req.body;
|
||||
|
||||
if (!id || !serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Group ID and server name are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate tools parameter
|
||||
if (
|
||||
tools !== 'all' &&
|
||||
(!Array.isArray(tools) || !tools.every((tool) => typeof tool === 'string'))
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Tools must be "all" or an array of strings',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = updateServerToolsInGroup(id, serverName, tools);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Group or server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: updatedGroup,
|
||||
message: 'Server tools updated successfully',
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -506,6 +506,7 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { routing, install, smartRouting } = req.body;
|
||||
const currentUser = (req as any).user;
|
||||
|
||||
if (
|
||||
(!routing ||
|
||||
@@ -675,7 +676,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged);
|
||||
}
|
||||
|
||||
if (saveSettings(settings)) {
|
||||
if (saveSettings(settings, currentUser)) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings.systemConfig,
|
||||
|
||||
@@ -9,9 +9,15 @@ import {
|
||||
getUserCount,
|
||||
getAdminCount,
|
||||
} from '../services/userService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
// Admin permission check middleware function
|
||||
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
const settings = loadSettings();
|
||||
if (settings.systemConfig?.routing?.skipAuth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const user = (req as any).user;
|
||||
if (!user || !user.isAdmin) {
|
||||
res.status(403).json({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import defaultConfig from '../config/index.js';
|
||||
|
||||
// Default secret key - in production, use an environment variable
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
||||
@@ -18,8 +19,30 @@ const validateBearerAuth = (req: Request, routingConfig: any): boolean => {
|
||||
return authHeader.substring(7) === routingConfig.bearerAuthKey;
|
||||
};
|
||||
|
||||
const readonlyAllowPaths = ['/tools/call/'];
|
||||
|
||||
const checkReadonly = (req: Request): boolean => {
|
||||
if (!defaultConfig.readonly) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const path of readonlyAllowPaths) {
|
||||
if (req.path.startsWith(defaultConfig.basePath + path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return req.method === 'GET';
|
||||
};
|
||||
|
||||
// Middleware to authenticate JWT token
|
||||
export const auth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const t = (req as any).t;
|
||||
if (!checkReadonly(req)) {
|
||||
res.status(403).json({ success: false, message: t('api.errors.readonly') });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if authentication is disabled globally
|
||||
const routingConfig = loadSettings().systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
|
||||
41
src/middlewares/i18n.ts
Normal file
41
src/middlewares/i18n.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { getT } from '../utils/i18n.js';
|
||||
|
||||
/**
|
||||
* i18n middleware to detect user language and attach translation function to request
|
||||
*/
|
||||
export const i18nMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
// Detect language from various sources (prioritized)
|
||||
const acceptLanguage = req.headers['accept-language'];
|
||||
const customLanguageHeader = req.headers['x-language'] as string;
|
||||
const languageFromQuery = req.query.lang as string;
|
||||
|
||||
// Default to English
|
||||
let detectedLanguage = 'en';
|
||||
|
||||
// Priority order: query parameter > custom header > accept-language header
|
||||
if (languageFromQuery) {
|
||||
detectedLanguage = languageFromQuery;
|
||||
} else if (customLanguageHeader) {
|
||||
detectedLanguage = customLanguageHeader;
|
||||
} else if (acceptLanguage) {
|
||||
// Parse accept-language header and get primary language
|
||||
const primaryLanguage = acceptLanguage.split(',')[0].split('-')[0].trim();
|
||||
detectedLanguage = primaryLanguage;
|
||||
}
|
||||
|
||||
// Normalize language code (ensure we support it)
|
||||
const supportedLanguages = ['en', 'zh'];
|
||||
if (!supportedLanguages.includes(detectedLanguage)) {
|
||||
detectedLanguage = 'en'; // fallback to English
|
||||
}
|
||||
|
||||
// Set language in request (using any type to avoid TypeScript issues)
|
||||
(req as any).language = detectedLanguage;
|
||||
|
||||
// Get translation function for the detected language
|
||||
const t = getT(detectedLanguage);
|
||||
(req as any).t = t;
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { auth } from './auth.js';
|
||||
import { userContextMiddleware } from './userContext.js';
|
||||
import { i18nMiddleware } from './i18n.js';
|
||||
import { initializeDefaultUser } from '../models/User.js';
|
||||
import config from '../config/index.js';
|
||||
|
||||
@@ -18,6 +19,9 @@ export const errorHandler = (
|
||||
};
|
||||
|
||||
export const initMiddlewares = (app: express.Application): void => {
|
||||
// Apply i18n middleware first to detect language for all requests
|
||||
app.use(i18nMiddleware);
|
||||
|
||||
// Serve static files from the dynamically determined frontend path
|
||||
// Note: Static files will be handled by the server directly, not here
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
removeServerFromExistingGroup,
|
||||
getGroupServers,
|
||||
updateGroupServersBatch,
|
||||
getGroupServerConfigs,
|
||||
getGroupServerConfig,
|
||||
updateGroupServerTools,
|
||||
} from '../controllers/groupController.js';
|
||||
import {
|
||||
getUsers,
|
||||
@@ -72,6 +75,10 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.get('/groups/:id/servers', getGroupServers);
|
||||
// New route for batch updating servers in a group
|
||||
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
|
||||
// New routes for server configurations and tool management in groups
|
||||
router.get('/groups/:id/server-configs', getGroupServerConfigs);
|
||||
router.get('/groups/:id/server-configs/:serverName', getGroupServerConfig);
|
||||
router.put('/groups/:id/server-configs/:serverName/tools', updateGroupServerTools);
|
||||
|
||||
// User management routes (admin only)
|
||||
router.get('/users', getUsers);
|
||||
|
||||
@@ -5,6 +5,7 @@ import fs from 'fs';
|
||||
import { initUpstreamServers, connected } from './services/mcpService.js';
|
||||
import { initMiddlewares } from './middlewares/index.js';
|
||||
import { initRoutes } from './routes/index.js';
|
||||
import { initI18n } from './utils/i18n.js';
|
||||
import {
|
||||
handleSseConnection,
|
||||
handleSseMessage,
|
||||
@@ -31,6 +32,10 @@ export class AppServer {
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
// Initialize i18n before other components
|
||||
await initI18n();
|
||||
console.log('i18n initialized successfully');
|
||||
|
||||
// Initialize default admin user if no users exist
|
||||
await initializeDefaultUser();
|
||||
|
||||
|
||||
102
src/services/__tests__/schema-cleanup.test.ts
Normal file
102
src/services/__tests__/schema-cleanup.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
describe('Schema Cleanup Tests', () => {
|
||||
describe('cleanInputSchema functionality', () => {
|
||||
// Helper function to simulate the cleanInputSchema behavior
|
||||
const cleanInputSchema = (schema: any): any => {
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const cleanedSchema = { ...schema };
|
||||
delete cleanedSchema.$schema;
|
||||
|
||||
return cleanedSchema;
|
||||
};
|
||||
|
||||
test('should remove $schema field from inputSchema', () => {
|
||||
const schemaWithDollarSchema = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Test property',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
};
|
||||
|
||||
const cleanedSchema = cleanInputSchema(schemaWithDollarSchema);
|
||||
|
||||
expect(cleanedSchema).not.toHaveProperty('$schema');
|
||||
expect(cleanedSchema.type).toBe('object');
|
||||
expect(cleanedSchema.properties).toEqual({
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Test property',
|
||||
},
|
||||
});
|
||||
expect(cleanedSchema.required).toEqual(['name']);
|
||||
});
|
||||
|
||||
test('should handle null and undefined schemas', () => {
|
||||
expect(cleanInputSchema(null)).toBe(null);
|
||||
expect(cleanInputSchema(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('should handle non-object schemas', () => {
|
||||
expect(cleanInputSchema('string')).toBe('string');
|
||||
expect(cleanInputSchema(42)).toBe(42);
|
||||
expect(cleanInputSchema(true)).toBe(true);
|
||||
});
|
||||
|
||||
test('should preserve other properties while removing $schema', () => {
|
||||
const complexSchema = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
title: 'Test Schema',
|
||||
description: 'A test schema',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const cleanedSchema = cleanInputSchema(complexSchema);
|
||||
|
||||
expect(cleanedSchema).not.toHaveProperty('$schema');
|
||||
expect(cleanedSchema.type).toBe('object');
|
||||
expect(cleanedSchema.title).toBe('Test Schema');
|
||||
expect(cleanedSchema.description).toBe('A test schema');
|
||||
expect(cleanedSchema.properties).toEqual({
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
});
|
||||
expect(cleanedSchema.required).toEqual(['name']);
|
||||
expect(cleanedSchema.additionalProperties).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle schemas without $schema field', () => {
|
||||
const schemaWithoutDollarSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const cleanedSchema = cleanInputSchema(schemaWithoutDollarSchema);
|
||||
|
||||
expect(cleanedSchema).toEqual(schemaWithoutDollarSchema);
|
||||
expect(cleanedSchema).not.toHaveProperty('$schema');
|
||||
});
|
||||
|
||||
test('should handle empty objects', () => {
|
||||
const emptySchema = {};
|
||||
const cleanedSchema = cleanInputSchema(emptySchema);
|
||||
|
||||
expect(cleanedSchema).toEqual({});
|
||||
expect(cleanedSchema).not.toHaveProperty('$schema');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,9 @@ import { IUser, McpSettings } from '../types/index.js';
|
||||
|
||||
export interface DataService {
|
||||
foo(): void;
|
||||
filterData(data: any[]): any[];
|
||||
filterSettings(settings: McpSettings): McpSettings;
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings;
|
||||
filterData(data: any[], user?: IUser): any[];
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings;
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings;
|
||||
getPermissions(user: IUser): string[];
|
||||
}
|
||||
|
||||
@@ -13,15 +13,15 @@ export class DataServiceImpl implements DataService {
|
||||
console.log('default implementation');
|
||||
}
|
||||
|
||||
filterData(data: any[]): any[] {
|
||||
filterData(data: any[], _user?: IUser): any[] {
|
||||
return data;
|
||||
}
|
||||
|
||||
filterSettings(settings: McpSettings): McpSettings {
|
||||
filterSettings(settings: McpSettings, _user?: IUser): McpSettings {
|
||||
return settings;
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings {
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { IGroup } from '../types/index.js';
|
||||
import { IGroup, IGroupServerConfig } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { notifyToolChanged } from './mcpService.js';
|
||||
import { getDataService } from './services.js';
|
||||
|
||||
// Helper function to normalize group servers configuration
|
||||
const normalizeGroupServers = (servers: string[] | IGroupServerConfig[]): IGroupServerConfig[] => {
|
||||
return servers.map((server) => {
|
||||
if (typeof server === 'string') {
|
||||
// Backward compatibility: string format means all tools
|
||||
return { name: server, tools: 'all' };
|
||||
}
|
||||
// New format: ensure tools defaults to 'all' if not specified
|
||||
return { name: server.name, tools: server.tools || 'all' };
|
||||
});
|
||||
};
|
||||
|
||||
// Get all groups
|
||||
export const getAllGroups = (): IGroup[] => {
|
||||
const settings = loadSettings();
|
||||
@@ -32,7 +44,7 @@ export const getGroupByIdOrName = (key: string): IGroup | undefined => {
|
||||
export const createGroup = (
|
||||
name: string,
|
||||
description?: string,
|
||||
servers: string[] = [],
|
||||
servers: string[] | IGroupServerConfig[] = [],
|
||||
owner?: string,
|
||||
): IGroup | null => {
|
||||
try {
|
||||
@@ -44,8 +56,11 @@ export const createGroup = (
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter out non-existent servers
|
||||
const validServers = servers.filter((serverName) => settings.mcpServers[serverName]);
|
||||
// Normalize servers configuration and filter out non-existent servers
|
||||
const normalizedServers = normalizeGroupServers(servers);
|
||||
const validServers: IGroupServerConfig[] = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
);
|
||||
|
||||
const newGroup: IGroup = {
|
||||
id: uuidv4(),
|
||||
@@ -91,9 +106,12 @@ export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null =>
|
||||
return null;
|
||||
}
|
||||
|
||||
// If servers array is provided, validate server existence
|
||||
// If servers array is provided, validate server existence and normalize format
|
||||
if (data.servers) {
|
||||
data.servers = data.servers.filter((serverName) => settings.mcpServers[serverName]);
|
||||
const normalizedServers = normalizeGroupServers(data.servers);
|
||||
data.servers = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
);
|
||||
}
|
||||
|
||||
const updatedGroup = {
|
||||
@@ -116,7 +134,11 @@ export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null =>
|
||||
};
|
||||
|
||||
// Update servers in a group (batch update)
|
||||
export const updateGroupServers = (groupId: string, servers: string[]): IGroup | null => {
|
||||
// Update group servers (maintaining backward compatibility)
|
||||
export const updateGroupServers = (
|
||||
groupId: string,
|
||||
servers: string[] | IGroupServerConfig[],
|
||||
): IGroup | null => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
@@ -128,8 +150,11 @@ export const updateGroupServers = (groupId: string, servers: string[]): IGroup |
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter out non-existent servers
|
||||
const validServers = servers.filter((serverName) => settings.mcpServers[serverName]);
|
||||
// Normalize and filter out non-existent servers
|
||||
const normalizedServers = normalizeGroupServers(servers);
|
||||
const validServers = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
);
|
||||
|
||||
settings.groups[groupIndex].servers = validServers;
|
||||
|
||||
@@ -186,10 +211,12 @@ export const addServerToGroup = (groupId: string, serverName: string): IGroup |
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
|
||||
// Add server to group if not already in it
|
||||
if (!group.servers.includes(serverName)) {
|
||||
group.servers.push(serverName);
|
||||
if (!normalizedServers.some((server) => server.name === serverName)) {
|
||||
normalizedServers.push({ name: serverName, tools: 'all' });
|
||||
group.servers = normalizedServers;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
@@ -218,7 +245,8 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
group.servers = group.servers.filter((name) => name !== serverName);
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
group.servers = normalizedServers.filter((server) => server.name !== serverName);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
@@ -234,5 +262,71 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro
|
||||
// Get all servers in a group
|
||||
export const getServersInGroup = (groupId: string): string[] => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
return group ? group.servers : [];
|
||||
if (!group) return [];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
return normalizedServers.map((server) => server.name);
|
||||
};
|
||||
|
||||
// Get server configuration from group (including tool selection)
|
||||
export const getServerConfigInGroup = (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
): IGroupServerConfig | undefined => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
if (!group) return undefined;
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
return normalizedServers.find((server) => server.name === serverName);
|
||||
};
|
||||
|
||||
// Get all server configurations in a group
|
||||
export const getServerConfigsInGroup = (groupId: string): IGroupServerConfig[] => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
if (!group) return [];
|
||||
return normalizeGroupServers(group.servers);
|
||||
};
|
||||
|
||||
// Update tools selection for a specific server in a group
|
||||
export const updateServerToolsInGroup = (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
tools: string[] | 'all',
|
||||
): IGroup | null => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify server exists
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
|
||||
const serverIndex = normalizedServers.findIndex((server) => server.name === serverName);
|
||||
if (serverIndex === -1) {
|
||||
return null; // Server not in group
|
||||
}
|
||||
|
||||
// Update the tools configuration for the server
|
||||
normalizedServers[serverIndex].tools = tools;
|
||||
group.servers = normalizedServers;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return group;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update tools for server ${serverName} in group ${groupId}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ServerInfo, ServerConfig, ToolInfo } from '../types/index.js';
|
||||
import { loadSettings, saveSettings, expandEnvVars, replaceEnvVars } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup } from './groupService.js';
|
||||
import { getServersInGroup, getServerConfigInGroup } from './groupService.js';
|
||||
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
|
||||
import { OpenAPIClient } from '../clients/openapi.js';
|
||||
import { getDataService } from './services.js';
|
||||
@@ -99,6 +99,18 @@ export const syncToolEmbedding = async (serverName: string, toolName: string) =>
|
||||
saveToolsAsVectorEmbeddings(serverName, [tool]);
|
||||
};
|
||||
|
||||
// Helper function to clean $schema field from inputSchema
|
||||
const cleanInputSchema = (schema: any): any => {
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const cleanedSchema = { ...schema };
|
||||
delete cleanedSchema.$schema;
|
||||
|
||||
return cleanedSchema;
|
||||
};
|
||||
|
||||
// Store all server information
|
||||
let serverInfos: ServerInfo[] = [];
|
||||
|
||||
@@ -272,7 +284,7 @@ const callToolWithReconnect = async (
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${serverInfo.name}-${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || {},
|
||||
inputSchema: cleanInputSchema(tool.inputSchema || {}),
|
||||
}));
|
||||
|
||||
// Save tools as vector embeddings for search
|
||||
@@ -391,7 +403,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
const mcpTools: ToolInfo[] = openApiTools.map((tool) => ({
|
||||
name: `${name}-${tool.name}`,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
inputSchema: cleanInputSchema(tool.inputSchema),
|
||||
}));
|
||||
|
||||
// Update server info with successful initialization
|
||||
@@ -472,7 +484,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${name}-${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || {},
|
||||
inputSchema: cleanInputSchema(tool.inputSchema || {}),
|
||||
}));
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
@@ -823,10 +835,22 @@ Available servers: ${serversList}`;
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
if (serverInfo.tools && serverInfo.tools.length > 0) {
|
||||
// Filter tools based on server configuration and apply custom descriptions
|
||||
const enabledTools = filterToolsByConfig(serverInfo.name, serverInfo.tools);
|
||||
// Filter tools based on server configuration
|
||||
let enabledTools = filterToolsByConfig(serverInfo.name, serverInfo.tools);
|
||||
|
||||
// Apply custom descriptions from configuration
|
||||
// If this is a group request, apply group-level tool filtering
|
||||
if (group) {
|
||||
const serverConfig = getServerConfigInGroup(group, serverInfo.name);
|
||||
if (serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools)) {
|
||||
// Filter tools based on group configuration
|
||||
const allowedToolNames = serverConfig.tools.map(
|
||||
(toolName) => `${serverInfo.name}-${toolName}`,
|
||||
);
|
||||
enabledTools = enabledTools.filter((tool) => allowedToolNames.includes(tool.name));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom descriptions from server configuration
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers[serverInfo.name];
|
||||
const toolsWithCustomDescriptions = enabledTools.map((tool) => {
|
||||
@@ -912,7 +936,7 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
return {
|
||||
name: result.toolName,
|
||||
description: result.description || '',
|
||||
inputSchema: result.inputSchema || {},
|
||||
inputSchema: cleanInputSchema(result.inputSchema || {}),
|
||||
};
|
||||
})
|
||||
.filter((tool) => {
|
||||
|
||||
@@ -13,18 +13,37 @@ const instances = new Map<string, unknown>();
|
||||
|
||||
export function registerService<T>(key: string, entry: Service<T>) {
|
||||
// Try to load override immediately during registration
|
||||
const overridePath = join(process.cwd(), 'src', 'services', key + 'x.ts');
|
||||
try {
|
||||
const require = createRequire(process.cwd());
|
||||
const mod = require(overridePath);
|
||||
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
|
||||
if (typeof override === 'function') {
|
||||
entry.override = override;
|
||||
// Try multiple paths and file extensions in order
|
||||
const serviceDirs = ['src/services', 'dist/services'];
|
||||
const fileExts = ['.ts', '.js'];
|
||||
const overrideFileName = key + 'x';
|
||||
|
||||
for (const serviceDir of serviceDirs) {
|
||||
for (const fileExt of fileExts) {
|
||||
const overridePath = join(process.cwd(), serviceDir, overrideFileName + fileExt);
|
||||
|
||||
try {
|
||||
// Use createRequire with a stable path reference
|
||||
const require = createRequire(join(process.cwd(), 'package.json'));
|
||||
const mod = require(overridePath);
|
||||
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
|
||||
if (typeof override === 'function') {
|
||||
entry.override = override;
|
||||
break; // Found override, exit both loops
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue trying next path/extension combination
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If override was found, break out of outer loop too
|
||||
if (entry.override) {
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore if override doesn't exist
|
||||
}
|
||||
|
||||
console.log(`Service registered: ${key} with entry:`, entry);
|
||||
registry.set(key, entry);
|
||||
}
|
||||
|
||||
|
||||
5
src/types/express.d.ts
vendored
Normal file
5
src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
// Custom types for Express Request
|
||||
export interface I18nRequest extends Request {
|
||||
language?: string;
|
||||
t: (key: string, options?: any) => string;
|
||||
}
|
||||
@@ -17,10 +17,16 @@ export interface IGroup {
|
||||
id: string; // Unique UUID for the group
|
||||
name: string; // Display name of the group
|
||||
description?: string; // Optional description of the group
|
||||
servers: string[]; // Array of server names that belong to this group
|
||||
servers: string[] | IGroupServerConfig[]; // Array of server names or server configurations that belong to this group
|
||||
owner?: string; // Owner of the group, defaults to 'admin' user
|
||||
}
|
||||
|
||||
// Server configuration within a group - supports tool selection
|
||||
export interface IGroupServerConfig {
|
||||
name: string; // Server name
|
||||
tools?: string[] | 'all'; // Array of specific tool names to include, or 'all' for all tools (default: 'all')
|
||||
}
|
||||
|
||||
// Market server types
|
||||
export interface MarketServerRepository {
|
||||
type: string;
|
||||
|
||||
41
src/utils/i18n.ts
Normal file
41
src/utils/i18n.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import i18n from 'i18next';
|
||||
import Backend from 'i18next-fs-backend';
|
||||
import path from 'path';
|
||||
|
||||
// Initialize i18n for backend
|
||||
const initI18n = async () => {
|
||||
return i18n.use(Backend).init({
|
||||
lng: 'en', // default language
|
||||
fallbackLng: 'en',
|
||||
|
||||
backend: {
|
||||
// Path to translation files
|
||||
loadPath: path.join(process.cwd(), 'locales', '{{lng}}.json'),
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for server side
|
||||
},
|
||||
|
||||
// Enable debug mode for development
|
||||
debug: false,
|
||||
|
||||
// Preload languages
|
||||
preload: ['en', 'zh'],
|
||||
|
||||
// Use sync mode for server
|
||||
initImmediate: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Get translation function for a specific language
|
||||
export const getT = (language?: string) => {
|
||||
if (language && language !== i18n.language) {
|
||||
i18n.changeLanguage(language);
|
||||
}
|
||||
return i18n.t.bind(i18n);
|
||||
};
|
||||
|
||||
// Initialize and export
|
||||
export { initI18n };
|
||||
export default i18n;
|
||||
Reference in New Issue
Block a user