feat: add group management functionality (#12)

This commit is contained in:
samanhappy
2025-04-17 18:55:04 +08:00
committed by GitHub
parent 6a065ca2f2
commit adef02d3b9
27 changed files with 1953 additions and 446 deletions

View File

@@ -6,6 +6,7 @@ import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/Dashboard';
import ServersPage from './pages/ServersPage';
import GroupsPage from './pages/GroupsPage';
import SettingsPage from './pages/SettingsPage';
function App() {
@@ -21,6 +22,7 @@ function App() {
<Route element={<MainLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/servers" element={<ServersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>

View File

@@ -0,0 +1,132 @@
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'
interface AddGroupFormProps {
onAdd: () => void
onCancel: () => void
}
const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
const { t } = useTranslation()
const { createGroup } = useGroupData()
const { servers } = useServerData()
const [availableServers, setAvailableServers] = useState<Server[]>([])
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState<GroupFormData>({
name: '',
description: '',
servers: []
})
useEffect(() => {
// Filter available servers (enabled only)
setAvailableServers(servers.filter(server => server.enabled !== false))
}, [servers])
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setError(null)
try {
if (!formData.name.trim()) {
setError(t('groups.nameRequired'))
setIsSubmitting(false)
return
}
const result = await createGroup(formData.name, formData.description, formData.servers)
if (!result) {
setError(t('groups.createError'))
setIsSubmitting(false)
return
}
onAdd()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-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">
<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">
{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"
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"
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"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.create')}
</button>
</div>
</form>
</div>
</div>
</div>
)
}
export default AddGroupForm

View File

@@ -0,0 +1,149 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, GroupFormData, Server } from '@/types'
import { useGroupData } from '@/hooks/useGroupData'
import { useServerData } from '@/hooks/useServerData'
import { ToggleGroup } from './ui/ToggleGroup'
interface EditGroupFormProps {
group: Group
onEdit: () => void
onCancel: () => void
}
const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
const { t } = useTranslation()
const { updateGroup } = useGroupData()
const { servers } = useServerData()
const [availableServers, setAvailableServers] = useState<Server[]>([])
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState<GroupFormData>({
name: group.name,
description: group.description || '',
servers: group.servers || []
})
useEffect(() => {
// Filter available servers (enabled only)
setAvailableServers(servers.filter(server => server.enabled !== false))
}, [servers])
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value
}))
}
const handleServerToggle = (serverName: string) => {
setFormData(prev => {
const isSelected = prev.servers.includes(serverName)
return {
...prev,
servers: isSelected
? prev.servers.filter(name => name !== serverName)
: [...prev.servers, serverName]
}
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setError(null)
try {
if (!formData.name.trim()) {
setError(t('groups.nameRequired'))
setIsSubmitting(false)
return
}
const result = await updateGroup(group.id, {
name: formData.name,
description: formData.description,
servers: formData.servers
})
if (!result) {
setError(t('groups.updateError'))
setIsSubmitting(false)
return
}
onEdit()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-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">
<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">
{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"
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"
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"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.save')}
</button>
</div>
</form>
</div>
</div>
</div>
)
}
export default EditGroupForm

View File

@@ -0,0 +1,121 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, Server } from '@/types'
import { Edit, Trash, Copy, Check } from '@/components/icons/LucideIcons'
import DeleteDialog from '@/components/ui/DeleteDialog'
interface GroupCardProps {
group: Group
servers: Server[]
onEdit: (group: Group) => void
onDelete: (groupId: string) => void
}
const GroupCard = ({
group,
servers,
onEdit,
onDelete
}: GroupCardProps) => {
const { t } = useTranslation()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copied, setCopied] = useState(false)
const handleEdit = () => {
onEdit(group)
}
const handleDelete = () => {
setShowDeleteDialog(true)
}
const handleConfirmDelete = () => {
onDelete(group.id)
setShowDeleteDialog(false)
}
const copyToClipboard = () => {
navigator.clipboard.writeText(group.id).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
// Get servers that belong to this group
const groupServers = servers.filter(server => group.servers.includes(server.name))
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<div>
<div className="flex items-center">
<h2 className="text-xl font-semibold text-gray-800">{group.name}</h2>
<div className="flex items-center ml-3">
<span className="text-xs text-gray-500 mr-1">{group.id}</span>
<button
onClick={copyToClipboard}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
</div>
</div>
{group.description && (
<p className="text-gray-600 text-sm mt-1">{group.description}</p>
)}
</div>
<div className="flex items-center space-x-3">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm">
{t('groups.serverCount', { count: group.servers.length })}
</div>
<button
onClick={handleEdit}
className="text-gray-500 hover:text-gray-700"
title={t('groups.edit')}
>
<Edit size={18} />
</button>
<button
onClick={handleDelete}
className="text-gray-500 hover:text-red-600"
title={t('groups.delete')}
>
<Trash size={18} />
</button>
</div>
</div>
<div className="mt-4">
{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>
)}
</div>
<DeleteDialog
isOpen={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onConfirm={handleConfirmDelete}
serverName={group.name}
isGroup={true}
/>
</div>
)
}
export default GroupCard

View File

@@ -47,52 +47,65 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
}
return (
<div className={`bg-white shadow rounded-lg p-6 mb-6 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-3">
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
<Badge status={server.status} />
</div>
<div className="flex space-x-2">
<button
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm"
>
{t('server.edit')}
</button>
<div className="flex items-center">
<>
<div className={`bg-white shadow rounded-lg p-6 mb-6 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-3">
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
<Badge status={server.status} />
</div>
<div className="flex space-x-2">
<button
onClick={handleToggle}
className={`px-3 py-1 text-sm rounded transition-colors ${
isToggling
? 'bg-gray-200 text-gray-500'
: server.enabled !== false
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}`}
disabled={isToggling}
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm"
>
{isToggling
? t('common.processing')
: server.enabled !== false
? t('server.disable')
: t('server.enable')
}
{t('server.edit')}
</button>
<div className="flex items-center">
<button
onClick={handleToggle}
className={`px-3 py-1 text-sm rounded transition-colors ${
isToggling
? 'bg-gray-200 text-gray-500'
: server.enabled !== false
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}`}
disabled={isToggling}
>
{isToggling
? t('common.processing')
: server.enabled !== false
? t('server.disable')
: t('server.enable')
}
</button>
</div>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
>
{t('server.delete')}
</button>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
</div>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
>
{t('server.delete')}
</button>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
</div>
{isExpanded && server.tools && (
<div className="mt-6">
<h3 className={`text-lg font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h3>
<div className="space-y-4">
{server.tools.map((tool, index) => (
<ToolCard key={index} tool={tool} />
))}
</div>
</div>
)}
</div>
<DeleteDialog
@@ -101,18 +114,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
onConfirm={handleConfirmDelete}
serverName={server.name}
/>
{isExpanded && server.tools && (
<div className="mt-6">
<h3 className={`text-lg font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h3>
<div className="space-y-4">
{server.tools.map((tool, index) => (
<ToolCard key={index} tool={tool} />
))}
</div>
</div>
)}
</div>
</>
)
}

View File

@@ -1,10 +1,14 @@
import { ChevronDown, ChevronRight } from 'lucide-react'
import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check } from 'lucide-react'
export { ChevronDown, ChevronRight }
export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check }
const LucideIcons = {
ChevronDown,
ChevronRight,
Edit,
Trash,
Copy,
Check
}
export default LucideIcons

