mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
feat: add group management functionality (#12)
This commit is contained in:
@@ -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>
|
||||
|
||||
132
frontend/src/components/AddGroupForm.tsx
Normal file
132
frontend/src/components/AddGroupForm.tsx
Normal 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
|
||||
149
frontend/src/components/EditGroupForm.tsx
Normal file
149
frontend/src/components/EditGroupForm.tsx
Normal 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
|
||||
121
frontend/src/components/GroupCard.tsx
Normal file
121
frontend/src/components/GroupCard.tsx
Normal 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
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
100
frontend/src/components/ui/ToggleGroup.tsx
Normal file
100
frontend/src/components/ui/ToggleGroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
232
frontend/src/hooks/useGroupData.ts
Normal file
232
frontend/src/hooks/useGroupData.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}} 台服务器"
|
||||
}
|
||||
}
|
||||
116
frontend/src/pages/GroupsPage.tsx
Normal file
116
frontend/src/pages/GroupsPage.tsx
Normal 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;
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user