Initial commit
This commit is contained in:
148
frontend/src/components/Auth.jsx
Normal file
148
frontend/src/components/Auth.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { Server, Eye, EyeOff } from 'lucide-react';
|
||||
import { getTheme } from '../themes';
|
||||
|
||||
export default function Auth({ onLogin }) {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [theme] = useState(localStorage.getItem('theme') || 'dark');
|
||||
|
||||
const currentTheme = getTheme(theme);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await onLogin(username, password, isLogin);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Ошибка авторизации');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${currentTheme.primary} flex items-center justify-center p-4 transition-colors duration-300`}>
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className={`${currentTheme.accent} w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg`}>
|
||||
<Server className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h1 className={`text-3xl font-bold bg-gradient-to-r ${currentTheme.gradient} bg-clip-text text-transparent mb-2`}>MC Panel</h1>
|
||||
<p className={`${currentTheme.textSecondary}`}>Панель управления Minecraft серверами</p>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<div className={`${currentTheme.secondary} rounded-2xl shadow-2xl ${currentTheme.border} border p-8`}>
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setIsLogin(true)}
|
||||
className={`flex-1 py-3 rounded-xl font-medium transition-all duration-200 ${
|
||||
isLogin
|
||||
? `${currentTheme.accent} text-white shadow-lg`
|
||||
: `${currentTheme.card} ${currentTheme.text} ${currentTheme.hover}`
|
||||
}`}
|
||||
>
|
||||
Вход
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsLogin(false)}
|
||||
className={`flex-1 py-3 rounded-xl font-medium transition-all duration-200 ${
|
||||
!isLogin
|
||||
? `${currentTheme.accent} text-white shadow-lg`
|
||||
: `${currentTheme.card} ${currentTheme.text} ${currentTheme.hover}`
|
||||
}`}
|
||||
>
|
||||
Регистрация
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium ${currentTheme.text} mb-2`}>
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className={`w-full ${currentTheme.input} ${currentTheme.border} border rounded-xl px-4 py-3 ${currentTheme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium ${currentTheme.text} mb-2`}>
|
||||
Пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className={`w-full ${currentTheme.input} ${currentTheme.border} border rounded-xl px-4 py-3 pr-12 ${currentTheme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={`absolute right-3 top-1/2 -translate-y-1/2 ${currentTheme.textSecondary} hover:${currentTheme.text} transition`}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-500 bg-opacity-10 border border-red-500 rounded-xl p-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full ${currentTheme.accent} ${currentTheme.accentHover} text-white py-3 rounded-xl font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl`}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Загрузка...
|
||||
</span>
|
||||
) : (
|
||||
isLogin ? 'Войти' : 'Зарегистрироваться'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Default Credentials */}
|
||||
{isLogin && (
|
||||
<div className={`mt-6 text-center text-sm ${currentTheme.textSecondary}`}>
|
||||
<p>Учётные данные по умолчанию:</p>
|
||||
<p className={`${currentTheme.text} font-mono mt-1`}>admin / admin</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`text-center mt-6 text-sm ${currentTheme.textSecondary}`}>
|
||||
<p>© 2024 MC Panel. Все права защищены.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
frontend/src/components/Auth1.jsx
Normal file
109
frontend/src/components/Auth1.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { Server } from 'lucide-react';
|
||||
|
||||
export default function Auth({ onLogin }) {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await onLogin(username, password, isLogin);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Ошибка авторизации');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-800 rounded-lg p-8 w-full max-w-md border border-gray-700">
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
<Server className="w-12 h-12 text-blue-500 mr-3" />
|
||||
<h1 className="text-3xl font-bold text-white">MC Panel</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setIsLogin(true)}
|
||||
className={`flex-1 py-2 rounded ${
|
||||
isLogin
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-400 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Вход
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsLogin(false)}
|
||||
className={`flex-1 py-2 rounded ${
|
||||
!isLogin
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-400 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Регистрация
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white focus:outline-none focus:border-blue-500"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white focus:outline-none focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-600 bg-opacity-20 border border-red-600 rounded p-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Загрузка...' : isLogin ? 'Войти' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{isLogin && (
|
||||
<div className="mt-4 text-center text-sm text-gray-400">
|
||||
<p>По умолчанию:</p>
|
||||
<p className="text-gray-300">admin / admin</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/Console.jsx
Normal file
90
frontend/src/components/Console.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL, WS_URL } from '../config';
|
||||
|
||||
export default function Console({ serverName, token, theme }) {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [command, setCommand] = useState('');
|
||||
const logsEndRef = useRef(null);
|
||||
const wsRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLogs([]);
|
||||
|
||||
const ws = new WebSocket(`${WS_URL}/ws/servers/${serverName}/console`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket подключен');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
setLogs((prev) => [...prev, event.data]);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket ошибка:', error);
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [serverName]);
|
||||
|
||||
useEffect(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
const sendCommand = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!command.trim()) return;
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_URL}/api/servers/${serverName}/command`,
|
||||
{ command: command.trim() },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
setCommand('');
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки команды:', error);
|
||||
alert(error.response?.data?.detail || 'Ошибка отправки команды');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${theme.primary}`}>
|
||||
<div className={`flex-1 overflow-y-auto p-4 font-mono text-sm ${theme.secondary}`}>
|
||||
{logs.length === 0 ? (
|
||||
<div className={theme.textSecondary}>Консоль пуста. Запустите сервер для просмотра логов.</div>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div key={index} className={`${theme.text} whitespace-pre-wrap leading-relaxed`}>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={sendCommand} className={`${theme.border} border-t p-4 flex gap-2`}>
|
||||
<input
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="Введите команду..."
|
||||
className={`flex-1 ${theme.input} ${theme.border} border rounded-xl px-4 py-2 ${theme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={`${theme.accent} ${theme.accentHover} px-6 py-2 rounded-xl flex items-center gap-2 text-white transition`}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
Отправить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
frontend/src/components/CreateServerModal.jsx
Normal file
108
frontend/src/components/CreateServerModal.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function CreateServerModal({ token, theme, onClose, onCreated }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
displayName: '',
|
||||
startCommand: 'java -Xmx2G -Xms1G -jar server.jar nogui'
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_URL}/api/servers/create`,
|
||||
formData,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка создания сервера');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className={`${theme.secondary} rounded-2xl p-6 w-full max-w-md shadow-2xl ${theme.border} border`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-bold ${theme.text}`}>Создать сервер</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`${theme.textSecondary} hover:${theme.text} transition`}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${theme.text}`}>
|
||||
Имя папки (только латиница, цифры, _ и -)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className={`w-full ${theme.input} ${theme.border} border rounded-xl px-4 py-2 ${theme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||
placeholder="my_server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${theme.text}`}>
|
||||
Отображаемое имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.displayName}
|
||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||
className={`w-full ${theme.input} ${theme.border} border rounded-xl px-4 py-2 ${theme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||
placeholder="Мой сервер"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${theme.text}`}>
|
||||
Команда запуска
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.startCommand}
|
||||
onChange={(e) => setFormData({ ...formData, startCommand: e.target.value })}
|
||||
className={`w-full ${theme.input} ${theme.border} border rounded-xl px-4 py-2 ${theme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex-1 ${theme.card} ${theme.hover} px-4 py-2 rounded-xl transition`}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`flex-1 ${theme.accent} ${theme.accentHover} px-4 py-2 rounded-xl disabled:opacity-50 transition text-white`}
|
||||
>
|
||||
{loading ? 'Создание...' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/ErrorBoundary.jsx
Normal file
44
frontend/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-gray-900 text-white p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">Что-то пошло не так</h2>
|
||||
<p className="text-gray-400 mb-4">
|
||||
Произошла ошибка при загрузке компонента
|
||||
</p>
|
||||
<pre className="bg-black p-4 rounded text-left text-sm overflow-auto mb-4">
|
||||
{this.state.error?.toString()}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-6 py-2 rounded"
|
||||
>
|
||||
Перезагрузить страницу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
64
frontend/src/components/FileEditorModal.jsx
Normal file
64
frontend/src/components/FileEditorModal.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Save } from 'lucide-react';
|
||||
|
||||
export default function FileEditorModal({ file, onClose, onSave }) {
|
||||
const [content, setContent] = useState(file.content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await onSave(file.path, content);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.ctrlKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-800 rounded-lg w-full max-w-4xl h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold">Редактирование: {file.name}</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-4 bg-gray-900">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full h-full bg-black text-gray-300 font-mono text-sm p-4 rounded border border-gray-700 focus:outline-none focus:border-blue-500 resize-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-700 text-sm text-gray-400">
|
||||
Используйте Ctrl+S для быстрого сохранения
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
303
frontend/src/components/FileManager.jsx
Normal file
303
frontend/src/components/FileManager.jsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Folder, File, Download, Trash2, Upload, Edit, Eye } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import FileEditorModal from './FileEditorModal';
|
||||
import FileViewerModal from './FileViewerModal';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function FileManager({ serverName, token }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [editingFile, setEditingFile] = useState(null);
|
||||
const [viewingFile, setViewingFile] = useState(null);
|
||||
const [renamingFile, setRenamingFile] = useState(null);
|
||||
const [newFileName, setNewFileName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
}, [serverName, currentPath]);
|
||||
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/servers/${serverName}/files`, {
|
||||
params: { path: currentPath },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setFiles(data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки файлов:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const openFolder = (folderName) => {
|
||||
setCurrentPath(currentPath ? `${currentPath}/${folderName}` : folderName);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const parts = currentPath.split('/');
|
||||
parts.pop();
|
||||
setCurrentPath(parts.join('/'));
|
||||
};
|
||||
|
||||
const downloadFile = async (fileName) => {
|
||||
const filePath = currentPath ? `${currentPath}/${fileName}` : fileName;
|
||||
window.open(`${API_URL}/api/servers/${serverName}/files/download?path=${filePath}`, '_blank');
|
||||
};
|
||||
|
||||
const deleteFile = async (fileName) => {
|
||||
if (!confirm(`Удалить ${fileName}?`)) return;
|
||||
|
||||
try {
|
||||
const filePath = currentPath ? `${currentPath}/${fileName}` : fileName;
|
||||
await axios.delete(`${API_URL}/api/servers/${serverName}/files`, {
|
||||
params: { path: filePath },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
loadFiles();
|
||||
} catch (error) {
|
||||
alert('Ошибка удаления файла');
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFile = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_URL}/api/servers/${serverName}/files/upload?path=${currentPath}`,
|
||||
formData,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
loadFiles();
|
||||
} catch (error) {
|
||||
alert('Ошибка загрузки файла');
|
||||
}
|
||||
};
|
||||
|
||||
const viewFile = async (fileName) => {
|
||||
const filePath = currentPath ? `${currentPath}/${fileName}` : fileName;
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/servers/${serverName}/files/content`, {
|
||||
params: { path: filePath },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setViewingFile({ name: fileName, path: filePath, content: data.content });
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка открытия файла');
|
||||
}
|
||||
};
|
||||
|
||||
const editFile = async (fileName) => {
|
||||
const filePath = currentPath ? `${currentPath}/${fileName}` : fileName;
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/servers/${serverName}/files/content`, {
|
||||
params: { path: filePath },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setEditingFile({ name: fileName, path: filePath, content: data.content });
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка открытия файла');
|
||||
}
|
||||
};
|
||||
|
||||
const saveFile = async (filePath, content) => {
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_URL}/api/servers/${serverName}/files/content`,
|
||||
{ content },
|
||||
{
|
||||
params: { path: filePath },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
setEditingFile(null);
|
||||
alert('Файл сохранен');
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка сохранения файла');
|
||||
}
|
||||
};
|
||||
|
||||
const startRename = (fileName) => {
|
||||
setRenamingFile(fileName);
|
||||
setNewFileName(fileName);
|
||||
};
|
||||
|
||||
const renameFile = async (oldName) => {
|
||||
if (!newFileName.trim() || newFileName === oldName) {
|
||||
setRenamingFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const oldPath = currentPath ? `${currentPath}/${oldName}` : oldName;
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_URL}/api/servers/${serverName}/files/rename`,
|
||||
null,
|
||||
{
|
||||
params: { old_path: oldPath, new_name: newFileName },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
setRenamingFile(null);
|
||||
loadFiles();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка переименования файла');
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes === 0) return '-';
|
||||
const k = 1024;
|
||||
const sizes = ['Б', 'КБ', 'МБ', 'ГБ'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-900">
|
||||
<div className="border-b border-gray-700 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{currentPath && (
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
)}
|
||||
<span className="text-gray-400">/{currentPath || 'root'}</span>
|
||||
</div>
|
||||
<label className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded cursor-pointer flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
Загрузить
|
||||
<input type="file" onChange={uploadFile} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-800 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left p-4">Имя</th>
|
||||
<th className="text-left p-4">Размер</th>
|
||||
<th className="text-right p-4">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<tr
|
||||
key={file.name}
|
||||
className="border-b border-gray-800 hover:bg-gray-800"
|
||||
>
|
||||
<td className="p-4">
|
||||
{renamingFile === file.name ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{file.type === 'directory' ? (
|
||||
<Folder className="w-5 h-5 text-blue-400" />
|
||||
) : (
|
||||
<File className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
onBlur={() => renameFile(file.name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') renameFile(file.name);
|
||||
if (e.key === 'Escape') setRenamingFile(null);
|
||||
}}
|
||||
autoFocus
|
||||
className="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => file.type === 'directory' && openFolder(file.name)}
|
||||
onDoubleClick={() => file.type === 'file' && viewFile(file.name)}
|
||||
>
|
||||
{file.type === 'directory' ? (
|
||||
<Folder className="w-5 h-5 text-blue-400" />
|
||||
) : (
|
||||
<File className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
<span>{file.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">{formatSize(file.size)}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex gap-2 justify-end">
|
||||
{file.type === 'file' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => viewFile(file.name)}
|
||||
className="bg-blue-600 hover:bg-blue-700 p-2 rounded"
|
||||
title="Просмотр"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editFile(file.name)}
|
||||
className="bg-purple-600 hover:bg-purple-700 p-2 rounded"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadFile(file.name)}
|
||||
className="bg-green-600 hover:bg-green-700 p-2 rounded"
|
||||
title="Скачать"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => startRename(file.name)}
|
||||
className="bg-yellow-600 hover:bg-yellow-700 p-2 rounded"
|
||||
title="Переименовать"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteFile(file.name)}
|
||||
className="bg-red-600 hover:bg-red-700 p-2 rounded"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{viewingFile && (
|
||||
<FileViewerModal
|
||||
file={viewingFile}
|
||||
onClose={() => setViewingFile(null)}
|
||||
onEdit={() => {
|
||||
setEditingFile(viewingFile);
|
||||
setViewingFile(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingFile && (
|
||||
<FileEditorModal
|
||||
file={editingFile}
|
||||
onClose={() => setEditingFile(null)}
|
||||
onSave={saveFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/FileViewerModal.jsx
Normal file
34
frontend/src/components/FileViewerModal.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { X, Edit } from 'lucide-react';
|
||||
|
||||
export default function FileViewerModal({ file, onClose, onEdit }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-800 rounded-lg w-full max-w-4xl h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold">{file.name}</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded flex items-center gap-2"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 bg-gray-900">
|
||||
<pre className="text-sm text-gray-300 font-mono whitespace-pre-wrap">
|
||||
{file.content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/Login.jsx
Normal file
106
frontend/src/components/Login.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import { Server } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function Login({ onLogin }) {
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const endpoint = isRegister ? '/api/auth/register' : '/api/auth/login';
|
||||
const { data } = await axios.post(`${API_URL}${endpoint}`, {
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('username', data.username);
|
||||
localStorage.setItem('role', data.role);
|
||||
|
||||
onLogin(data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Ошибка авторизации');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-800 rounded-lg p-8 w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
<Server className="w-12 h-12 text-blue-500 mr-3" />
|
||||
<h1 className="text-3xl font-bold text-white">MC Panel</h1>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold text-white mb-6 text-center">
|
||||
{isRegister ? 'Регистрация' : 'Вход'}
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-600 text-white p-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
className="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white focus:outline-none focus:border-blue-500"
|
||||
placeholder="Введите имя пользователя"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white focus:outline-none focus:border-blue-500"
|
||||
placeholder="Введите пароль"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded font-medium disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Загрузка...' : (isRegister ? 'Зарегистрироваться' : 'Войти')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => setIsRegister(!isRegister)}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{isRegister ? 'Уже есть аккаунт? Войти' : 'Нет аккаунта? Зарегистрироваться'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
frontend/src/components/ServerAccessModal.jsx
Normal file
132
frontend/src/components/ServerAccessModal.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, UserPlus, Trash2 } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function ServerAccessModal({ serverName, onClose }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, [serverName]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const { data } = await axios.get(
|
||||
`${API_URL}/api/servers/${serverName}/access`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
setUsers(data.users);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки пользователей:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const addUser = async () => {
|
||||
if (!newUsername.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post(
|
||||
`${API_URL}/api/servers/${serverName}/access`,
|
||||
{ username: newUsername, server_name: serverName },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
setNewUsername('');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка добавления пользователя');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeUser = async (username) => {
|
||||
if (!confirm(`Удалить доступ для ${username}?`)) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.delete(
|
||||
`${API_URL}/api/servers/${serverName}/access`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: { username, server_name: serverName }
|
||||
}
|
||||
);
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка удаления доступа');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold">Управление доступом</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Добавить пользователя
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
placeholder="Имя пользователя"
|
||||
className="flex-1 bg-gray-700 border border-gray-600 rounded px-4 py-2 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={addUser}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Пользователи с доступом:</h3>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{users.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Нет пользователей</p>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<div
|
||||
key={user.username}
|
||||
className="bg-gray-700 p-3 rounded flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">{user.username}</span>
|
||||
{user.role === 'admin' && (
|
||||
<span className="ml-2 text-xs bg-blue-600 px-2 py-1 rounded">
|
||||
Админ
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeUser(user.username)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
268
frontend/src/components/ServerSettings.jsx
Normal file
268
frontend/src/components/ServerSettings.jsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Save, Trash2, Users, UserPlus } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function ServerSettings({ serverName, token, user, onDeleted }) {
|
||||
const [config, setConfig] = useState({
|
||||
name: '',
|
||||
displayName: '',
|
||||
startCommand: '',
|
||||
owner: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [serverUsers, setServerUsers] = useState([]);
|
||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const isOwner = config.owner === user?.username;
|
||||
const canManageAccess = isAdmin || isOwner;
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
if (canManageAccess) {
|
||||
loadUsers();
|
||||
}
|
||||
}, [serverName, canManageAccess]);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/servers/${serverName}/config`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setConfig(data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки настроек:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/users`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setAllUsers(data);
|
||||
|
||||
// Находим пользователей с доступом к этому серверу
|
||||
const usersWithAccess = data.filter(u =>
|
||||
u.role === 'admin' || u.servers?.includes(serverName)
|
||||
);
|
||||
setServerUsers(usersWithAccess);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки пользователей:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleUserAccess = async (username) => {
|
||||
const userHasAccess = serverUsers.some(u => u.username === username);
|
||||
const targetUser = allUsers.find(u => u.username === username);
|
||||
|
||||
if (!targetUser) return;
|
||||
|
||||
const newServers = userHasAccess
|
||||
? targetUser.servers.filter(s => s !== serverName)
|
||||
: [...(targetUser.servers || []), serverName];
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_URL}/api/users/${username}/servers`,
|
||||
{ servers: newServers },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка изменения доступа');
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_URL}/api/servers/${serverName}/config`,
|
||||
config,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
alert('Настройки сохранены');
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка сохранения настроек');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteServer = async () => {
|
||||
if (!confirm(`Вы уверены, что хотите удалить сервер "${config.displayName}"? Все файлы будут удалены!`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.delete(`${API_URL}/api/servers/${serverName}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
onDeleted();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка удаления сервера');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-400">Загрузка...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-gray-900 h-full overflow-y-auto">
|
||||
<h2 className="text-2xl font-bold mb-6">Настройки сервера</h2>
|
||||
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Имя папки (нельзя изменить)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.name}
|
||||
disabled
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-4 py-2 text-gray-500 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Отображаемое имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.displayName}
|
||||
onChange={(e) => setConfig({ ...config, displayName: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-4 py-2 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Команда запуска
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.startCommand}
|
||||
onChange={(e) => setConfig({ ...config, startCommand: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-4 py-2 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
Пример: java -Xmx2G -Xms1G -jar server.jar nogui
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{config.owner && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Владелец
|
||||
</label>
|
||||
<div className="text-gray-300">{config.owner}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
onClick={saveConfig}
|
||||
disabled={saving}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-6 py-2 rounded flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{canManageAccess && (
|
||||
<div className="border-t border-gray-700 pt-6 mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Управление доступом</h3>
|
||||
<button
|
||||
onClick={() => setShowUserManagement(!showUserManagement)}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded flex items-center gap-2"
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
{showUserManagement ? 'Скрыть' : 'Показать пользователей'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showUserManagement && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Выберите пользователей, которые могут управлять этим сервером:
|
||||
</p>
|
||||
{allUsers.filter(u => u.role !== 'admin').map((targetUser) => {
|
||||
const hasAccess = serverUsers.some(u => u.username === targetUser.username);
|
||||
return (
|
||||
<div
|
||||
key={targetUser.username}
|
||||
className="flex items-center justify-between bg-gray-800 p-3 rounded"
|
||||
>
|
||||
<span>{targetUser.username}</span>
|
||||
<button
|
||||
onClick={() => toggleUserAccess(targetUser.username)}
|
||||
className={`px-4 py-1 rounded text-sm ${
|
||||
hasAccess
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-gray-600 hover:bg-gray-500'
|
||||
}`}
|
||||
>
|
||||
{hasAccess ? 'Есть доступ' : 'Нет доступа'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{allUsers.filter(u => u.role !== 'admin').length === 0 && (
|
||||
<p className="text-gray-500 text-sm">Нет обычных пользователей</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium mb-2 text-gray-400">
|
||||
Пользователи с доступом:
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{serverUsers.map((u) => (
|
||||
<span
|
||||
key={u.username}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
u.role === 'admin'
|
||||
? 'bg-blue-600'
|
||||
: 'bg-green-600'
|
||||
}`}
|
||||
>
|
||||
{u.username} {u.role === 'admin' && '(Админ)'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-700 pt-6 mt-8">
|
||||
<h3 className="text-lg font-semibold mb-4 text-red-400">Опасная зона</h3>
|
||||
<button
|
||||
onClick={deleteServer}
|
||||
className="bg-red-600 hover:bg-red-700 px-6 py-2 rounded flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Удалить сервер
|
||||
</button>
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
Это действие нельзя отменить. Все файлы сервера будут удалены.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/Stats.jsx
Normal file
91
frontend/src/components/Stats.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Cpu, HardDrive, Activity } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function Stats({ serverName, token }) {
|
||||
const [stats, setStats] = useState({
|
||||
status: 'stopped',
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
disk: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
const interval = setInterval(loadStats, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [serverName]);
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/servers/${serverName}/stats`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки статистики:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-gray-900">
|
||||
<h2 className="text-2xl font-bold mb-6">Статистика сервера</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">CPU</h3>
|
||||
<Cpu className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold mb-2">{stats.cpu}%</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(stats.cpu, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">ОЗУ</h3>
|
||||
<Activity className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold mb-2">{stats.memory} МБ</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min((stats.memory / 2048) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Диск</h3>
|
||||
<HardDrive className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold mb-2">{stats.disk} МБ</div>
|
||||
<div className="text-sm text-gray-400 mt-2">
|
||||
Использовано на диске
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 className="text-lg font-semibold mb-4">Статус</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full ${
|
||||
stats.status === 'running' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xl">
|
||||
{stats.status === 'running' ? 'Запущен' : 'Остановлен'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/ThemeSelector.jsx
Normal file
43
frontend/src/components/ThemeSelector.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Palette } from 'lucide-react';
|
||||
import { themes, getTheme } from '../themes';
|
||||
|
||||
export default function ThemeSelector({ currentTheme, onThemeChange }) {
|
||||
const theme = getTheme(currentTheme);
|
||||
|
||||
const themeColors = {
|
||||
dark: 'bg-gray-800',
|
||||
light: 'bg-gray-100',
|
||||
purple: 'bg-purple-600',
|
||||
blue: 'bg-blue-600',
|
||||
green: 'bg-green-600',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button className={`p-2 rounded-lg ${theme.hover} transition`}>
|
||||
<Palette className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className={`absolute right-0 mt-2 w-48 ${theme.secondary} rounded-lg shadow-xl ${theme.border} border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50`}>
|
||||
<div className="p-2">
|
||||
<div className={`text-xs ${theme.textSecondary} px-2 py-1 mb-1`}>Выберите тему</div>
|
||||
{Object.entries(themes).map(([key, themeItem]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onThemeChange(key)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg ${theme.hover} transition ${
|
||||
currentTheme === key ? theme.tertiary : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded ${themeColors[key]}`} />
|
||||
<span className="text-sm">{themeItem.name}</span>
|
||||
{currentTheme === key && (
|
||||
<span className="ml-auto text-xs text-green-500">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
frontend/src/components/Users.jsx
Normal file
183
frontend/src/components/Users.jsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Users as UsersIcon, Trash2, Shield, User } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function Users({ token }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [servers, setServers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [usersRes, serversRes] = await Promise.all([
|
||||
axios.get(`${API_URL}/api/users`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}),
|
||||
axios.get(`${API_URL}/api/servers`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
]);
|
||||
setUsers(usersRes.data);
|
||||
setServers(serversRes.data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleServerAccess = async (username, serverName) => {
|
||||
const user = users.find(u => u.username === username);
|
||||
const currentServers = user.servers || [];
|
||||
const newServers = currentServers.includes(serverName)
|
||||
? currentServers.filter(s => s !== serverName)
|
||||
: [...currentServers, serverName];
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_URL}/api/users/${username}/servers`,
|
||||
{ servers: newServers },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка обновления доступа');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRole = async (username, currentRole) => {
|
||||
const newRole = currentRole === 'admin' ? 'user' : 'admin';
|
||||
|
||||
if (!confirm(`Изменить роль пользователя ${username} на ${newRole}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_URL}/api/users/${username}/role`,
|
||||
{ role: newRole },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка изменения роли');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (username) => {
|
||||
if (!confirm(`Удалить пользователя ${username}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.delete(`${API_URL}/api/users/${username}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
loadData();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка удаления пользователя');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-400">Загрузка...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-gray-900 h-full overflow-y-auto">
|
||||
<h2 className="text-2xl font-bold mb-6 flex items-center gap-2">
|
||||
<UsersIcon className="w-8 h-8" />
|
||||
Управление пользователями
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.username}
|
||||
className="bg-gray-800 rounded-lg p-6 border border-gray-700"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded ${
|
||||
user.role === 'admin' ? 'bg-blue-600' : 'bg-gray-700'
|
||||
}`}>
|
||||
{user.role === 'admin' ? (
|
||||
<Shield className="w-6 h-6" />
|
||||
) : (
|
||||
<User className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{user.username}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
{user.role === 'admin' ? 'Администратор' : 'Пользователь'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => toggleRole(user.username, user.role)}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm"
|
||||
>
|
||||
{user.role === 'admin' ? 'Сделать пользователем' : 'Сделать админом'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteUser(user.username)}
|
||||
className="bg-red-600 hover:bg-red-700 p-2 rounded"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.role !== 'admin' && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 text-gray-400">
|
||||
Доступ к серверам:
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{servers.map((server) => {
|
||||
const hasAccess = user.servers?.includes(server.name);
|
||||
return (
|
||||
<button
|
||||
key={server.name}
|
||||
onClick={() => toggleServerAccess(user.username, server.name)}
|
||||
className={`px-3 py-1 rounded text-sm transition ${
|
||||
hasAccess
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{server.displayName}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{servers.length === 0 && (
|
||||
<p className="text-gray-500 text-sm">Нет доступных серверов</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<p className="text-sm text-gray-400">
|
||||
Администратор имеет доступ ко всем серверам
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user