View File

@@ -37,6 +37,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
</svg>
),
},
{
path: '/groups',
label: t('nav.groups'),
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
</svg>
),
},
{
path: '/settings',
label: t('nav.settings'),

View File

@@ -5,33 +5,40 @@ interface DeleteDialogProps {
onClose: () => void
onConfirm: () => void
serverName: string
isGroup?: boolean
}
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName }: DeleteDialogProps) => {
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false }: DeleteDialogProps) => {
const { t } = useTranslation()
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-gray-900 mb-4">{t('server.delete')}</h3>
<p className="text-gray-700">
{t('server.confirmDelete')} <strong>{serverName}</strong>
</p>
<div className="flex justify-end mt-6">
<button
onClick={onClose}
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
>
{t('server.cancel')}
</button>
<button
onClick={onConfirm}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded"
>
{t('server.delete')}
</button>
<div className="fixed inset-0 bg-black bg-opacity-30 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">
<h3 className="text-lg font-medium text-gray-900 mb-3">
{isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')}
</h3>
<p className="text-gray-500 mb-6">
{isGroup
? t('groups.deleteWarning', { name: serverName })
: t('server.deleteWarning', { name: serverName })}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
{t('common.delete')}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,100 @@
import React, { ReactNode } from 'react';
import { cn } from '@/utils/cn';
interface ToggleGroupItemProps {
value: string;
isSelected: boolean;
onClick: () => void;
children: ReactNode;
}
export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
value,
isSelected,
onClick,
children
}) => {
return (
<button
type="button"
role="checkbox"
aria-checked={isSelected}
className={cn(
"flex w-full items-center justify-between p-2 rounded transition-colors cursor-pointer",
isSelected
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
: "hover:bg-gray-50 text-gray-700"
)}
onClick={onClick}
>
<span className="flex items-center">
{children}
</span>
{isSelected && (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-5 h-5 text-blue-500">
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" />
</svg>
)}
</button>
);
};
interface ToggleGroupProps {
label: string;
helpText?: string;
noOptionsText?: string;
values: string[];
options: { value: string; label: string }[];
onChange: (values: string[]) => void;
className?: string;
}
export const ToggleGroup: React.FC<ToggleGroupProps> = ({
label,
helpText,
noOptionsText = "No options available",
values,
options,
onChange,
className
}) => {
const handleToggle = (value: string) => {
const isSelected = values.includes(value);
if (isSelected) {
onChange(values.filter(v => v !== value));
} else {
onChange([...values, value]);
}
};
return (
<div className={className}>
<label className="block text-gray-700 text-sm font-bold mb-2">
{label}
</label>
<div className="border rounded shadow max-h-60 overflow-y-auto">
{options.length === 0 ? (
<p className="text-gray-500 text-sm p-3">{noOptionsText}</p>
) : (
<div className="space-y-1 p-1">
{options.map(option => (
<ToggleGroupItem
key={option.value}
value={option.value}
isSelected={values.includes(option.value)}
onClick={() => handleToggle(option.value)}
>
{option.label}
</ToggleGroupItem>
))}
</div>
)}
</div>
{helpText && (
<p className="text-xs text-gray-500 mt-1">
{helpText}
</p>
)}
</div>
);
};

View File

@@ -0,0 +1,232 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, ApiResponse } from '@/types';
export const useGroupData = () => {
const { t } = useTranslation();
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const fetchGroups = useCallback(async () => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/groups', {
headers: {
'x-auth-token': token || ''
}
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<Group[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setGroups(data.data);
} else {
console.error('Invalid group data format:', data);
setGroups([]);
}
setError(null);
} catch (err) {
console.error('Error fetching groups:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch groups');
setGroups([]);
} finally {
setLoading(false);
}
}, []);
// Trigger a refresh of the groups data
const triggerRefresh = useCallback(() => {
setRefreshKey(prev => prev + 1);
}, []);
// Create a new group with server associations
const createGroup = async (name: string, description?: string, servers: string[] = []) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/groups', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify({ name, description, servers }),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.createError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create group');
return null;
}
};
// Update an existing group with server associations
const updateGroup = async (id: string, data: { name?: string; description?: string; servers?: string[] }) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/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;
}
triggerRefresh();
return result.data || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update group');
return null;
}
};
// Update servers in a group (for batch updates)
const updateGroupServers = async (groupId: string, servers: string[]) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${groupId}/servers/batch`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify({ servers }),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.updateError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update group servers');
return null;
}
};
// Delete a group
const deleteGroup = async (id: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/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;
}
triggerRefresh();
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete group');
return false;
}
};
// Add server to a group
const addServerToGroup = async (groupId: string, serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${groupId}/servers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify({ serverName }),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.serverAddError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add server to group');
return null;
}
};
// Remove server from group
const removeServerFromGroup = async (groupId: string, serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${groupId}/servers/${serverName}`, {
method: 'DELETE',
headers: {
'x-auth-token': token || ''
}
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.serverRemoveError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove server from group');
return null;
}
};
// Fetch groups when the component mounts or refreshKey changes
useEffect(() => {
fetchGroups();
}, [fetchGroups, refreshKey]);
return {
groups,
loading,
error,
setError,
triggerRefresh,
createGroup,
updateGroup,
updateGroupServers,
deleteGroup,
addServerToGroup,
removeServerFromGroup
};
};

View File

@@ -34,6 +34,7 @@
"edit": "Edit",
"delete": "Delete",
"confirmDelete": "Are you sure you want to delete this server?",
"deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.",
"status": "Status",
"tools": "Tools",
"name": "Server Name",
@@ -80,11 +81,15 @@
"processing": "Processing...",
"save": "Save",
"cancel": "Cancel",
"refresh": "Refresh"
"refresh": "Refresh",
"create": "Create",
"submitting": "Submitting...",
"delete": "Delete"
},
"nav": {
"dashboard": "Dashboard",
"servers": "Servers",
"groups": "Groups",
"settings": "Settings",
"changePassword": "Change Password"
},
@@ -100,9 +105,38 @@
"servers": {
"title": "Servers Management"
},
"groups": {
"title": "Group Management"
},
"settings": {
"title": "Settings",
"language": "Language"
}
},
"groups": {
"add": "Add",
"addNew": "Add New Group",
"edit": "Edit Group",
"delete": "Delete",
"confirmDelete": "Are you sure you want to delete this group?",
"deleteWarning": "Deleting group '{{name}}' will remove it and all its server associations. This action cannot be undone.",
"name": "Group Name",
"namePlaceholder": "Enter group name",
"nameRequired": "Group name is required",
"description": "Description",
"descriptionPlaceholder": "Enter group description (optional)",
"createError": "Failed to create group",
"updateError": "Failed to update group",
"deleteError": "Failed to delete group",
"serverAddError": "Failed to add server to group",
"serverRemoveError": "Failed to remove server from group",
"addServer": "Add Server to Group",
"selectServer": "Select a server to add",
"servers": "Servers in Group",
"remove": "Remove",
"noGroups": "No groups available. Create a new group to get started.",
"noServers": "No servers in this group.",
"noServerOptions": "No servers available",
"serverCount": "{{count}} Servers"
}
}

View File

@@ -34,6 +34,7 @@
"edit": "编辑",
"delete": "删除",
"confirmDelete": "您确定要删除此服务器吗?",
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
"status": "状态",
"tools": "工具",
"name": "服务器名称",
@@ -80,13 +81,17 @@
"processing": "处理中...",
"save": "保存",
"cancel": "取消",
"refresh": "刷新"
"refresh": "刷新",
"create": "创建",
"submitting": "提交中...",
"delete": "删除"
},
"nav": {
"dashboard": "仪表盘",
"servers": "服务器",
"settings": "设置",
"changePassword": "修改密码"
"changePassword": "修改密码",
"groups": "分组"
},
"pages": {
"dashboard": {
@@ -103,6 +108,35 @@
"settings": {
"title": "设置",
"language": "语言"
},
"groups": {
"title": "分组管理"
}
},
"groups": {
"add": "添加",
"addNew": "添加新分组",
"edit": "编辑分组",
"delete": "删除",
"confirmDelete": "您确定要删除此分组吗?",
"deleteWarning": "删除分组 '{{name}}' 将会移除该分组及其所有服务器关联。此操作无法撤销。",
"name": "分组名称",
"namePlaceholder": "请输入分组名称",
"nameRequired": "分组名称不能为空",
"description": "描述",
"descriptionPlaceholder": "请输入分组描述(可选)",
"createError": "创建分组失败",
"updateError": "更新分组失败",
"deleteError": "删除分组失败",
"serverAddError": "向分组添加服务器失败",
"serverRemoveError": "从分组移除服务器失败",
"addServer": "添加服务器到分组",
"selectServer": "选择要添加的服务器",
"servers": "分组中的服务器",
"remove": "移除",
"noGroups": "暂无可用分组。创建一个新分组以开始使用。",
"noServers": "此分组中没有服务器。",
"noServerOptions": "没有可用的服务器",
"serverCount": "{{count}} 台服务器"
}
}

View File

@@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Group } from '@/types';
import { useGroupData } from '@/hooks/useGroupData';
import { useServerData } from '@/hooks/useServerData';
import AddGroupForm from '@/components/AddGroupForm';
import EditGroupForm from '@/components/EditGroupForm';
import GroupCard from '@/components/GroupCard';
const GroupsPage: React.FC = () => {
const { t } = useTranslation();
const {
groups,
loading: groupsLoading,
error: groupError,
setError: setGroupError,
deleteGroup,
triggerRefresh
} = useGroupData();
const { servers } = useServerData();
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const handleEditClick = (group: Group) => {
setEditingGroup(group);
};
const handleEditComplete = () => {
setEditingGroup(null);
triggerRefresh(); // Refresh the groups list after editing
};
const handleDeleteGroup = async (groupId: string) => {
const success = await deleteGroup(groupId);
if (!success) {
setGroupError(t('groups.deleteError'));
}
};
const handleAddGroup = () => {
setShowAddForm(true);
};
const handleAddComplete = () => {
setShowAddForm(false);
triggerRefresh(); // Refresh the groups list after adding
};
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">{t('pages.groups.title')}</h1>
<div className="flex space-x-4">
<button
onClick={handleAddGroup}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{t('groups.add')}
</button>
</div>
</div>
{groupError && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<p>{groupError}</p>
</div>
)}
{groupsLoading ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : groups.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-600">{t('groups.noGroups')}</p>
</div>
) : (
<div className="space-y-6">
{groups.map((group) => (
<GroupCard
key={group.id}
group={group}
servers={servers}
onEdit={handleEditClick}
onDelete={handleDeleteGroup}
/>
))}
</div>
)}
{showAddForm && (
<AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />
)}
{editingGroup && (
<EditGroupForm
group={editingGroup}
onEdit={handleEditComplete}
onCancel={() => setEditingGroup(null)}
/>
)}
</div>
);
};
export default GroupsPage;

View File

@@ -1,30 +1,30 @@
// 服务器状态类型
// Server status types
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
// 工具输入模式类型
// Tool input schema types
export interface ToolInputSchema {
type: string;
properties?: Record<string, any>;
properties?: Record<string, unknown>;
required?: string[];
[key: string]: any;
}
// 工具类型
// Tool types
export interface Tool {
name: string;
description?: string;
description: string;
inputSchema: ToolInputSchema;
}
// 服务器配置类型
// Server config types
export interface ServerConfig {
url?: string;
command?: string;
args?: string[] | string;
args?: string[];
env?: Record<string, string>;
enabled?: boolean;
}
// 服务器类型
// Server types
export interface Server {
name: string;
status: ServerStatus;
@@ -33,26 +33,41 @@ export interface Server {
enabled?: boolean;
}
// 环境变量类型
// Group types
export interface Group {
id: string;
name: string;
description?: string;
servers: string[];
}
// Environment variable types
export interface EnvVar {
key: string;
value: string;
}
// 表单数据类型
// Form data types
export interface ServerFormData {
name: string;
url: string;
command: string;
arguments: string;
args: string[];
env: EnvVar[];
}
// API响应类型
export interface ApiResponse<T> {
// Group form data types
export interface GroupFormData {
name: string;
description: string;
servers: string[]; // Added servers array to include in form data
}
// API response types
export interface ApiResponse<T = any> {
success: boolean;
data: T;
message?: string;
data?: T;
}
// Auth types
@@ -62,10 +77,9 @@ export interface IUser {
}
export interface AuthState {
token: string | null;
isAuthenticated: boolean;
loading: boolean;
user: IUser | null;
loading: boolean;
error: string | null;
}
@@ -88,5 +102,4 @@ export interface AuthResponse {
token?: string;
user?: IUser;
message?: string;
errors?: Array<{ msg: string }>;
}

View File

@@ -0,0 +1,10 @@
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Combines multiple class names and deduplicates Tailwind CSS classes
* This is a utility function for conditionally joining class names together
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}