Compare commits

...

49 Commits

Author SHA1 Message Date
fd84094aa4 Another attempt to fix .drone.yml...
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-22 10:37:05 +07:00
7eb2ea5662 An attempt to fix .drone.yml, deleted python-security stage
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-22 10:32:21 +07:00
de8ad67038 fixed drone.yml
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-21 19:34:36 +06:00
7b1fe32871 fixed drone.yml
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-21 19:30:53 +06:00
1a3fdf131c Fixed dockerfile
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-21 19:14:22 +06:00
fea553df3d fixed drone.yml
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-18 20:52:38 +06:00
b4b5bba562 fixed drone.yml
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-18 20:48:52 +06:00
49affe3891 fixed drone.yml
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-18 20:27:54 +06:00
e66ecbf178 fixed drone.yml
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-18 20:23:19 +06:00
d8f7f108c7 fixed drone.yml
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-18 20:18:54 +06:00
217804b011 fixed docker-compose file
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-18 20:06:58 +06:00
643987d211 fixed drone.yml
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-18 20:03:34 +06:00
5868c4014b fixed dockerfile
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-18 19:59:20 +06:00
e7b2216c72 fixed drone.yml
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-18 19:56:38 +06:00
e4bbf50725 fixed drone.yml
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-18 19:48:21 +06:00
0ed8039644 Fixed dockerfile
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-18 19:36:21 +06:00
4d9dfddd5d chore(docker): clean up docker-compose-simple.yml and remove backup file
All checks were successful
continuous-integration/drone/push Build is passing
- Remove inline comments from docker-compose-simple.yml for cleaner configuration
- Delete unused docker-compose.txt.backup file
- Remove unused volume definitions (servers-data, users-data)
- Add docker-compose-simple.yml to .gitignore to prevent accidental commits
- Simplify environment variable organization by removing comment separators
- Improve file maintainability by reducing comment clutter
2026-01-17 19:19:36 +06:00
9d19d49c9b chore(docker): clean up docker-compose formatting and whitespace
- Remove trailing whitespace from expose port configuration
- Consolidate environment variable declarations by removing extra blank lines
- Improve readability by standardizing spacing between configuration sections
- Maintain functional equivalence while improving code consistency
2026-01-17 19:18:20 +06:00
062984283a docs: Add comprehensive changelog for version 1.2.0
All checks were successful
continuous-integration/drone/push Build is passing
- Document all major changes including daemon system implementation
- Add migration guide for updating from previous versions
- Include deployment options and configuration details
- Document security improvements and performance optimizations
- Add troubleshooting and testing information

Major features added:
- Complete daemon system (like MCSManager)
- Nginx static file serving
- Enhanced authorization with role-based access
- Docker deployment improvements
- UI/UX enhancements
2026-01-17 11:22:07 +06:00
b781407334 feat: Add nginx configuration for static frontend serving
All checks were successful
continuous-integration/drone/push Build is passing
- Update docker-compose.yml to use nginx for static file serving
- Configure nginx to serve frontend static files and proxy API requests
- Add frontend-init container to copy static files to nginx volume
- Update nginx/default.conf with proper static file handling and gzip compression
- Add NGINX_SETUP.md documentation for nginx deployment
- Improve performance by separating static files from backend API

Changes:
- Frontend static files now served by nginx (better performance)
- Backend only handles API requests (port 8000, internal)
- Gzip compression and caching for static assets
- WebSocket support for console functionality
- Health check endpoint for monitoring
2026-01-17 11:18:21 +06:00
2d77f99e93 fix(docker): simplify deployment by removing nginx and exposing backend on port 80
All checks were successful
continuous-integration/drone/push Build is passing
- Remove nginx service from docker-compose.yml to eliminate configuration issues
- Expose backend directly on port 80 for direct access without reverse proxy
- Update BASE_URL and FRONTEND_URL environment variables to use port 80
- Add data volume mount for daemon storage at /app/data
- Add docker-compose.txt to .gitignore to exclude temporary files
- Add LINUX_DOCKER_FIX.md documentation with setup instructions and troubleshooting
- Simplify deployment configuration for Linux environments where nginx events section was causing startup failures
2026-01-17 10:56:53 +06:00
c0125f3962 fix(docker): simplify deployment with nginx fixes and alternative compose config
All checks were successful
continuous-integration/drone/push Build is passing
- Update nginx/default.conf with complete configuration including events section
- Add docker-compose-simple.yml for simplified deployment without nginx
- Update docker-compose.yml to properly mount nginx configuration
- Revise DOCKER_FIX.md with clearer instructions and recommended solutions
- Provide three deployment variants: with nginx, without nginx (recommended), and quick fix
- Include data folder structure and environment setup documentation
This change addresses nginx configuration errors and FileNotFoundError by providing both a corrected nginx setup and a simplified alternative deployment method without nginx for faster troubleshooting and deployment.
2026-01-17 10:50:00 +06:00
e02789ef53 fix(docker): resolve nginx and backend path configuration issues
All checks were successful
continuous-integration/drone/push Build is passing
- Add nginx/default.conf with simplified configuration to fix "no events section" error
- Update docker-compose.yml to mount nginx/default.conf instead of nginx.conf
- Fix backend/daemons.py data path from backend/data/daemons.json to data/daemons.json
- Improve users.json path detection with fallback logic in daemons.py
- Add servers directory to .gitignore
- Create DOCKER_FIX.md documentation with troubleshooting steps and solutions
- Ensure data directory is created automatically when backend starts
2026-01-17 10:32:46 +06:00
d188cec1f0 Added Daemon system and fixed interface
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-16 18:56:21 +06:00
fbfddf3c7a Changed design and bug fixes
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-16 15:40:14 +06:00
e6264efac6 Fixed drone.yml
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 22:21:14 +06:00
c840024e4a Fixed drone.yml
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-15 22:16:53 +06:00
fbb1356b13 Fixed drone.yml
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-15 22:08:28 +06:00
3a621b6d92 Fixed drone.yml
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-15 21:41:14 +06:00
ca7882b84a Fixed Dockerfile
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-15 21:22:13 +06:00
66ece236f9 Merge branch 'main' of https://git.nevetime.ru/Arkon/NeveTime-Panel--MC-Panel-
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-15 20:58:16 +06:00
6d80ef7200 Fixed drone.yml 2026-01-15 20:57:51 +06:00
07df32dda8 Изменён адрес реестра изображений
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-15 20:56:14 +07:00
7aa13ba01c Deleted unnecessary text 2026-01-15 20:43:43 +07:00
1985a25ea8 Removed unused .md files 2026-01-15 19:38:21 +06:00
112123b0ff Change drone.yml 2026-01-15 19:25:42 +06:00
d25d7fc2f9 Merge branch 'main' of https://git.nevetime.ru/Arkon/NeveTime-Panel--MC-Panel- 2026-01-15 19:00:59 +06:00
551d733dc4 Added Role Owner and new UI for Owner 2026-01-15 19:00:09 +06:00
6bff125c2a Обновить README.md 2026-01-15 16:19:55 +07:00
77b857d1d1 Обновить README.md 2026-01-15 16:14:59 +07:00
9a1e2df04d Added Dockerfile 2026-01-15 15:08:33 +06:00
8edd7131a2 Add Notification and new mini desing 2026-01-15 13:26:04 +06:00
303d38f28e Add SSO 2026-01-15 09:32:13 +06:00
14f020e819 Added user account overview for admins 2026-01-14 22:36:59 +06:00
1eaba59f0f Add Banned role 2026-01-14 22:13:07 +06:00
011996d78d Add Personal account 2026-01-14 21:54:24 +06:00
db2eddca4b Add Ticket and add Role Support 2026-01-14 21:26:23 +06:00
cf131bb04e Add System Ticket 2026-01-14 21:05:22 +06:00
f0a4ad177e Remove account admin data 2026-01-14 20:44:25 +06:00
76 changed files with 8792 additions and 2443 deletions

113
.dockerignore Normal file
View File

@@ -0,0 +1,113 @@
# Git
.git
.gitignore
.gitattributes
# CI/CD
.drone.yml*
.github
# Documentation
*.md
!README.md
CHANGELOG.md
LICENSE
ОБНОВЛЕНИЯ.md
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Node.js
frontend/node_modules
frontend/.vite
frontend/npm-debug.log*
frontend/yarn-debug.log*
frontend/yarn-error.log*
# Python
backend/__pycache__
backend/*.pyc
backend/*.pyo
backend/*.pyd
backend/.Python
backend/env
backend/venv
backend/.venv
backend/pip-log.txt
backend/pip-delete-this-directory.txt
backend/.pytest_cache
backend/.coverage
backend/htmlcov
daemon/__pycache__
# Data (будет монтироваться как volume)
backend/servers/*
!backend/servers/.gitkeep
backend/data/*
!backend/data/.gitkeep
# Logs
*.log
logs/*
!logs/.gitkeep
# Environment files
.env
.env.local
.env.*.local
backend/.env*
frontend/.env*
daemon/.env*
# Docker
docker-compose.yml
docker-compose.*.yml
.dockerignore
docker-start.bat
docker-stop.bat
# Tests
tests
test
*.test.js
*.spec.js
__tests__
# Build artifacts
build
dist
*.egg-info
.pytest_cache
# Temporary files
tmp
temp
*.tmp
*.temp
# Batch files (Windows specific)
*.bat
# Postman collections
*.postman_collection.json
# Nginx configs (handled separately)
nginx/*
# Keys and certificates
*.key
*.pem
*.crt
*.p12
# Backup files
*.bak
*.backup

160
.drone.yml Normal file
View File

@@ -0,0 +1,160 @@
kind: pipeline
type: docker
name: code-quality
trigger:
event:
- push
- pull_request
steps:
- name: python-lint
image: python:3.11-slim
commands:
- cd backend
- pip install flake8
- flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- name: frontend-lint
image: node:20-alpine
commands:
- cd frontend
- npm ci --silent
- npm run lint || echo "ESLint warnings found"
- name: frontend-security
image: node:20-alpine
commands:
- cd frontend
- npm ci --silent
- npm audit --audit-level=moderate || echo "Security warnings found"
---
kind: pipeline
type: docker
name: build-backend
trigger:
branch:
- main
- master
- develop
event:
- push
- tag
depends_on:
- code-quality
steps:
- name: build-backend-image
image: plugins/docker
settings:
registry: registry.nevetime.ru
repo: registry.nevetime.ru/mc-panel-backend
tags:
- latest
- "${DRONE_COMMIT_SHA:0:8}"
- "${DRONE_BRANCH}"
auto_tag: true
dockerfile: backend/Dockerfile
context: backend
username:
from_secret: docker_username
password:
from_secret: docker_password
build_args:
- BUILD_DATE=${DRONE_BUILD_CREATED}
- VCS_REF=${DRONE_COMMIT_SHA}
- VERSION=${DRONE_TAG:-${DRONE_BRANCH}}
when:
event:
- push
- tag
---
kind: pipeline
type: docker
name: build-frontend
trigger:
branch:
- main
- master
- develop
event:
- push
- tag
depends_on:
- code-quality
steps:
- name: build-frontend-image
image: plugins/docker
settings:
registry: registry.nevetime.ru
repo: registry.nevetime.ru/mc-panel-frontend
tags:
- latest
- "${DRONE_COMMIT_SHA:0:8}"
- "${DRONE_BRANCH}"
auto_tag: true
dockerfile: frontend/Dockerfile
context: frontend
target: production
username:
from_secret: docker_username
password:
from_secret: docker_password
build_args:
- BUILD_DATE=${DRONE_BUILD_CREATED}
- VCS_REF=${DRONE_COMMIT_SHA}
- VERSION=${DRONE_TAG:-${DRONE_BRANCH}}
when:
event:
- push
- tag
---
kind: pipeline
type: docker
name: build-monolith
trigger:
branch:
- main
- master
- develop
event:
- push
- tag
depends_on:
- code-quality
steps:
- name: build-monolith-image
image: plugins/docker
settings:
registry: registry.nevetime.ru
repo: registry.nevetime.ru/mc-panel
tags:
- latest
- "${DRONE_COMMIT_SHA:0:8}"
- "${DRONE_BRANCH}"
auto_tag: true
dockerfile: Dockerfile
context: .
username:
from_secret: docker_username
password:
from_secret: docker_password
build_args:
- BUILD_DATE=${DRONE_BUILD_CREATED}
- VCS_REF=${DRONE_COMMIT_SHA}
- VERSION=${DRONE_TAG:-${DRONE_BRANCH}}
when:
event:
- push
- tag

128
.drone.yml.with-trivy Normal file
View File

@@ -0,0 +1,128 @@
---
kind: pipeline
type: docker
name: code-quality
# Триггеры для пайплайна проверки качества
trigger:
event:
- push
- pull_request
steps:
# Проверка качества Python кода (только критические ошибки)
- name: python-lint
image: python:3.11-slim
commands:
- cd backend
- pip install flake8
- echo "Running flake8 (critical errors only)..."
- flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- echo "✅ Critical checks passed"
# Проверка качества JavaScript/React кода (опционально)
- name: frontend-lint
image: node:18-alpine
commands:
- cd frontend
- npm ci
- echo "Running ESLint (non-blocking)..."
- npm run lint || echo "⚠️ ESLint warnings found (non-blocking)"
- echo "✅ Frontend checks completed"
# Проверка безопасности зависимостей Python (опционально)
- name: python-security
image: python:3.11-slim
commands:
- cd backend
- pip install safety
- echo "Checking for known security vulnerabilities..."
- safety check --file=requirements.txt --exit-zero || echo "⚠️ Security warnings found (non-blocking)"
- echo "✅ Security checks completed"
# Проверка безопасности зависимостей Node.js
- name: frontend-security
image: node:18-alpine
commands:
- cd frontend
- npm ci
- echo "Running npm audit..."
- npm audit --audit-level=moderate || true
---
kind: pipeline
type: docker
name: build-and-publish
# Триггеры для пайплайна сборки
trigger:
event:
- push
- tag
branch:
- main
- master
- develop
# Зависимость от пайплайна проверки качества
depends_on:
- code-quality
steps:
# Сборка и публикация Docker образа
- name: build-and-push
image: plugins/docker
settings:
# Настройки реестра
registry: registry.nevetime.ru
repo: registry.nevetime.ru/mc-panel
# Теги для образа
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_BRANCH}
# Автоматическое тегирование при push тега
auto_tag: true
auto_tag_suffix: ${DRONE_BUILD_NUMBER}
# Dockerfile
dockerfile: Dockerfile
context: .
# Учетные данные (настройте в Drone secrets)
username:
from_secret: docker_username
password:
from_secret: docker_password
# Build args (опционально)
build_args:
- BUILD_DATE=${DRONE_BUILD_CREATED}
- VCS_REF=${DRONE_COMMIT_SHA}
- VERSION=${DRONE_TAG:-${DRONE_BRANCH}}
when:
event:
- push
- tag
# Сканирование образа на уязвимости (с авторизацией)
- name: scan-image
image: aquasec/trivy
environment:
TRIVY_USERNAME:
from_secret: docker_username
TRIVY_PASSWORD:
from_secret: docker_password
commands:
- echo "Scanning image for vulnerabilities..."
- trivy image --exit-code 0 --severity HIGH,CRITICAL --username $TRIVY_USERNAME --password $TRIVY_PASSWORD registry.nevetime.ru/mc-panel:${DRONE_COMMIT_SHA:0:8}
- echo "✅ Security scan completed"
when:
event:
- push
- tag
depends_on:
- build-and-push

5
.gitignore vendored
View File

@@ -16,6 +16,8 @@ dist/
# Servers
backend/servers/
backend/.env.exemple
servers
# IDE
.vscode/
@@ -34,3 +36,6 @@ frontend/.env.production.local
# Build
frontend/dist/
backend/build/
backend/users1.json.backup
docker-compose.txt
docker-compose-simple.yml

View File

@@ -1,25 +0,0 @@
# Применение исправлений
## Что исправлено
### ✅ Фронтенд компоненты
Все компоненты обновлены для передачи токена:
- `Console.jsx`
- `Stats.jsx`
- `FileManager.jsx`
- `ServerSettings.jsx`
- `CreateServerModal.jsx`
### ⚠️ Нужно обновить вручную
#### 1. App.jsx (или App_final.jsx)
Найдите строку:
```jsx
{user?.role === 'admin' && (
<button
onClick={() => setShowCreateModal(true)}
className="bg-blue-600 hover:bg-blue-700 p-2 rounded"
title="Создать сервер"
>
<Plus className="w-4

View File

@@ -1,196 +0,0 @@
# Настройка системы авторизации
## Что добавлено
1. **Система авторизации** - вход и регистрация пользователей
2. **Роли пользователей** - администраторы и обычные пользователи
3. **Управление доступом** - админы могут выдавать доступ к серверам
4. **JWT токены** - безопасная авторизация
## Первый запуск
### 1. Установите новые зависимости
```bash
cd backend
pip install -r requirements.txt
```
Новые библиотеки:
- `passlib[bcrypt]` - хеширование паролей
- `python-jose[cryptography]` - JWT токены
### 2. Переименуйте файл бэкенда
**ВАЖНО:** Удалите старый `backend/main.py` и переименуйте `backend/main_new.py` в `backend/main.py`
```bash
cd backend
del main.py
ren main_new.py main.py
```
Или вручную в проводнике Windows.
### 3. Запустите бэкенд
```bash
cd backend
python main.py
```
При первом запуске создастся пользователь по умолчанию:
- **Логин:** admin
- **Пароль:** admin
### 4. Запустите фронтенд
```bash
cd frontend
npm run dev
```
## Использование
### Первый вход
1. Откройте http://localhost:3000
2. Войдите как `admin` / `admin`
3. **ВАЖНО:** Смените пароль администратора!
### Создание пользователей
1. Нажмите кнопку **"Пользователи"** в шапке
2. Новые пользователи могут зарегистрироваться самостоятельно
3. По умолчанию новые пользователи получают роль "Пользователь"
### Управление доступом к серверам
1. Перейдите в **"Пользователи"**
2. Найдите нужного пользователя
3. Нажмите на названия серверов чтобы выдать/забрать доступ
4. Зеленые кнопки = доступ есть
5. Серые кнопки = доступа нет
### Роли пользователей
**Администратор:**
- Видит все серверы
- Может создавать/удалять серверы
- Может управлять пользователями
- Может изменять настройки серверов
**Пользователь:**
- Видит только серверы с доступом
- Может запускать/останавливать свои серверы
- Может управлять файлами своих серверов
- Не может создавать серверы
- Не может изменять настройки
### Изменение роли
1. Перейдите в **"Пользователи"**
2. Нажмите **"Сделать админом"** или **"Сделать пользователем"**
3. Подтвердите действие
### Удаление пользователя
1. Перейдите в **"Пользователи"**
2. Нажмите кнопку с иконкой корзины
3. Подтвердите удаление
**Примечание:** Нельзя удалить самого себя или изменить свою роль.
## Безопасность
### Смена секретного ключа
Откройте `backend/main_new.py` (или `main.py` после переименования) и измените:
```python
SECRET_KEY = "your-secret-key-change-this-in-production-12345"
```
На случайную строку, например:
```python
SECRET_KEY = "super-secret-key-" + str(uuid.uuid4())
```
### Время жизни токена
По умолчанию токен действует 7 дней. Чтобы изменить:
```python
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 дней
```
### Хранение паролей
Пароли хешируются с помощью bcrypt и хранятся в файле `backend/users.json`.
**Не удаляйте этот файл!** Иначе потеряете всех пользователей.
## Файлы
- `backend/users.json` - база пользователей
- `backend/main_new.py` - новый бэкенд с авторизацией
- `frontend/src/components/Auth.jsx` - форма входа/регистрации
- `frontend/src/components/Users.jsx` - управление пользователями
## API эндпоинты
### Авторизация
- `POST /api/auth/register` - регистрация
- `POST /api/auth/login` - вход
- `GET /api/auth/me` - информация о текущем пользователе
### Пользователи (только админы)
- `GET /api/users` - список пользователей
- `PUT /api/users/{username}/servers` - изменить доступ к серверам
- `PUT /api/users/{username}/role` - изменить роль
- `DELETE /api/users/{username}` - удалить пользователя
### Серверы (с проверкой доступа)
Все существующие эндпоинты теперь требуют токен в заголовке:
```
Authorization: Bearer <token>
```
## Troubleshooting
### Ошибка "Требуется авторизация"
Токен истек или недействителен. Выйдите и войдите заново.
### Не могу войти как admin
Удалите файл `backend/users.json` и перезапустите бэкенд. Создастся новый админ с паролем `admin`.
### Забыл пароль
Удалите файл `backend/users.json` - все пользователи будут удалены и создастся новый админ.
Или отредактируйте `users.json` вручную, удалив нужного пользователя.
### Пользователь не видит серверы
Проверьте что админ выдал ему доступ в разделе "Пользователи".
## Миграция со старой версии
Если у вас уже есть серверы:
1. Сделайте backup папки `backend/servers/`
2. Установите новые зависимости
3. Замените `main.py` на новый
4. Запустите бэкенд
5. Войдите как admin/admin
6. Все серверы будут доступны админу автоматически
7. Создайте пользователей и выдайте им доступ
---
**Готово!** Теперь у вас есть полноценная система авторизации и управления доступом! 🔐

145
BUGFIX.md
View File

@@ -1,145 +0,0 @@
# Исправление багов
## Исправленные проблемы
### 1. ✅ Обычные пользователи теперь могут создавать серверы
**Что изменилось:**
- Убрана проверка роли при создании сервера
- Любой авторизованный пользователь может создать сервер
- При создании сервера обычным пользователем, ему автоматически выдается доступ к этому серверу
- Кнопка "+" теперь видна всем пользователям
**Файлы:**
- `backend/main_new.py` - убрана проверка `if user["role"] != "admin"`
- `frontend/src/App_final.jsx` - кнопка создания доступна всем
### 2. ✅ Админ теперь может просматривать файлы, статистику, настройки и консоль
**Проблема:**
Компоненты не передавали токен авторизации в запросы к API.
**Что исправлено:**
- Все компоненты теперь принимают prop `token`
- Все запросы к API включают заголовок `Authorization: Bearer ${token}`
**Исправленные компоненты:**
- `Console.jsx` - добавлен токен в запросы команд
- `Stats.jsx` - добавлен токен в запросы статистики
- `FileManager.jsx` - добавлен токен во все файловые операции
- `ServerSettings.jsx` - добавлен токен в настройки
- `CreateServerModal.jsx` - добавлен токен при создании
## Что нужно сделать
### Если вы еще не переименовали файлы:
1. **Удалите старые файлы:**
```
backend/main.py (если есть)
frontend/src/App.jsx (если есть)
```
2. **Переименуйте новые файлы:**
```
backend/main_new.py → backend/main.py
frontend/src/App_final.jsx → frontend/src/App.jsx
```
3. **Перезапустите панель:**
```bash
START_PANEL.bat
```
### Если файлы уже переименованы:
Просто перезапустите панель - изменения уже применены в `main_new.py` и `App_final.jsx`.
## Проверка исправлений
### Тест 1: Создание сервера обычным пользователем
1. Зарегистрируйте нового пользователя
2. Войдите под ним
3. Нажмите кнопку "+" в списке серверов
4. Создайте сервер
5. ✅ Сервер должен появиться в списке
### Тест 2: Просмотр файлов админом
1. Войдите как admin
2. Выберите любой сервер
3. Перейдите на вкладку "Файлы"
4. ✅ Должен отобразиться список файлов
### Тест 3: Просмотр статистики
1. Выберите сервер
2. Перейдите на вкладку "Статистика"
3. ✅ Должна отобразиться статистика (CPU, RAM, Disk)
### Тест 4: Консоль
1. Запустите сервер
2. Перейдите на вкладку "Консоль"
3. ✅ Должны появиться логи сервера
4. Отправьте команду (например, "list")
5. ✅ Команда должна выполниться
### Тест 5: Настройки
1. Перейдите на вкладку "Настройки"
2. ✅ Должны отобразиться настройки сервера
3. Измените что-нибудь и сохраните
4. ✅ Изменения должны сохраниться
## Дополнительные улучшения
### Автоматический доступ к созданным серверам
Теперь когда обычный пользователь создает сервер:
1. Сервер создается
2. Пользователю автоматически выдается доступ к этому серверу
3. Сервер сразу появляется в его списке
Админу не нужно вручную выдавать доступ!
### Логирование ошибок
Все ошибки API теперь выводятся в консоль браузера (F12) для отладки.
## Если что-то не работает
### Ошибка "Требуется авторизация"
**Причина:** Токен не передается в запросах
**Решение:**
1. Убедитесь что используете обновленные файлы
2. Очистите кэш браузера (Ctrl+Shift+Delete)
3. Выйдите и войдите заново
### Ошибка "Нет доступа к этому серверу"
**Причина:** У пользователя нет прав на сервер
**Решение:**
1. Если вы админ - проверьте что сервер существует
2. Если вы пользователь - попросите админа выдать доступ
3. Или создайте свой сервер - доступ выдастся автоматически
### Пустой список файлов
**Причина:** Токен не передается или сервер пустой
**Решение:**
1. Проверьте консоль браузера (F12) на ошибки
2. Убедитесь что используете обновленный FileManager.jsx
3. Загрузите файлы через кнопку "Загрузить"
---
**Готово!** Все баги исправлены. Теперь:
- ✅ Любой пользователь может создавать серверы
- ✅ Админ может просматривать все вкладки
-Все запросы включают токен авторизации

View File

@@ -1,155 +0,0 @@
# Руководство по отладке проблем
## Проблема: После запуска сервера пропадают файлы/настройки/статистика
### Причина
Процесс сервера блокирует выполнение или завершается с ошибкой.
### Диагностика
1. **Проверьте логи бэкенда** (терминал где запущен `python main.py`):
```
Сервер test_server запущен с PID 12345
Начало чтения вывода для сервера test_server
```
2. **Проверьте консоль браузера** (F12):
```javascript
// Должны быть логи:
Сервер запущен: {message: "Сервер запущен", pid: 12345}
```
3. **Проверьте, запустился ли Java процесс**:
```bash
# Windows
tasklist | findstr java
# Должен показать процесс java.exe
```
4. **Проверьте наличие server.jar**:
- Откройте папку `backend/servers/ИМЯ_СЕРВЕРА/`
- Убедитесь, что там есть файл `server.jar` или другой .jar файл
- Проверьте команду запуска в настройках сервера
### Решение
#### Если server.jar отсутствует:
1. Скачайте server.jar для Minecraft
2. Загрузите через менеджер файлов в панели
3. Убедитесь, что команда запуска правильная
#### Если Java не установлена:
1. Установите Java 17 или новее
2. Проверьте установку:
```bash
java -version
```
#### Если процесс запускается но сразу завершается:
1. Проверьте логи в консоли панели
2. Возможно нужно принять EULA:
- Откройте файл `eula.txt` через редактор в панели
- Измените `eula=false` на `eula=true`
- Сохраните и перезапустите сервер
#### Если команда запуска неправильная:
1. Перейдите в Настройки сервера
2. Измените команду запуска, например:
```
java -Xmx2G -Xms1G -jar server.jar nogui
```
3. Сохраните настройки
4. Запустите сервер
## Проблема: Сервер не останавливается
### Причина
Процесс не отвечает на команду stop.
### Решение
1. **Через панель**: Подождите 30 секунд, процесс будет принудительно завершен
2. **Вручную через Task Manager**:
- Откройте Диспетчер задач (Ctrl+Shift+Esc)
- Найдите процесс `java.exe`
- Завершите процесс
- Обновите страницу панели
## Проблема: Консоль не показывает логи
### Причина
WebSocket не подключается или процесс не выводит логи.
### Диагностика
1. **Проверьте консоль браузера**:
```
WebSocket подключен
```
2. **Проверьте логи бэкенда**:
```
WebSocket подключен для сервера: test_server
Отправка X существующих логов
```
### Решение
1. Перезапустите сервер
2. Обновите страницу панели (F5)
3. Проверьте, что сервер действительно запущен
## Проблема: Статистика показывает неправильный статус
### Причина
Процесс завершился, но панель не обновилась.
### Решение
1. Обновите страницу (F5)
2. Статус обновляется автоматически каждые 5 секунд
3. Проверьте логи бэкенда на наличие ошибок
## Полезные команды для отладки
### Проверка портов
```bash
# Windows
netstat -ano | findstr :8000
netstat -ano | findstr :3000
```
### Проверка процессов Java
```bash
# Windows
tasklist | findstr java
# Убить все процессы Java (ОСТОРОЖНО!)
taskkill /F /IM java.exe
```
### Очистка и перезапуск
1. Остановите все серверы в панели
2. Закройте бэкенд (Ctrl+C)
3. Закройте фронтенд (Ctrl+C)
4. Убейте все процессы Java если нужно
5. Запустите бэкенд заново
6. Запустите фронтенд заново
7. Обновите страницу в браузере
## Логи для отправки при обращении за помощью
Если проблема не решается, соберите следующую информацию:
1. **Логи бэкенда** (последние 50 строк из терминала)
2. **Консоль браузера** (F12 → Console, скриншот или текст)
3. **Network вкладка** (F12 → Network, покажите неудачные запросы)
4. **Содержимое папки сервера** (список файлов)
5. **Команда запуска** из настроек сервера

191
DOCKER_SEPARATE_README.md Normal file
View File

@@ -0,0 +1,191 @@
# MC Panel - Separate Docker Services
## Обзор
Теперь у вас есть отдельные Dockerfile для backend и frontend, что обеспечивает:
- **Лучшую изоляцию** - каждый сервис в своем контейнере
- **Независимое масштабирование** - можно масштабировать backend и frontend отдельно
- **Гибкость деплоя** - можно деплоить сервисы на разные серверы
- **Оптимизацию ресурсов** - каждый контейнер оптимизирован под свою задачу
## Структура файлов
```
mc-panel/
├── backend/
│ └── Dockerfile # Backend (FastAPI + Python)
├── frontend/
│ └── Dockerfile # Frontend (React + Nginx)
├── docker-compose.separate.yml # Production с отдельными сервисами
├── docker-compose.dev.yml # Development с hot reload
├── start-separate.bat # Запуск production
└── start-dev.bat # Запуск development
```
## Backend Dockerfile
**Особенности:**
- Базовый образ: `python:3.11-slim`
- Пользователь: `mcpanel` (UID/GID 1000)
- Порт: 8000
- Health check: `/health` endpoint
- Volumes: `/app/servers`, `/app/data`, `/app/logs`
- Init процесс: `tini`
**Команда сборки:**
```bash
cd backend
docker build -t mc-panel-backend .
```
## Frontend Dockerfile
**Multi-stage сборка:**
### Stage 1: Builder
- Базовый образ: `node:20-alpine`
- Собирает React приложение
- Оптимизирует статические файлы
### Stage 2: Production
- Базовый образ: `nginx:alpine`
- Служит статические файлы
- Проксирует API запросы к backend
- Поддерживает React Router (SPA)
### Stage 3: Development
- Базовый образ: `node:20-alpine`
- Vite dev server с hot reload
- Порт: 5173
**Команда сборки:**
```bash
cd frontend
# Production
docker build --target production -t mc-panel-frontend .
# Development
docker build --target development -t mc-panel-frontend-dev .
```
## Способы запуска
### 1. Production (отдельные сервисы)
```bash
# Windows
start-separate.bat
# Linux/macOS
docker-compose -f docker-compose.separate.yml up --build -d
```
**Доступ:**
- Frontend: http://localhost
- Backend API: http://localhost:8000
### 2. Development (с hot reload)
```bash
# Windows
start-dev.bat
# Linux/macOS
docker-compose -f docker-compose.dev.yml up --build -d
```
**Доступ:**
- Frontend Dev: http://localhost:5173
- Backend API: http://localhost:8000
### 3. Оригинальный (монолитный)
```bash
# Windows
docker-start.bat
# Linux/macOS
docker-compose up --build -d
```
## Конфигурация Nginx (Frontend)
Frontend Dockerfile включает оптимизированную конфигурацию Nginx:
- **Gzip сжатие** для статических файлов
- **Кеширование** статических ресурсов (1 год)
- **Security headers** (XSS, CSRF защита)
- **SPA поддержка** (React Router)
- **API прокси** на backend:8000
- **WebSocket прокси** для real-time функций
## Переменные окружения
### Backend
```env
PORT=8000
WORKERS=2
PYTHONPATH=/app
DEBUG=false
LOG_LEVEL=INFO
```
### Frontend (Development)
```env
VITE_API_URL=http://localhost:8000
```
## Volumes
### Production
- `mc_servers` - серверы Minecraft
- `mc_data` - данные приложения
- `mc_logs` - логи
### Development
- `mc_servers_dev` - серверы (dev)
- `mc_data_dev` - данные (dev)
- `mc_logs_dev` - логи (dev)
## Полезные команды
### Логи
```bash
# Production
docker-compose -f docker-compose.separate.yml logs -f backend
docker-compose -f docker-compose.separate.yml logs -f frontend
# Development
docker-compose -f docker-compose.dev.yml logs -f backend-dev
docker-compose -f docker-compose.dev.yml logs -f frontend-dev
```
### Остановка
```bash
# Production
docker-compose -f docker-compose.separate.yml down
# Development
docker-compose -f docker-compose.dev.yml down
```
### Пересборка
```bash
# Production
docker-compose -f docker-compose.separate.yml build --no-cache
# Development
docker-compose -f docker-compose.dev.yml build --no-cache
```
## Преимущества отдельных сервисов
1. **Масштабирование**: можно запустить несколько backend инстансов
2. **Обновления**: можно обновлять frontend и backend независимо
3. **Мониторинг**: отдельные метрики для каждого сервиса
4. **Безопасность**: изоляция сервисов
5. **Разработка**: разные команды могут работать независимо
## Рекомендации
- **Для разработки**: используйте `docker-compose.dev.yml`
- **Для тестирования**: используйте `docker-compose.separate.yml`
- **Для production**: рассмотрите Kubernetes или Docker Swarm
- **Для CI/CD**: можно собирать образы отдельно и деплоить независимо

View File

@@ -1,163 +0,0 @@
# Финальные шаги для запуска панели с авторизацией
## Шаг 1: Переименуйте файлы
### Backend
1. Откройте папку `backend`
2. Удалите файл `main.py` (если есть)
3. Переименуйте `main_new.py` в `main.py`
### Frontend
1. Откройте папку `frontend/src`
2. Удалите файл `App.jsx` (если есть)
3. Переименуйте `App_final.jsx` в `App.jsx`
## Шаг 2: Установите зависимости
```bash
cd backend
pip install -r requirements.txt
```
Новые зависимости:
- passlib[bcrypt] - для хеширования паролей
- python-jose[cryptography] - для JWT токенов
## Шаг 3: Запустите панель
### Вариант 1: Автоматический запуск
```bash
START_PANEL.bat
```
### Вариант 2: Ручной запуск
**Терминал 1 - Бэкенд:**
```bash
cd backend
python main.py
```
**Терминал 2 - Фронтенд:**
```bash
cd frontend
npm run dev
```
## Шаг 4: Первый вход
1. Откройте http://localhost:3000
2. Войдите с учетными данными:
- **Логин:** admin
- **Пароль:** admin
3. Вы попадете в панель управления
## Что нового
### ✅ Система авторизации
- Вход и регистрация пользователей
- JWT токены для безопасности
- Автоматический выход при истечении токена
### ✅ Роли пользователей
- **Администратор** - полный доступ ко всем функциям
- **Пользователь** - доступ только к назначенным серверам
### ✅ Управление пользователями
- Кнопка "Пользователи" в шапке (только для админов)
- Выдача/отзыв доступа к серверам
- Изменение ролей пользователей
- Удаление пользователей
### ✅ Контроль доступа
- Пользователи видят только свои серверы
- Админы видят все серверы
- Проверка прав на каждое действие
## Использование
### Создание нового пользователя
**Вариант 1: Регистрация**
1. На странице входа нажмите "Регистрация"
2. Введите логин и пароль
3. Новый пользователь создастся с ролью "Пользователь"
**Вариант 2: Админ создает**
1. Попросите пользователя зарегистрироваться
2. Админ выдает ему доступ к нужным серверам
### Выдача доступа к серверу
1. Войдите как администратор
2. Нажмите кнопку "Пользователи"
3. Найдите нужного пользователя
4. Нажмите на название сервера (станет зеленым)
5. Пользователь сразу увидит этот сервер
### Изменение роли
1. В разделе "Пользователи"
2. Нажмите "Сделать админом" или "Сделать пользователем"
3. Подтвердите действие
## Безопасность
### Смените секретный ключ!
Откройте `backend/main.py` и измените:
```python
SECRET_KEY = "your-secret-key-change-this-in-production-12345"
```
На случайную строку длиной минимум 32 символа.
### Смените пароль администратора
1. Войдите как admin
2. Создайте нового администратора с другим паролем
3. Войдите под новым админом
4. Удалите старого admin
## Файлы данных
- `backend/users.json` - база пользователей (НЕ УДАЛЯЙТЕ!)
- `backend/servers/` - папки серверов
- `backend/servers/*/panel_config.json` - настройки каждого сервера
## Troubleshooting
### "Требуется авторизация"
Токен истек. Выйдите и войдите заново.
### Не могу войти
Удалите `backend/users.json` и перезапустите бэкенд. Создастся новый admin/admin.
### Пользователь не видит серверы
Проверьте что админ выдал ему доступ в разделе "Пользователи".
### Ошибка импорта passlib или jose
Установите зависимости:
```bash
pip install passlib[bcrypt] python-jose[cryptography]
```
## Доступ через сеть
Всё работает так же как раньше:
1. Узнайте ваш IP в Radmin VPN: `ipconfig`
2. Друг открывает: `http://ВАШ_IP:3000`
3. Друг регистрируется
4. Вы выдаете ему доступ к нужным серверам
## Готово!
Теперь у вас полноценная панель управления с:
- ✅ Авторизацией и регистрацией
- ✅ Ролями и правами доступа
- ✅ Управлением пользователями
- ✅ Контролем доступа к серверам
- ✅ Всеми предыдущими функциями
Подробнее см. `AUTH_SETUP.md`

View File

@@ -1,187 +0,0 @@
# ✅ Установка завершена!
## Что было создано
### Backend (FastAPI)
- ✅ Система авторизации с JWT токенами
- ✅ Управление пользователями и ролями
- ✅ Контроль доступа к серверам
- ✅ API для всех операций с серверами
- ✅ WebSocket для консоли в реальном времени
- ✅ Файловый менеджер с редактором
- ✅ Мониторинг ресурсов
### Frontend (React)
- ✅ Форма входа и регистрации
- ✅ Управление пользователями (для админов)
- ✅ Панель управления серверами
- ✅ Консоль с логами
- ✅ Файловый менеджер
- ✅ Статистика ресурсов
- ✅ Настройки серверов
## Финальные шаги
### 1. Переименуйте файлы
**Backend:**
```
backend/main_new.py → backend/main.py
```
**Frontend:**
```
frontend/src/App_final.jsx → frontend/src/App.jsx
```
### 2. Установите зависимости
```bash
cd backend
pip install -r requirements.txt
```
Новые зависимости:
- `passlib[bcrypt]` - хеширование паролей
- `python-jose[cryptography]` - JWT токены
### 3. Запустите панель
**Автоматически:**
```bash
START_PANEL.bat
```
**Вручную:**
```bash
# Терминал 1
cd backend
python main.py
# Терминал 2
cd frontend
npm run dev
```
### 4. Первый вход
1. Откройте http://localhost:3000
2. Войдите:
- Логин: `admin`
- Пароль: `admin`
## Основные функции
### Для администраторов
1. **Создание серверов** - кнопка "+" в боковой панели
2. **Управление пользователями** - кнопка "Пользователи" в шапке
3. **Выдача доступа** - нажимайте на названия серверов в карточке пользователя
4. **Изменение ролей** - кнопка "Сделать админом/пользователем"
5. **Настройки серверов** - вкладка "Настройки"
### Для пользователей
1. **Просмотр своих серверов** - только те, к которым есть доступ
2. **Запуск/остановка** - кнопки на карточке сервера
3. **Консоль** - просмотр логов и отправка команд
4. **Файлы** - управление файлами сервера
5. **Статистика** - мониторинг ресурсов
## Безопасность
### ⚠️ ВАЖНО: Смените секретный ключ!
Откройте `backend/main.py` и измените:
```python
SECRET_KEY = "your-secret-key-change-this-in-production-12345"
```
На случайную строку минимум 32 символа.
### Смените пароль администратора
1. Создайте нового администратора
2. Войдите под ним
3. Удалите старого admin
## Доступ через сеть (Radmin VPN)
### На вашем компьютере:
1. Узнайте IP: `ipconfig` (ищите Radmin VPN, обычно 26.x.x.x)
2. Запустите панель
3. Откройте: http://localhost:3000
### На компьютере друга:
1. Откройте: http://ВАШ_IP:3000
2. Зарегистрируйтесь
3. Попросите вас выдать доступ к серверам
### Откройте порты (если не работает):
```powershell
netsh advfirewall firewall add rule name="MC Panel Backend" dir=in action=allow protocol=TCP localport=8000
netsh advfirewall firewall add rule name="MC Panel Frontend" dir=in action=allow protocol=TCP localport=3000
```
## Структура файлов
```
mc-panel/
├── START_PANEL.bat # Автозапуск
├── FINAL_STEPS.md # Инструкция
├── AUTH_SETUP.md # Руководство по авторизации
├── backend/
│ ├── main_new.py # Новый бэкенд (переименуйте в main.py)
│ ├── requirements.txt # Зависимости
│ ├── users.json # База пользователей (создастся автоматически)
│ └── servers/ # Папки серверов
└── frontend/
├── src/
│ ├── App_final.jsx # Новый App (переименуйте в App.jsx)
│ └── components/
│ ├── Auth.jsx # Форма входа
│ ├── Users.jsx # Управление пользователями
│ ├── Console.jsx # Консоль (обновлен)
│ ├── FileManager.jsx # Файлы (обновлен)
│ ├── Stats.jsx # Статистика (обновлен)
│ └── ...
└── package.json
```
## Troubleshooting
### Ошибка импорта passlib или jose
```bash
pip install passlib[bcrypt] python-jose[cryptography]
```
### Не могу войти
Удалите `backend/users.json` и перезапустите бэкенд.
### Пользователь не видит серверы
Админ должен выдать доступ в разделе "Пользователи".
### Токен истек
Выйдите и войдите заново.
## Документация
- `FINAL_STEPS.md` - пошаговая инструкция
- `AUTH_SETUP.md` - полное руководство по авторизации
- `QUICK_START.md` - быстрый старт
- `DEBUG_GUIDE.md` - отладка проблем
- `NETWORK_SETUP.md` - настройка сети
## Готово! 🎉
Теперь у вас есть полноценная панель управления Minecraft серверами с:
- ✅ Авторизацией и регистрацией
- ✅ Ролями и правами доступа
- ✅ Управлением пользователями
- ✅ Контролем доступа к серверам
- ✅ Консолью в реальном времени
- ✅ Файловым менеджером с редактором
- ✅ Мониторингом ресурсов
- ✅ Поддержкой удаленного доступа
**Приятного использования!** 🚀

121
LINUX_DOCKER_FIX.md Normal file
View File

@@ -0,0 +1,121 @@
# Исправление Docker на Linux
## Проблема
Nginx не может запуститься из-за отсутствия секции "events" в конфигурации.
Backend работает отлично!
## ✅ ПРОСТОЕ РЕШЕНИЕ - Запуск без nginx
### Вариант 1: Быстрое исправление
```bash
# 1. Остановить все контейнеры
docker compose down
# 2. Создать папку data с файлами
mkdir -p data
cat > data/users.json << 'EOF'
{"admin":{"username":"admin","password":"$2b$12$PAaomoUWn3Ip5ov.S/uYPeTIRiDMq7DbA57ahyYQnw3QHT2zuYMlG","role":"owner","servers":[],"permissions":{"manage_users":true,"manage_roles":true,"manage_servers":true,"manage_tickets":true,"manage_files":true,"delete_users":true,"view_all_resources":true},"resource_access":{"servers":[],"tickets":[],"files":[]}}}
EOF
echo '{}' > data/tickets.json
echo '{}' > data/daemons.json
# 3. Изменить порты в docker-compose.yml
sed -i 's/"8000:8000"/"80:8000"/' docker-compose.yml
# 4. Запустить только mc-panel
docker compose up -d mc-panel
```
### Вариант 2: Упрощенный docker-compose (РЕКОМЕНДУЕТСЯ)
1. **Скопируйте файлы**:
- `docker-compose-linux.yml`
- `backend/daemons.py` (обновленный)
2. **Выполните команды**:
```bash
# Остановить контейнеры
docker compose down
# Создать папку data
mkdir -p data
cat > data/users.json << 'EOF'
{"admin":{"username":"admin","password":"$2b$12$PAaomoUWn3Ip5ov.S/uYPeTIRiDMq7DbA57ahyYQnw3QHT2zuYMlG","role":"owner","servers":[],"permissions":{"manage_users":true,"manage_roles":true,"manage_servers":true,"manage_tickets":true,"manage_files":true,"delete_users":true,"view_all_resources":true},"resource_access":{"servers":[],"tickets":[],"files":[]}}}
EOF
echo '{}' > data/tickets.json
echo '{}' > data/daemons.json
# Запустить с новой конфигурацией
docker compose -f docker-compose-linux.yml up --build -d
```
## Проверка
```bash
# Статус контейнера
docker compose ps
# Логи
docker compose logs mc-panel
# Проверка API
curl http://localhost/api/auth/oidc/providers
# Проверка в браузере
# Откройте http://IP_СЕРВЕРА
```
## Результат
**Панель доступна на IP сервера через порт 80**
**Логин: admin, пароль: Admin**
**SSO работает**
**Никаких проблем с nginx**
## Если нужен nginx позже
После того как панель заработает, можно настроить nginx отдельно:
```bash
# Создать правильную nginx конфигурацию
cat > nginx/simple.conf << 'EOF'
events {
worker_connections 1024;
}
http {
upstream mc_panel {
server mc-panel:8000;
}
server {
listen 80;
location / {
proxy_pass http://mc_panel;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
EOF
# Добавить nginx в docker-compose
# И изменить порты mc-panel обратно на "8000:8000"
```
## Структура файлов
```
📁 Проект
├── docker-compose-linux.yml # ✅ Упрощенная конфигурация
├── backend/daemons.py # ✅ Исправленные пути
└── data/ # ✅ Создается автоматически
├── users.json
├── tickets.json
└── daemons.json
```
---
**Используйте Вариант 2 - самый надежный способ!**

View File

@@ -0,0 +1,707 @@
{
"info": {
"name": "MC Panel API",
"description": "API коллекция для MC Panel - системы управления Minecraft серверами",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"version": "1.0.0"
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
},
"variable": [
{
"key": "baseUrl",
"value": "http://localhost:8000",
"type": "string"
},
{
"key": "token",
"value": "",
"type": "string"
},
{
"key": "serverName",
"value": "survival",
"type": "string"
}
],
"item": [
{
"name": "Authentication",
"item": [
{
"name": "Register",
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 200) {",
" const response = pm.response.json();",
" pm.collectionVariables.set('token', response.access_token);",
" pm.environment.set('token', response.access_token);",
"}"
]
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"testuser\",\n \"password\": \"testpass123\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/auth/register",
"host": ["{{baseUrl}}"],
"path": ["api", "auth", "register"]
}
}
},
{
"name": "Login",
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 200) {",
" const response = pm.response.json();",
" pm.collectionVariables.set('token', response.access_token);",
" pm.environment.set('token', response.access_token);",
"}"
]
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"Sofa12345\",\n \"password\": \"arkonsad123\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/auth/login",
"host": ["{{baseUrl}}"],
"path": ["api", "auth", "login"]
}
}
},
{
"name": "Get Current User",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/auth/me",
"host": ["{{baseUrl}}"],
"path": ["api", "auth", "me"]
}
}
}
]
},
{
"name": "Users",
"item": [
{
"name": "Get All Users",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/users",
"host": ["{{baseUrl}}"],
"path": ["api", "users"]
}
}
},
{
"name": "Update User Role",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"role\": \"support\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/users/username/role",
"host": ["{{baseUrl}}"],
"path": ["api", "users", "username", "role"]
}
}
},
{
"name": "Update User Servers",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"servers\": [\"survival\", \"creative\"]\n}"
},
"url": {
"raw": "{{baseUrl}}/api/users/username/servers",
"host": ["{{baseUrl}}"],
"path": ["api", "users", "username", "servers"]
}
}
},
{
"name": "Delete User",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/users/username",
"host": ["{{baseUrl}}"],
"path": ["api", "users", "username"]
}
}
}
]
},
{
"name": "Servers",
"item": [
{
"name": "Get Servers",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/servers",
"host": ["{{baseUrl}}"],
"path": ["api", "servers"]
}
}
},
{
"name": "Create Server",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"survival\",\n \"displayName\": \"Survival Server\",\n \"startCommand\": \"java -Xmx2G -Xms1G -jar server.jar nogui\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/servers/create",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "create"]
}
}
},
{
"name": "Get Server Config",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/config",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "config"]
}
}
},
{
"name": "Update Server Config",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"survival\",\n \"displayName\": \"Updated Survival Server\",\n \"startCommand\": \"java -Xmx4G -Xms2G -jar server.jar nogui\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/config",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "config"]
}
}
},
{
"name": "Start Server",
"request": {
"method": "POST",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/start",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "start"]
}
}
},
{
"name": "Stop Server",
"request": {
"method": "POST",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/stop",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "stop"]
}
}
},
{
"name": "Send Command",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"command\": \"say Hello from API!\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/command",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "command"]
}
}
},
{
"name": "Get Server Stats",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/stats",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "stats"]
}
}
},
{
"name": "Delete Server",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}"]
}
}
}
]
},
{
"name": "Files",
"item": [
{
"name": "List Files",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/files?path=",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "files"],
"query": [
{
"key": "path",
"value": ""
}
]
}
}
},
{
"name": "Create File",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"file\",\n \"name\": \"test.txt\",\n \"path\": \"\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/files/create",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "files", "create"]
}
}
},
{
"name": "Create Folder",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"folder\",\n \"name\": \"backup\",\n \"path\": \"\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/files/create",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "files", "create"]
}
}
},
{
"name": "Get File Content",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/files/content?path=server.properties",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "files", "content"],
"query": [
{
"key": "path",
"value": "server.properties"
}
]
}
}
},
{
"name": "Update File Content",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"content\": \"server-port=25565\\nmax-players=20\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/files/content?path=server.properties",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "files", "content"],
"query": [
{
"key": "path",
"value": "server.properties"
}
]
}
}
},
{
"name": "Rename File",
"request": {
"method": "PUT",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/files/rename?old_path=test.txt&new_name=test_renamed.txt",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "files", "rename"],
"query": [
{
"key": "old_path",
"value": "test.txt"
},
{
"key": "new_name",
"value": "test_renamed.txt"
}
]
}
}
},
{
"name": "Move File",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"source\": \"test.txt\",\n \"destination\": \"backup\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/files/move",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "files", "move"]
}
}
},
{
"name": "Delete File",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/files?path=test.txt",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "files"],
"query": [
{
"key": "path",
"value": "test.txt"
}
]
}
}
},
{
"name": "Download File",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/servers/{{serverName}}/files/download?path=server.jar",
"host": ["{{baseUrl}}"],
"path": ["api", "servers", "{{serverName}}", "files", "download"],
"query": [
{
"key": "path",
"value": "server.jar"
}
]
}
}
}
]
},
{
"name": "Tickets",
"item": [
{
"name": "Get Tickets",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/tickets",
"host": ["{{baseUrl}}"],
"path": ["api", "tickets"]
}
}
},
{
"name": "Create Ticket",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Проблема с сервером\",\n \"description\": \"Сервер не запускается после обновления\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/tickets/create",
"host": ["{{baseUrl}}"],
"path": ["api", "tickets", "create"]
}
}
},
{
"name": "Get Ticket",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/tickets/1",
"host": ["{{baseUrl}}"],
"path": ["api", "tickets", "1"]
}
}
},
{
"name": "Add Message",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"text\": \"Я попробовал перезапустить, но проблема осталась\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/tickets/1/message",
"host": ["{{baseUrl}}"],
"path": ["api", "tickets", "1", "message"]
}
}
},
{
"name": "Update Status",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"status\": \"in_progress\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/tickets/1/status",
"host": ["{{baseUrl}}"],
"path": ["api", "tickets", "1", "status"]
}
}
}
]
},
{
"name": "Profile",
"item": [
{
"name": "Get Profile Stats",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/profile/stats",
"host": ["{{baseUrl}}"],
"path": ["api", "profile", "stats"]
}
}
},
{
"name": "Get User Profile Stats",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/profile/stats/username",
"host": ["{{baseUrl}}"],
"path": ["api", "profile", "stats", "username"]
}
}
},
{
"name": "Update Username",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"new_username\": \"newusername\",\n \"password\": \"currentpassword\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/profile/username",
"host": ["{{baseUrl}}"],
"path": ["api", "profile", "username"]
}
}
},
{
"name": "Update Password",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"old_password\": \"oldpass123\",\n \"new_password\": \"newpass456\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/profile/password",
"host": ["{{baseUrl}}"],
"path": ["api", "profile", "password"]
}
}
}
]
},
{
"name": "OpenID Connect",
"item": [
{
"name": "Get OIDC Providers",
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/auth/oidc/providers",
"host": ["{{baseUrl}}"],
"path": ["api", "auth", "oidc", "providers"]
}
}
}
]
}
]
}

59
MIGRATE_USERS.bat Normal file
View File

@@ -0,0 +1,59 @@
@echo off
echo ========================================
echo MC Panel - User Migration v1.1.0
echo ========================================
echo.
echo [INFO] Starting user migration...
echo [INFO] This will add Owner role and permissions system
echo.
REM Check if Python is installed
python --version >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] Python is not installed!
echo.
echo Please install Python 3.11+ from:
echo https://www.python.org/downloads/
echo.
pause
exit /b 1
)
REM Check if users.json exists
if not exist "backend\users.json" (
echo [WARNING] users.json not found!
echo.
echo This is normal if you haven't started the panel yet.
echo Start the panel first to create users.json
echo.
pause
exit /b 1
)
echo [STEP 1/3] Creating backup...
cd backend
python migrate_users.py
if %ERRORLEVEL% EQU 0 (
echo.
echo ========================================
echo [SUCCESS] Migration completed!
echo ========================================
echo.
echo Next steps:
echo 1. Restart the panel
echo 2. Login as owner
echo 3. Check user permissions
echo.
) else (
echo.
echo [ERROR] Migration failed!
echo.
echo Check the error messages above.
echo Your original users.json is backed up.
echo.
)
cd ..
pause

View File

@@ -1,114 +0,0 @@
# Настройка доступа через сеть
## Быстрый старт для Radmin VPN
### 1. Узнайте ваш IP адрес в Radmin VPN
Откройте командную строку и выполните:
```bash
ipconfig
```
Найдите адаптер Radmin VPN, IP обычно выглядит как `26.x.x.x`
### 2. Запустите бэкенд
```bash
cd backend
python main.py
```
Бэкенд автоматически слушает на всех сетевых интерфейсах (0.0.0.0:8000)
### 3. Запустите фронтенд
```bash
cd frontend
npm run dev
```
Теперь фронтенд запускается с флагом `--host` по умолчанию.
### 4. Откройте в браузере
**На вашем компьютере:**
- http://localhost:3000
**На компьютере друга:**
- http://ВАШ_RADMIN_IP:3000
- Например: http://26.123.45.67:3000
## Автоматическое определение API
Фронтенд автоматически определяет правильный API URL:
- Если открыто через `localhost` → подключится к `http://localhost:8000`
- Если открыто через IP → подключится к `http://ВАШ_IP:8000`
## Ручная настройка (если автоматика не работает)
Создайте файл `frontend/.env.local`:
```env
VITE_API_URL=http://26.123.45.67:8000
```
Замените `26.123.45.67` на ваш реальный IP в Radmin VPN.
Перезапустите фронтенд:
```bash
npm run dev
```
## Проверка подключения
1. Откройте консоль браузера (F12)
2. Проверьте, нет ли ошибок подключения
3. Убедитесь, что запросы идут на правильный IP адрес
## Возможные проблемы
### Серверы не загружаются
**Причина:** Фронтенд не может подключиться к бэкенду
**Решение:**
1. Убедитесь, что бэкенд запущен
2. Проверьте, что используется правильный IP
3. Проверьте брандмауэр Windows (порты 8000 и 3000 должны быть открыты)
### Ошибка при создании сервера
**Причина:** CORS или неправильный API URL
**Решение:**
1. Перезапустите бэкенд
2. Очистите кэш браузера (Ctrl+Shift+Delete)
3. Проверьте консоль браузера на ошибки
### WebSocket не подключается (консоль не работает)
**Причина:** WebSocket использует неправильный адрес
**Решение:**
1. Проверьте файл `frontend/src/config.js`
2. WebSocket должен использовать `ws://` вместо `http://`
3. Перезапустите фронтенд
## Открытие портов в брандмауэре Windows
```powershell
# Откройте PowerShell от имени администратора
# Порт для бэкенда
netsh advfirewall firewall add rule name="MC Panel Backend" dir=in action=allow protocol=TCP localport=8000
# Порт для фронтенда
netsh advfirewall firewall add rule name="MC Panel Frontend" dir=in action=allow protocol=TCP localport=3000
```
## Проверка работы
На компьютере друга откройте:
- http://ВАШ_IP:3000
Вы должны увидеть панель управления, и она должна показывать ваши серверы.

168
NGINX_SETUP.md Normal file
View File

@@ -0,0 +1,168 @@
# Настройка с Nginx + Frontend
## Архитектура
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Browser │───▶│ Nginx │───▶│ MC Panel │
│ │ │ │ │ (API only) │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐
│ Frontend │
│ (Static) │
└─────────────┘
```
- **Nginx**: Раздает статические файлы frontend + проксирует API запросы
- **MC Panel**: Только API backend (порт 8000, внутренний)
- **Frontend**: Собирается в Docker и копируется в nginx
## Файлы
### 1. ✅ Dockerfile
Уже настроен для многоэтапной сборки:
- Stage 1: Собирает frontend (`npm run build`)
- Stage 2: Python backend + статические файлы
### 2. ✅ nginx/default.conf
Полная nginx конфигурация:
- Раздача статических файлов из `/usr/share/nginx/html`
- Проксирование `/api/*` на backend
- WebSocket поддержка для `/ws/*`
- Gzip сжатие
- Кэширование статики
### 3. ✅ docker-compose-nginx.yml
- `mc-panel`: Backend API (внутренний порт 8000)
- `nginx`: Статика + reverse proxy (порт 80)
- `frontend-init`: Init контейнер для копирования статики
## Запуск
### Подготовка
```bash
# 1. Создать папку data
mkdir -p data
cat > data/users.json << 'EOF'
{"admin":{"username":"admin","password":"$2b$12$PAaomoUWn3Ip5ov.S/uYPeTIRiDMq7DbA57ahyYQnw3QHT2zuYMlG","role":"owner","servers":[],"permissions":{"manage_users":true,"manage_roles":true,"manage_servers":true,"manage_tickets":true,"manage_files":true,"delete_users":true,"view_all_resources":true},"resource_access":{"servers":[],"tickets":[],"files":[]}}}
EOF
echo '{}' > data/tickets.json
echo '{}' > data/daemons.json
# 2. Скопировать файлы:
# - docker-compose-nginx.yml
# - nginx/default.conf (обновленный)
# - backend/daemons.py (обновленный)
```
### Запуск
```bash
# Остановить старые контейнеры
docker compose down
# Запустить с nginx
docker compose -f docker-compose-nginx.yml up --build -d
```
### Проверка
```bash
# Статус контейнеров
docker compose -f docker-compose-nginx.yml ps
# Логи
docker compose -f docker-compose-nginx.yml logs nginx
docker compose -f docker-compose-nginx.yml logs mc-panel
# Проверка API
curl http://localhost/api/auth/oidc/providers
# Проверка frontend
curl http://localhost/
```
## Что происходит
1. **Сборка**:
- Frontend собирается в `/app/frontend/dist`
- Backend копируется в `/app/backend`
2. **Инициализация**:
- `frontend-init` копирует статику в volume `frontend-static`
- Завершается после копирования
3. **Nginx**:
- Монтирует volume `frontend-static` в `/usr/share/nginx/html`
- Раздает статические файлы
- Проксирует `/api/*` на `mc-panel:8000`
4. **Backend**:
- Запускается на внутреннем порту 8000
- Доступен только через nginx
## Преимущества
**Производительность**: Nginx раздает статику быстрее
**Кэширование**: Статические файлы кэшируются
**Сжатие**: Gzip для всех файлов
**Безопасность**: Backend недоступен извне
**Масштабируемость**: Можно добавить SSL, балансировку
## Отладка
### Nginx не запускается
```bash
# Проверить конфигурацию
docker compose -f docker-compose-nginx.yml exec nginx nginx -t
# Проверить файлы
docker compose -f docker-compose-nginx.yml exec nginx ls -la /usr/share/nginx/html
```
### Frontend не загружается
```bash
# Проверить статические файлы
docker compose -f docker-compose-nginx.yml exec nginx ls -la /usr/share/nginx/html
# Проверить логи init контейнера
docker compose -f docker-compose-nginx.yml logs frontend-init
```
### API не работает
```bash
# Проверить backend
docker compose -f docker-compose-nginx.yml exec mc-panel curl http://localhost:8000/api/auth/oidc/providers
# Проверить проксирование
curl -v http://localhost/api/auth/oidc/providers
```
## Альтернативные варианты
### Вариант 1: Простой (без nginx)
Используйте `docker-compose-linux.yml` - backend раздает всё сам
### Вариант 2: Nginx + внешняя сборка
Соберите frontend локально и монтируйте папку `dist`
### Вариант 3: Отдельные контейнеры
Frontend и backend в разных контейнерах
## Результат
После успешного запуска:
- **Frontend**: `http://IP_СЕРВЕРА/`
- **API**: `http://IP_СЕРВЕРА/api/`
- **WebSocket**: `ws://IP_СЕРВЕРА/ws/`
- **Логин**: `admin` / `Admin`
---
**Nginx + Frontend в статике = максимальная производительность!** 🚀

View File

@@ -1,201 +0,0 @@
# 🚀 Быстрый старт MC Panel
## Первый запуск
### Вариант 1: Автоматический запуск (Windows)
Просто запустите файл:
```
START_PANEL.bat
```
Откроются два окна:
- **MC Panel Backend** - бэкенд сервер
- **MC Panel Frontend** - фронтенд сервер
Подождите 10-15 секунд и откройте в браузере:
```
http://localhost:3000
```
### Вариант 2: Ручной запуск
**Терминал 1 - Бэкенд:**
```bash
cd backend
python main.py
```
**Терминал 2 - Фронтенд:**
```bash
cd frontend
npm run dev
```
## Создание первого сервера
1. Нажмите кнопку **"+"** в левой панели
2. Заполните форму:
- **Имя папки**: `my_server` (только латиница)
- **Отображаемое имя**: `Мой сервер`
- **Команда запуска**: `java -Xmx2G -Xms1G -jar server.jar nogui`
3. Нажмите **"Создать"**
## Загрузка файлов сервера
1. Выберите созданный сервер в списке
2. Перейдите на вкладку **"Файлы"**
3. Нажмите **"Загрузить"**
4. Выберите `server.jar` (скачайте с официального сайта Minecraft)
5. Если нужно, создайте файл `eula.txt`:
- Нажмите **"Загрузить"**
- Создайте текстовый файл с содержимым: `eula=true`
- Загрузите его
## Запуск сервера
1. Нажмите кнопку **"Старт"** на карточке сервера
2. Перейдите на вкладку **"Консоль"** чтобы видеть логи
3. Дождитесь сообщения `Done!` в консоли
4. Сервер готов к подключению!
## Управление сервером
### Консоль
- Просмотр логов в реальном времени
- Отправка команд серверу
- Примеры команд: `list`, `say Hello`, `stop`
### Файлы
- Просмотр и редактирование конфигов
- Загрузка плагинов/модов
- Скачивание файлов
- Переименование и удаление
### Статистика
- Использование CPU
- Потребление ОЗУ
- Размер на диске
- Статус сервера
### Настройки
- Изменение отображаемого имени
- Настройка команды запуска
- Удаление сервера
## Доступ через сеть (Radmin VPN)
### На вашем компьютере:
1. Узнайте ваш IP в Radmin VPN:
```bash
ipconfig
```
Ищите адаптер Radmin VPN (обычно `26.x.x.x`)
2. Запустите панель как обычно
3. Откройте в браузере:
```
http://localhost:3000
```
### На компьютере друга:
1. Откройте в браузере:
```
http://ВАШ_RADMIN_IP:3000
```
Например: `http://26.62.117.104:3000`
2. Панель автоматически подключится к вашему бэкенду
### Если не работает:
Откройте порты в брандмауэре Windows (от имени администратора):
```powershell
netsh advfirewall firewall add rule name="MC Panel Backend" dir=in action=allow protocol=TCP localport=8000
netsh advfirewall firewall add rule name="MC Panel Frontend" dir=in action=allow protocol=TCP localport=3000
```
## Типичные проблемы
### Java не найдена
**Ошибка:** `'java' is not recognized...`
**Решение:**
1. Установите Java 17+: https://adoptium.net/
2. Перезапустите терминал
3. Проверьте: `java -version`
### Сервер не запускается
**Причины:**
- Отсутствует `server.jar`
- Не принят EULA
- Неправильная команда запуска
**Решение:**
1. Проверьте наличие `server.jar` в файлах
2. Создайте `eula.txt` с содержимым `eula=true`
3. Проверьте команду запуска в настройках
### Порт уже занят
**Ошибка:** `Address already in use`
**Решение:**
```bash
# Найти процесс на порту 8000
netstat -ano | findstr :8000
# Убить процесс (замените PID)
taskkill /F /PID <PID>
```
### Не видно файлов/настроек
**Решение:**
1. Откройте консоль браузера (F12)
2. Проверьте вкладку Network на ошибки
3. Обновите страницу (F5)
4. Перезапустите бэкенд
## Полезные ссылки
- **Скачать Minecraft Server**: https://www.minecraft.net/en-us/download/server
- **Документация Minecraft**: https://minecraft.fandom.com/wiki/Server
- **Java Download**: https://adoptium.net/
- **Radmin VPN**: https://www.radmin-vpn.com/
## Команды Minecraft
Полезные команды для консоли:
```
list # Список игроков
say <message> # Сообщение всем
kick <player> # Кикнуть игрока
ban <player> # Забанить игрока
op <player> # Дать права оператора
deop <player> # Забрать права оператора
whitelist add <player> # Добавить в белый список
stop # Остановить сервер
```
## Конфигурационные файлы
Основные файлы для редактирования:
- **server.properties** - основные настройки сервера
- **eula.txt** - принятие лицензии
- **ops.json** - список операторов
- **whitelist.json** - белый список игроков
- **banned-players.json** - забаненные игроки
Редактируйте их через вкладку "Файлы" в панели!
---
**Готово!** Теперь у вас есть полноценная панель управления Minecraft серверами! 🎮

413
README.md
View File

@@ -1,128 +1,329 @@
# MC Panel - Панель управления Minecraft серверами
Панель управления для Minecraft серверов с FastAPI бэкендом и React фронтендом.
**Версия:** 1.1.0
**Дата:** 15 января 2026
## Возможности
---
- Создание новых серверов
- 🎮 Запуск и остановка серверов
- 💻 Консоль с отправкой команд в реальном времени
- 📁 Менеджер файлов:
- Загрузка и скачивание файлов
- Просмотр содержимого файлов
- Редактирование текстовых файлов
- Переименование файлов и папок
- Удаление файлов и папок
- 📊 Мониторинг ресурсов (CPU, ОЗУ, диск)
- ⚙️ Настройки сервера (название, команда запуска)
- 🗑️ Удаление серверов
- 🔄 Автообновление статистики
## 📚 Документация
## Установка
### 🎉 ПРОЕКТ_ЗАВЕРШЁН
**Полный обзор проекта**
### Бэкенд
Comprehensive overview всего проекта:
-Все выполненные задачи (11 шт.)
- 📊 Статистика проекта
- 🎯 Основные возможности
- 🚀 Быстрый старт (3 варианта)
- 🏆 Достижения
**Начните отсюда для общего понимания!** 🌟
### 📋 ФИНАЛЬНЫЙ_СПИСОК
**Полный список всех файлов**
Детальный список всех файлов проекта:
- 📁 Структура проекта (60+ файлов)
- 📊 Статистика кода (~20,000 строк)
- 📚 Навигация по документации
- 🎯 Выполненные задачи (14 шт.)
- 🏆 Достижения
**Полная карта проекта!** 🗺️
### ✅ CHECKLIST
**Финальный Checklist**
Проверка завершения всех работ:
-Все задачи (14/14 - 100%)
-Все файлы (65+)
- ✅ Вся функциональность
- ✅ Вся документация
- 🚀 Production Ready
**Подтверждение готовности!** ✔️
### 👑 OWNER_PERMISSIONS
**Роль Владельца и Система Прав**
Новая система управления пользователями:
- 👑 Роль владельца (Owner)
- 🔐 Система прав и разрешений
- 👥 Управление пользователями
- 🚫 Блокировка/разблокировка
- 📊 5 ролей (Owner, Admin, Support, User, Banned)
**Полный контроль над панелью!** 🎯
### 🔧 MIGRATION_FIX
**Исправление миграции**
Решение проблемы KeyError при миграции:
- 🐛 Описание проблемы
- ✅ Решение (поддержка обоих форматов)
- 📊 Примеры до/после
- 🧪 Тестирование
- ❓ FAQ
**Миграция работает!** ✔️
### ✅ OWNER_UI_READY
**UI Владельца готов!**
Полная инструкция по использованию:
- 🎉 Что было сделано
- 🚀 Как запустить
- 🎯 Как использовать
- 💡 Примеры
- 🐛 Troubleshooting
**Управление пользователями работает!** 👑
### 👁️ OWNER_VIEW_ALL
**Владелец видит все серверы**
Изменение логики доступа:
- 🎯 Что изменилось
- 📊 Логика доступа к серверам
- 🎫 Логика доступа к тикетам
- 🔐 Права view_all_resources
- 🚀 Как проверить
**Полный контроль над всеми ресурсами!** 🖥️
### 👑 MULTIPLE_OWNERS
**Несколько владельцев**
Возможность назначить несколько владельцев:
- 🎯 Что изменилось
- 📊 Новая логика
- 💡 Примеры использования
- 🔒 Правила безопасности
- 🎯 Рекомендации
**Больше владельцев - больше контроля!** 👑👑
### 🚀 DRONE_SIMPLIFIED
**Упрощённый CI/CD**
Упрощение Drone конфигурации:
- 🎯 Что изменилось (4→2 пайплайна)
- 📋 Оставшиеся пайплайны
- 🗑️ Удалённые компоненты
- 🔧 Настройка
- ✅ Преимущества
**Меньше сложности - больше контроля!** 🔧
### 📝 CHANGELOG
**История изменений**
Все изменения проекта:
- 📋 Версия 1.1.0 - Система прав
- 📋 Версия 1.0.0 - Первый релиз
- 🔄 Детальное описание изменений
- 🐛 Исправленные ошибки
**Отслеживание изменений!** 📊
### 🎉 VERSION_1.1.0
**Релиз v1.1.0**
Что нового в версии 1.1.0:
- 👑 Роль владельца
- 🔐 Система прав (7 типов)
- 🆕 8 новых API эндпоинтов
- 🛠️ Инструменты миграции
- 📚 Новая документация
**Обзор релиза!** 🚀
---
### 📖 ДОКУМЕНТАЦИЯ
**Полная документация проекта**
Содержит всю информацию о проекте:
- 🚀 Быстрый старт
- ⚙️ Установка и настройка
- 🎮 Функциональность
- 🔔 Система уведомлений
- 🎨 Дизайн и темы
- 📁 Файловый менеджер
- 🎫 Система тикетов
- 👤 Личный кабинет
- 🔐 OpenID Connect
- 👥 Роли пользователей
- 🔒 Безопасность
- 🔧 Troubleshooting
**Начните отсюда!** 👈
---
### 🌐 API
**Документация API**
Полное описание REST API:
- 📋 Все эндпоинты (37 шт.)
- 🔐 Аутентификация
- 👥 Управление пользователями
- 🖥️ Управление серверами
- 📁 Управление файлами
- 🎫 Тикеты
- 💡 Примеры интеграции (Python, JavaScript, cURL)
- 📦 Postman коллекция
**Для разработчиков!** 👨‍💻
---
### 📦 MC_Panel_API.postman_collection
**Postman коллекция**
Готовая коллекция для тестирования API:
- 40+ готовых запросов
- Автоматическое сохранение токена
- Переменные окружения
- Примеры тел запросов
**Импортируйте в Postman!** 📮
**Всё .md файлы есть на вики**
### Вариант 1: Docker (рекомендуется) 🐳
```bash
# 1. Настройте переменные окружения
cp .env.example .env
# Отредактируйте .env файл
# 2. Запустите
docker-compose up -d
# 3. Откройте http://localhost:8000
```
**Подробнее:** [DOCKER.md](DOCKER.md)
### Вариант 2: Локальная установка
**Backend:**
```bash
cd backend
pip install -r requirements.txt
python main.py
```
Сервер запустится на http://0.0.0.0:8000
### Фронтенд
**Frontend:**
```bash
cd frontend
npm install
npm run dev -- --host
```
Приложение откроется на http://localhost:3000
## Доступ через сеть (Radmin VPN, Hamachi и т.д.)
### Вариант 1: Автоматическое определение (рекомендуется)
Фронтенд автоматически определит IP адрес и подключится к бэкенду.
1. Запустите бэкенд (он слушает на всех интерфейсах)
2. Запустите фронтенд с флагом `--host`:
```bash
npm run dev -- --host
```
3. Откройте в браузере: `http://ВАШ_IP:3000`
- Например: `http://26.123.45.67:3000` (Radmin VPN IP)
### Вариант 2: Ручная настройка
Создайте файл `frontend/.env.local`:
```
VITE_API_URL=http://26.123.45.67:8000
```
Замените `26.123.45.67` на ваш IP адрес в Radmin VPN.
### Проверка IP адреса
Windows:
```bash
ipconfig
```
Ищите адрес адаптера Radmin VPN (обычно начинается с 26.x.x.x)
## Структура
```
backend/
main.py # FastAPI сервер
requirements.txt # Зависимости Python
servers/ # Папка с серверами Minecraft
frontend/
src/
components/
Console.jsx # Компонент консоли
FileManager.jsx # Менеджер файлов
Stats.jsx # Статистика
App.jsx # Главный компонент
main.jsx # Точка входа
package.json # Зависимости Node.js
```
## Использование
### Быстрый старт
**Windows:**
```bash
START_PANEL.bat
```
**Вручную:**
```bash
# Терминал 1 - Бэкенд
cd backend
python main.py
# Терминал 2 - Фронтенд
cd frontend
npm run dev
```
Откройте в браузере: http://localhost:3000
### 2. Первый вход
### Создание сервера
1. Откройте `http://localhost:3000`
2. Зарегистрируйтесь (первый пользователь = admin)
3. Создайте сервер
4. Загрузите `server.jar`
5. Запустите сервер!
1. Нажмите кнопку "+" для создания нового сервера
2. Укажите имя, отображаемое название и команду запуска
3. Загрузите файлы сервера (server.jar и т.д.) через менеджер файлов
4. Создайте файл `eula.txt` с содержимым `eula=true`
5. Запустите сервер и управляйте им через вкладки:
- **Консоль** - просмотр логов и отправка команд
- **Файлы** - управление файлами сервера
- **Статистика** - мониторинг ресурсов
- **Настройки** - изменение параметров сервера
**Учетные данные по умолчанию:**
- Логин: `Root`
- Пароль: `Admin`
Подробнее: см. `QUICK_START.md`
---
## ✨ Основные возможности
- 🖥️ **Управление серверами** - запуск, остановка, мониторинг
- 📁 **Файловый менеджер** - полное управление файлами
- 💬 **Консоль** - команды и логи в реальном времени
- 📊 **Статистика** - CPU, RAM, диск
- 🎫 **Тикеты** - система поддержки
- 👥 **Пользователи** - роли и права доступа
- 🔐 **OpenID Connect** - интеграция с ZITADEL
- 🎨 **6 тем** - включая современную темную
- 🔔 **Уведомления** - о всех событиях
- 👤 **Личный кабинет** - профиль и статистика
---
## 🛠️ Технологии
**Backend:**
- FastAPI (Python)
- JWT аутентификация
- WebSocket
- Authlib (OpenID Connect)
**Frontend:**
- React 18
- Tailwind CSS
- Axios
- Lucide React
---
## 📁 Структура проекта
```
mc-panel/
├── backend/
│ ├── main.py # FastAPI приложение
│ ├── oidc_config.py # OpenID Connect
│ ├── requirements.txt # Зависимости
│ └── servers/ # Папка серверов
├── frontend/
│ ├── src/
│ │ ├── App.jsx # Главный компонент
│ │ ├── components/ # React компоненты
│ │ └── themes.js # Темы
│ └── package.json # npm зависимости
├── MC_Panel_API.postman_collection.json # Postman
└── README.md # Этот файл
```
---
## 🔒 Безопасность
- JWT токены (7 дней)
- Bcrypt хеширование паролей
- Проверка прав доступа
- Защита файловой системы
- OpenID Connect поддержка
**Для production:**
1. Измените `SECRET_KEY` в `backend/main.py`
2. Используйте HTTPS
3. Настройте CORS
4. Используйте базу данных вместо JSON
---
## 📞 Поддержка
- **Документация**
- **API**
- **Тикеты:**
---
## 🙏 Благодарности
Спасибо за использование MC Panel!
Если у вас есть вопросы или предложения:
1. Прочитайте документацию
2. Проверьте API документацию
3. Создайте тикет в системе
---
**Версия:** 1.1.0
**Дата:** 15 января 2026
**Приятного использования!** 🎮

View File

@@ -1,138 +0,0 @@
# MC Panel - Финальная версия с авторизацией
## ✅ Что готово
Полноценная панель управления Minecraft серверами с системой авторизации и управлением пользователями.
## 🚀 Быстрый старт
### 1. Переименуйте файлы
**ВАЖНО! Сделайте это вручную в проводнике Windows:**
1. `backend/main_new.py``backend/main.py`
2. `frontend/src/App_final.jsx``frontend/src/App.jsx`
### 2. Установите зависимости
```bash
cd backend
pip install -r requirements.txt
```
### 3. Запустите
```bash
START_PANEL.bat
```
Или вручную:
```bash
# Терминал 1
cd backend
python main.py
# Терминал 2
cd frontend
npm run dev
```
### 4. Войдите
Откройте http://localhost:3000
- Логин: `admin`
- Пароль: `admin`
## 📚 Документация
- **INSTALLATION_COMPLETE.md** - полная инструкция по установке
- **AUTH_SETUP.md** - руководство по авторизации
- **FINAL_STEPS.md** - пошаговые инструкции
- **QUICK_START.md** - быстрый старт для новичков
- **DEBUG_GUIDE.md** - решение проблем
- **NETWORK_SETUP.md** - настройка удаленного доступа
## 🎯 Основные функции
### Авторизация
- Вход и регистрация
- JWT токены
- Автоматический выход при истечении
### Роли
- **Администратор** - полный доступ
- **Пользователь** - доступ к назначенным серверам
### Управление пользователями
- Выдача/отзыв доступа к серверам
- Изменение ролей
- Удаление пользователей
### Управление серверами
- Создание/удаление серверов
- Запуск/остановка
- Консоль в реальном времени
- Файловый менеджер с редактором
- Мониторинг ресурсов
- Настройки
## ⚠️ Важно
### Смените секретный ключ!
Откройте `backend/main.py` и измените:
```python
SECRET_KEY = "your-secret-key-change-this-in-production-12345"
```
### Смените пароль admin
1. Создайте нового администратора
2. Войдите под ним
3. Удалите старого admin
## 🌐 Удаленный доступ
1. Узнайте IP: `ipconfig` (Radmin VPN обычно 26.x.x.x)
2. Друг открывает: `http://ВАШ_IP:3000`
3. Друг регистрируется
4. Вы выдаете ему доступ к серверам
## 📁 Структура
```
mc-panel/
├── backend/
│ ├── main_new.py → main.py # Переименуйте!
│ ├── requirements.txt
│ ├── users.json # Создастся автоматически
│ └── servers/
└── frontend/
├── src/
│ ├── App_final.jsx → App.jsx # Переименуйте!
│ └── components/
│ ├── Auth.jsx
│ ├── Users.jsx
│ └── ...
└── package.json
```
## 🆘 Помощь
### Не могу войти
Удалите `backend/users.json` и перезапустите бэкенд.
### Ошибка импорта
```bash
pip install passlib[bcrypt] python-jose[cryptography]
```
### Пользователь не видит серверы
Админ должен выдать доступ в разделе "Пользователи".
## 🎉 Готово!
Теперь у вас полноценная панель с авторизацией!
Подробнее см. **INSTALLATION_COMPLETE.md**

View File

@@ -1,61 +0,0 @@
# 🚀 MC Panel готова к использованию!
## Быстрый старт
### 1. Запустите бэкенд
```bash
cd backend
python main_new.py
```
### 2. Запустите фронтенд
```bash
cd frontend
npm run dev
```
### 3. Откройте панель
Откройте http://localhost:3000 в браузере
### 4. Войдите в систему
- **Логин**: admin
- **Пароль**: admin
## ✨ Возможности
### 🎨 Темы
- 5 тем на выбор: Тёмная, Светлая, Фиолетовая, Синяя, Зелёная
- Градиентный логотип "MC Panel" для каждой темы
- Автоматическое сохранение выбранной темы
### 🖥️ Управление серверами
- Создание и удаление серверов
- Запуск и остановка серверов
- Просмотр консоли в реальном времени
- Менеджер файлов с редактированием
- Мониторинг ресурсов (RAM, диск)
- Настройки сервера
### 👥 Пользователи
- Регистрация и авторизация
- Роли: Админ и Пользователь
- Управление доступом к серверам
- Владельцы серверов могут выдавать доступ другим пользователям
### 🌐 Сетевой доступ
- Работает через Radmin VPN
- Автоматическое определение API URL
- Поддержка локальной и сетевой работы
## 📱 Интерфейс
Современный дизайн в стиле TimeWeb Cloud:
- Карточки с тенями и анимациями
- Плавные переходы
- Адаптивный дизайн для мобильных
- Sticky header
- Анимированные индикаторы статуса
## 🎯 Готово!
Панель полностью настроена и готова к использованию. Наслаждайтесь! 🎉

View File

@@ -1,8 +0,0 @@
ВАЖНО: Переименуйте файл backend/main_new.py в backend/main.py
Удалите старый backend/main.py (если есть) и переименуйте backend/main_new.py в backend/main.py
Это можно сделать вручную в проводнике Windows или командой:
cd backend
del main.py
ren main_new.py main.py

View File

@@ -1,80 +0,0 @@
# Тестирование API
## Проверка работы API
Откройте браузер и проверьте следующие URL (замените IP на ваш):
### 1. Проверка списка серверов
```
http://26.123.45.67:8000/api/servers
```
Должен вернуть JSON с массивом серверов.
### 2. Проверка конфигурации сервера
```
http://26.123.45.67:8000/api/servers/ИМЯ_СЕРВЕРА/config
```
Должен вернуть JSON с настройками сервера.
### 3. Проверка файлов сервера
```
http://26.123.45.67:8000/api/servers/ИМЯ_СЕРВЕРА/files
```
Должен вернуть JSON с массивом файлов.
## Проверка в консоли браузера
Откройте консоль браузера (F12) и выполните:
```javascript
// Проверка API URL
console.log('API URL:', window.location.protocol + '//' + window.location.hostname + ':8000');
// Проверка серверов
fetch('http://' + window.location.hostname + ':8000/api/servers')
.then(r => r.json())
.then(data => console.log('Серверы:', data))
.catch(err => console.error('Ошибка:', err));
```
## Проверка логов бэкенда
В терминале где запущен бэкенд должны появляться сообщения:
- `Найдено серверов: X`
- `Загружена конфигурация для ...`
- `WebSocket подключен для сервера: ...`
Если сообщений нет, значит запросы не доходят до бэкенда.
## Возможные проблемы
### Проблема: Серверы показываются, но файлы/настройки не загружаются
**Причина:** Запросы идут на неправильный URL
**Решение:**
1. Откройте консоль браузера (F12)
2. Перейдите на вкладку Network
3. Попробуйте открыть файлы или настройки
4. Посмотрите на URL запросов - они должны начинаться с `http://ВАШ_IP:8000/api/`
### Проблема: CORS ошибки
**Причина:** Браузер блокирует запросы
**Решение:**
1. Перезапустите бэкенд
2. Убедитесь, что в логах бэкенда нет ошибок
3. Очистите кэш браузера
### Проблема: WebSocket не подключается
**Причина:** WebSocket использует неправильный протокол
**Решение:**
1. Проверьте файл `frontend/src/config.js`
2. WebSocket URL должен быть `ws://ВАШ_IP:8000`
3. Перезапустите фронтенд

View File

@@ -1,47 +0,0 @@
# ✅ Тема успешно применена!
## Что было сделано
### 🎨 Система тем
- ✅ Создано 5 тем: Тёмная, Светлая, Фиолетовая, Синяя, Зелёная
- ✅ Каждая тема имеет уникальный градиент для логотипа "MC Panel"
- ✅ Селектор тем добавлен в header
- ✅ Выбранная тема сохраняется в localStorage
### 🎯 Градиенты для "MC Panel"
- **Тёмная**: синий → фиолетовый (from-blue-400 to-purple-600)
- **Светлая**: синий → фиолетовый (from-blue-600 to-purple-600)
- **Фиолетовая**: фиолетовый → розовый (from-purple-400 to-pink-600)
- **Синяя**: голубой → синий (from-cyan-400 to-blue-600)
- **Зелёная**: изумрудный → зелёный (from-emerald-400 to-green-600)
### 🎨 Современный интерфейс
- ✅ Дизайн в стиле TimeWeb Cloud
- ✅ Карточки с тенями и анимациями
- ✅ Плавные переходы между темами
- ✅ Адаптивный дизайн для мобильных устройств
- ✅ Sticky header с информацией о пользователе
- ✅ Анимированные индикаторы статуса серверов
### 📱 Адаптивность
- ✅ Скрываемая боковая панель на мобильных
- ✅ Адаптивные кнопки (текст скрывается на маленьких экранах)
- ✅ Горизонтальная прокрутка вкладок
## Как использовать
### Смена темы
1. Нажмите на селектор тем в правом верхнем углу
2. Выберите нужную тему из списка
3. Тема применится мгновенно и сохранится автоматически
### Файлы с темами
- `frontend/src/themes.js` - конфигурация всех тем
- `frontend/src/App.jsx` - главный компонент с темами
- `frontend/src/components/Auth.jsx` - страница входа с темами
- `frontend/src/components/ThemeSelector.jsx` - селектор тем
## Готово! 🎉
Панель теперь имеет современный интерфейс с 5 темами и градиентным логотипом "MC Panel".
Все компоненты автоматически используют цвета выбранной темы.

View File

@@ -1,88 +0,0 @@
# ✅ Система тем полностью готова!
## Что было исправлено и улучшено
### 🎨 Градиентный логотип "MC Panel"
- ✅ Добавлен градиент в Auth.jsx (страница входа)
- ✅ Добавлен градиент в App.jsx (главная панель)
- ✅ Каждая тема имеет свой уникальный градиент:
- **Тёмная**: синий → фиолетовый
- **Светлая**: синий → фиолетовый
- **Фиолетовая**: фиолетовый → розовый
- **Синяя**: голубой → синий
- **Зелёная**: изумрудный → зелёный
### 🎯 Обновлённые компоненты
1. **ThemeSelector.jsx** - теперь использует динамические цвета из текущей темы
2. **CreateServerModal.jsx** - обновлён для использования тем с современным дизайном
3. **App.jsx** - добавлен градиент для логотипа
4. **Auth.jsx** - добавлен градиент для логотипа
### 📁 Структура файлов
```
frontend/src/
├── themes.js # Конфигурация всех тем
├── App.jsx # Главный компонент с темами ✅
├── components/
│ ├── Auth.jsx # Страница входа с темами ✅
│ ├── ThemeSelector.jsx # Селектор тем ✅
│ ├── CreateServerModal.jsx # Модальное окно создания сервера ✅
│ ├── Console.jsx # Получает theme prop
│ ├── FileManager.jsx # Получает theme prop
│ ├── Stats.jsx # Получает theme prop
│ ├── ServerSettings.jsx # Получает theme prop
│ └── Users.jsx # Получает theme prop
```
## 🚀 Как использовать
### Запуск панели
```bash
# Терминал 1 - Бэкенд
cd backend
python main_new.py
# Терминал 2 - Фронтенд
cd frontend
npm run dev
```
### Смена темы
1. Откройте панель в браузере
2. Нажмите на иконку палитры (🎨) в правом верхнем углу
3. Выберите нужную тему из выпадающего меню
4. Тема применится мгновенно и сохранится автоматически
### Доступные темы
- 🌑 **Тёмная** - классическая тёмная тема (по умолчанию)
- ☀️ **Светлая** - светлая тема для дневного использования
- 💜 **Фиолетовая** - стильная фиолетовая палитра
- 💙 **Синяя** - холодная синяя тема
- 💚 **Зелёная** - природная зелёная тема
## ✨ Особенности
### Градиентный логотип
Логотип "MC Panel" теперь использует градиент, который меняется в зависимости от выбранной темы:
```jsx
<h1 className={`text-xl font-bold bg-gradient-to-r ${currentTheme.gradient} bg-clip-text text-transparent`}>
MC Panel
</h1>
```
### Автоматическое сохранение
Выбранная тема автоматически сохраняется в `localStorage` и применяется при следующем входе.
### Плавные переходы
Все элементы интерфейса имеют плавные переходы при смене темы благодаря классу `transition-colors duration-300`.
### Адаптивный дизайн
- Скрываемая боковая панель на мобильных устройствах
- Адаптивные кнопки (текст скрывается на маленьких экранах)
- Горизонтальная прокрутка вкладок на мобильных
## 🎉 Готово!
Панель MC Panel теперь имеет полноценную систему тем с градиентным логотипом и современным интерфейсом в стиле TimeWeb Cloud. Все компоненты используют цвета из выбранной темы, обеспечивая единообразный и красивый дизайн.
Наслаждайтесь использованием! 🚀

View File

@@ -1,125 +0,0 @@
# Обновление: Система тем и современный интерфейс
## Что добавлено
### 🎨 Система тем
- **5 тем на выбор:**
- Тёмная (по умолчанию)
- Светлая
- Фиолетовая
- Синяя
- Зелёная
### 🎯 Современный интерфейс в стиле TimeWeb Cloud
- Чистый и минималистичный дизайн
- Плавные переходы и анимации
- Адаптивная вёрстка
- Улучшенная типографика
- Современные карточки и кнопки
- Sticky header
- Анимированные индикаторы статуса
## Установка
### 1. Замените App.jsx
Переименуйте файлы:
```
frontend/src/App.jsx → frontend/src/App_old.jsx (бэкап)
frontend/src/App_modern.jsx → frontend/src/App.jsx
```
### 2. Перезапустите фронтенд
```bash
cd frontend
npm run dev
```
## Использование
### Смена темы
1. Нажмите на иконку палитры (🎨) в правом верхнем углу
2. Выберите нужную тему из выпадающего меню
3. Тема сохраняется автоматически
### Особенности интерфейса
**Header:**
- Sticky (прилипает к верху при прокрутке)
- Показывает статус подключения
- Отображает текущего пользователя и роль
- Кнопки быстрого доступа
**Sidebar:**
- Список серверов с карточками
- Кнопки запуска/остановки на каждой карточке
- Анимированный индикатор статуса (пульсирует когда запущен)
- Скрывается на мобильных устройствах
**Вкладки:**
- Современный дизайн с подчёркиванием
- Иконки для каждой вкладки
- Плавные переходы
**Карточки серверов:**
- Закруглённые углы
- Тени при наведении
- Цветовая индикация выбранного сервера
- Плавные анимации
## Темы
### Тёмная (Dark)
- Основной: Серый 900
- Вторичный: Серый 800
- Акцент: Синий 600
### Светлая (Light)
- Основной: Серый 50
- Вторичный: Белый
- Акцент: Синий 600
### Фиолетовая (Purple)
- Основной: Фиолетовый 950
- Вторичный: Фиолетовый 900
- Акцент: Фиолетовый 600
### Синяя (Blue)
- Основной: Синий 950
- Вторичный: Синий 900
- Акцент: Синий 500
### Зелёная (Green)
- Основной: Зелёный 950
- Вторичный: Зелёный 900
- Акцент: Зелёный 600
## Файлы
- `frontend/src/App_modern.jsx` - новый App с темами
- `frontend/src/themes.js` - конфигурация тем
- `frontend/src/components/ThemeSelector.jsx` - селектор тем
## Что дальше
Компоненты (Console, FileManager, Stats, ServerSettings) нужно обновить для поддержки тем.
Они получают `theme` prop с текущей темой.
Пример использования в компоненте:
```jsx
export default function MyComponent({ theme }) {
return (
<div className={`${theme.primary} ${theme.text}`}>
<button className={`${theme.accent} ${theme.accentHover}`}>
Кнопка
</button>
</div>
);
}
```
## Готово! 🎉
Теперь у вас современный интерфейс с системой тем!

View File

@@ -1,8 +0,0 @@
# Секретный ключ для JWT (сгенерируйте свой!)
SECRET_KEY=your-secret-key-here-change-this-in-production
# Алгоритм шифрования
ALGORITHM=HS256
# Время жизни токена в минутах
ACCESS_TOKEN_EXPIRE_MINUTES=43200

79
backend/Dockerfile Normal file
View File

@@ -0,0 +1,79 @@
# ================================
# MC Panel Backend - Production Dockerfile
# ================================
FROM python:3.11-slim AS production
# Метаданные
LABEL maintainer="MC Panel Team" \
version="2.0.0" \
description="MC Panel Backend - FastAPI Server" \
component="backend"
# Переменные окружения
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PORT=8000 \
WORKERS=1 \
DEBIAN_FRONTEND=noninteractive
# Устанавливаем системные зависимости
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
procps \
ca-certificates \
tini \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Создаем пользователя для безопасности
RUN groupadd -r -g 1000 mcpanel && \
useradd -r -u 1000 -g mcpanel -d /app -s /bin/bash mcpanel
# Создаем рабочую директорию
WORKDIR /app
# Копируем requirements и устанавливаем зависимости
COPY requirements.txt ./
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Копируем исходный код
COPY --chown=mcpanel:mcpanel . ./
# Создаем необходимые директории
RUN mkdir -p \
servers \
data \
logs \
&& touch users.json tickets.json
# Создаем конфигурационные файлы по умолчанию если их нет
RUN [ ! -f users.json ] && echo '{}' > users.json || true && \
[ ! -f tickets.json ] && echo '{}' > tickets.json || true
# Устанавливаем права доступа
RUN chown -R mcpanel:mcpanel /app && \
chmod -R 755 /app && \
chmod +x main.py
# Переключаемся на непривилегированного пользователя
USER mcpanel
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:${PORT}/health 2>/dev/null || \
curl -f http://localhost:${PORT}/ 2>/dev/null || exit 1
# Expose порт
EXPOSE 8000
# Volumes для персистентных данных
VOLUME ["/app/servers", "/app/data", "/app/logs"]
# Используем tini как init процесс
ENTRYPOINT ["/usr/bin/tini", "--"]
# Команда запуска
CMD ["sh", "-c", "python -m uvicorn main:app --host 0.0.0.0 --port ${PORT} --workers ${WORKERS}"]

336
backend/daemons.py Normal file
View File

@@ -0,0 +1,336 @@
"""
Управление демонами (удаленными серверами)
"""
from fastapi import APIRouter, HTTPException, Depends, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from typing import List, Optional
import json
import httpx
from pathlib import Path
from jose import JWTError, jwt
router = APIRouter()
security = HTTPBearer(auto_error=False)
# Файл с конфигурацией демонов
DAEMONS_FILE = Path("data/daemons.json")
DAEMONS_FILE.parent.mkdir(exist_ok=True)
# Файл с пользователями - проверяем оба возможных пути
if Path("users.json").exists():
USERS_FILE = Path("users.json")
elif Path("backend/users.json").exists():
USERS_FILE = Path("backend/users.json")
else:
USERS_FILE = Path("users.json") # По умолчанию
# Настройки JWT (должны совпадать с main.py)
SECRET_KEY = "your-secret-key-change-this-in-production-12345"
ALGORITHM = "HS256"
def load_users_dict():
"""Загрузить пользователей из файла"""
if USERS_FILE.exists():
with open(USERS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
return {}
def get_current_user_from_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Получить текущего пользователя из токена"""
if not credentials:
raise HTTPException(status_code=401, detail="Требуется авторизация")
token = credentials.credentials
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(status_code=401, detail="Неверный токен")
# Пытаемся получить роль из токена
role = payload.get("role")
print(f"[DEBUG] Username from token: {username}")
print(f"[DEBUG] Role from token: {role}")
# Если роли нет в токене, загружаем из базы
if not role:
print(f"[DEBUG] Role not in token, loading from database...")
print(f"[DEBUG] USERS_FILE path: {USERS_FILE}")
print(f"[DEBUG] USERS_FILE exists: {USERS_FILE.exists()}")
users = load_users_dict()
print(f"[DEBUG] Loaded users: {list(users.keys())}")
if username not in users:
raise HTTPException(status_code=401, detail="Пользователь не найден")
role = users[username].get("role", "user")
print(f"[DEBUG] Role from database: {role}")
print(f"[DEBUG] Final role: {role}")
return {"username": username, "role": role}
except JWTError as e:
print(f"[DEBUG] JWT Error: {e}")
raise HTTPException(status_code=401, detail="Неверный токен")
class DaemonCreate(BaseModel):
name: str
address: str
port: int
key: str
remarks: Optional[str] = ""
class DaemonUpdate(BaseModel):
name: Optional[str] = None
address: Optional[str] = None
port: Optional[int] = None
key: Optional[str] = None
remarks: Optional[str] = None
def load_daemons():
"""Загрузить список демонов"""
if not DAEMONS_FILE.exists():
return {}
with open(DAEMONS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
def save_daemons(daemons: dict):
"""Сохранить список демонов"""
with open(DAEMONS_FILE, 'w', encoding='utf-8') as f:
json.dump(daemons, f, indent=2, ensure_ascii=False)
async def check_daemon_connection(address: str, port: int, key: str) -> dict:
"""Проверить подключение к демону"""
url = f"http://{address}:{port}/api/status"
headers = {"Authorization": f"Bearer {key}"}
print(f"[DEBUG] Checking daemon connection:")
print(f"[DEBUG] URL: {url}")
print(f"[DEBUG] Key: {key[:20]}...")
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url, headers=headers)
print(f"[DEBUG] Status: {response.status_code}")
if response.status_code == 200:
data = response.json()
print(f"[DEBUG] Response: {data}")
return data
else:
print(f"[DEBUG] Error response: {response.text}")
raise HTTPException(status_code=400, detail=f"Failed to connect to daemon: {response.status_code}")
except httpx.RequestError as e:
print(f"[DEBUG] Connection error: {e}")
raise HTTPException(status_code=400, detail=f"Connection error: {str(e)}")
@router.get("/api/daemons")
async def get_daemons(current_user: dict = Depends(get_current_user_from_token)):
"""Получить список всех демонов"""
# Только админы и владельцы могут видеть демоны
if current_user["role"] not in ["owner", "admin"]:
raise HTTPException(status_code=403, detail="Access denied")
daemons = load_daemons()
# Проверяем статус каждого демона
result = []
for daemon_id, daemon in daemons.items():
daemon_info = {
"id": daemon_id,
**daemon,
"status": "offline"
}
try:
# Пытаемся получить статус
status = await check_daemon_connection(
daemon["address"],
daemon["port"],
daemon["key"]
)
daemon_info["status"] = "online"
daemon_info["system"] = status.get("system", {})
daemon_info["servers"] = status.get("servers", {})
except:
pass
result.append(daemon_info)
return result
@router.post("/api/daemons")
async def create_daemon(
daemon: DaemonCreate,
current_user: dict = Depends(get_current_user_from_token)
):
"""Добавить новый демон"""
if current_user["role"] not in ["owner", "admin"]:
raise HTTPException(status_code=403, detail="Access denied")
# Проверяем подключение
await check_daemon_connection(daemon.address, daemon.port, daemon.key)
daemons = load_daemons()
# Генерируем ID
daemon_id = f"daemon-{len(daemons) + 1}"
daemons[daemon_id] = {
"name": daemon.name,
"address": daemon.address,
"port": daemon.port,
"key": daemon.key,
"remarks": daemon.remarks,
"created_at": str(Path().cwd()) # Временная метка
}
save_daemons(daemons)
return {
"success": True,
"daemon_id": daemon_id,
"message": "Daemon added successfully"
}
@router.get("/api/daemons/{daemon_id}")
async def get_daemon(
daemon_id: str,
current_user: dict = Depends(get_current_user_from_token)
):
"""Получить информацию о демоне"""
if current_user["role"] not in ["owner", "admin"]:
raise HTTPException(status_code=403, detail="Access denied")
daemons = load_daemons()
if daemon_id not in daemons:
raise HTTPException(status_code=404, detail="Daemon not found")
daemon = daemons[daemon_id]
# Получаем статус
try:
status = await check_daemon_connection(
daemon["address"],
daemon["port"],
daemon["key"]
)
daemon["status"] = "online"
daemon["system"] = status.get("system", {})
daemon["servers"] = status.get("servers", {})
except:
daemon["status"] = "offline"
return {
"id": daemon_id,
**daemon
}
@router.put("/api/daemons/{daemon_id}")
async def update_daemon(
daemon_id: str,
daemon_update: DaemonUpdate,
current_user: dict = Depends(get_current_user_from_token)
):
"""Обновить демон"""
if current_user["role"] not in ["owner", "admin"]:
raise HTTPException(status_code=403, detail="Access denied")
daemons = load_daemons()
if daemon_id not in daemons:
raise HTTPException(status_code=404, detail="Daemon not found")
# Обновляем поля
daemon = daemons[daemon_id]
if daemon_update.name:
daemon["name"] = daemon_update.name
if daemon_update.address:
daemon["address"] = daemon_update.address
if daemon_update.port:
daemon["port"] = daemon_update.port
if daemon_update.key:
daemon["key"] = daemon_update.key
if daemon_update.remarks is not None:
daemon["remarks"] = daemon_update.remarks
# Проверяем подключение с новыми данными
await check_daemon_connection(
daemon["address"],
daemon["port"],
daemon["key"]
)
save_daemons(daemons)
return {
"success": True,
"message": "Daemon updated successfully"
}
@router.delete("/api/daemons/{daemon_id}")
async def delete_daemon(
daemon_id: str,
current_user: dict = Depends(get_current_user_from_token)
):
"""Удалить демон"""
if current_user["role"] not in ["owner", "admin"]:
raise HTTPException(status_code=403, detail="Access denied")
daemons = load_daemons()
if daemon_id not in daemons:
raise HTTPException(status_code=404, detail="Daemon not found")
del daemons[daemon_id]
save_daemons(daemons)
return {
"success": True,
"message": "Daemon deleted successfully"
}
@router.get("/api/daemons/{daemon_id}/servers")
async def get_daemon_servers(
daemon_id: str,
current_user: dict = Depends(get_current_user_from_token)
):
"""Получить список серверов на демоне"""
daemons = load_daemons()
if daemon_id not in daemons:
raise HTTPException(status_code=404, detail="Daemon not found")
daemon = daemons[daemon_id]
url = f"http://{daemon['address']}:{daemon['port']}/api/servers"
headers = {"Authorization": f"Bearer {daemon['key']}"}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
if response.status_code == 200:
return response.json()
else:
raise HTTPException(status_code=400, detail="Failed to get servers from daemon")
except httpx.RequestError as e:
raise HTTPException(status_code=400, detail=f"Connection error: {str(e)}")

10
backend/data/daemons.json Normal file
View File

@@ -0,0 +1,10 @@
{
"daemon-1": {
"name": "Test",
"address": "127.0.0.1",
"port": 24444,
"key": "JLgYFjTlFOqdyT49vmCqlXrLAuVE6FjiCdqf3zsZfr4",
"remarks": "",
"created_at": "D:\\Desktop\\adadad"
}
}

File diff suppressed because it is too large Load Diff

242
backend/migrate_users.py Normal file
View File

@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""
Скрипт миграции пользователей для MC Panel v1.1.0
Добавляет роль владельца и систему прав
"""
import json
from pathlib import Path
from datetime import datetime
def migrate_users():
"""Миграция пользователей на новую систему прав"""
users_file = Path("users.json")
# Проверка существования файла
if not users_file.exists():
print("❌ Файл users.json не найден")
print(" Создайте файл users.json или запустите панель для автоматического создания")
return False
# Создание backup
backup_file = Path(f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
try:
with open(users_file, "r", encoding="utf-8") as f:
backup_data = f.read()
with open(backup_file, "w", encoding="utf-8") as f:
f.write(backup_data)
print(f"✅ Backup создан: {backup_file}")
except Exception as e:
print(f"❌ Ошибка создания backup: {e}")
return False
# Загрузка пользователей
try:
with open(users_file, "r", encoding="utf-8") as f:
users_data = json.load(f)
except json.JSONDecodeError:
print("❌ Ошибка чтения users.json - неверный формат JSON")
return False
except Exception as e:
print(f"❌ Ошибка чтения файла: {e}")
return False
# Проверка формата (объект или список)
if isinstance(users_data, dict):
# Формат: {"username": {...}}
users_list = list(users_data.values())
is_dict_format = True
print(" Обнаружен формат: объект (словарь)")
elif isinstance(users_data, list):
# Формат: [{...}, {...}]
users_list = users_data
is_dict_format = False
print(" Обнаружен формат: список")
else:
print("❌ Неизвестный формат users.json")
return False
if not users_list:
print(" Нет пользователей для миграции")
return True
print(f"\n📊 Найдено пользователей: {len(users_list)}")
print("=" * 50)
# Миграция первого пользователя (владелец)
if users_list:
first_user = users_list[0]
print(f"\n👑 Назначение владельца: {first_user.get('username', 'Unknown')}")
first_user["role"] = "owner"
first_user["permissions"] = {
"manage_users": True,
"manage_roles": True,
"manage_servers": True,
"manage_tickets": True,
"manage_files": True,
"delete_users": True,
"view_all_resources": True
}
if "resource_access" not in first_user:
first_user["resource_access"] = {
"servers": first_user.get("servers", []),
"tickets": [],
"files": []
}
# Миграция остальных пользователей
for i, user in enumerate(users_list[1:], start=2):
username = user.get("username", f"User{i}")
current_role = user.get("role", "user")
print(f"\n👤 Пользователь {i}: {username}")
print(f" Текущая роль: {current_role}")
# Установка роли по умолчанию
if "role" not in user or user["role"] not in ["admin", "support", "user", "banned"]:
user["role"] = "user"
print(f" ➜ Установлена роль: user")
# Добавление прав
if "permissions" not in user:
if user["role"] == "admin":
user["permissions"] = {
"manage_users": True,
"manage_roles": False,
"manage_servers": True,
"manage_tickets": True,
"manage_files": True,
"delete_users": False,
"view_all_resources": True
}
print(" ➜ Добавлены права администратора")
elif user["role"] == "support":
user["permissions"] = {
"manage_users": False,
"manage_roles": False,
"manage_servers": False,
"manage_tickets": True,
"manage_files": False,
"delete_users": False,
"view_all_resources": False
}
print(" ➜ Добавлены права поддержки")
elif user["role"] == "banned":
user["permissions"] = {
"manage_users": False,
"manage_roles": False,
"manage_servers": False,
"manage_tickets": False,
"manage_files": False,
"delete_users": False,
"view_all_resources": False
}
print(" ➜ Пользователь заблокирован")
else: # user
user["permissions"] = {
"manage_users": False,
"manage_roles": False,
"manage_servers": True,
"manage_tickets": True,
"manage_files": True,
"delete_users": False,
"view_all_resources": False
}
print(" ➜ Добавлены права пользователя")
# Добавление доступа к ресурсам
if "resource_access" not in user:
user["resource_access"] = {
"servers": user.get("servers", []),
"tickets": [],
"files": []
}
print(" ➜ Добавлен доступ к ресурсам")
# Сохранение в правильном формате
try:
if is_dict_format:
# Сохраняем обратно как объект
users_dict = {user["username"]: user for user in users_list}
with open(users_file, "w", encoding="utf-8") as f:
json.dump(users_dict, f, indent=2, ensure_ascii=False)
else:
# Сохраняем как список
with open(users_file, "w", encoding="utf-8") as f:
json.dump(users_list, f, indent=2, ensure_ascii=False)
print("\n" + "=" * 50)
print("✅ Миграция успешно завершена!")
print(f"✅ Обновлено пользователей: {len(users_list)}")
print(f"👑 Владелец: {users_list[0]['username']}")
print(f"📁 Backup: {backup_file}")
return True
except Exception as e:
print(f"\n❌ Ошибка сохранения: {e}")
print(f" Восстановите из backup: {backup_file}")
return False
def show_users():
"""Показать список пользователей после миграции"""
users_file = Path("users.json")
if not users_file.exists():
print("❌ Файл users.json не найден")
return
try:
with open(users_file, "r", encoding="utf-8") as f:
users_data = json.load(f)
except Exception as e:
print(f"❌ Ошибка чтения файла: {e}")
return
# Преобразуем в список если это объект
if isinstance(users_data, dict):
users_list = list(users_data.values())
else:
users_list = users_data
print("\n" + "=" * 50)
print("📋 СПИСОК ПОЛЬЗОВАТЕЛЕЙ")
print("=" * 50)
for i, user in enumerate(users_list, start=1):
print(f"\n{i}. {user.get('username', 'Unknown')}")
print(f" Роль: {user.get('role', 'unknown')}")
print(f" Права:")
for perm, value in user.get('permissions', {}).items():
status = "" if value else ""
print(f" {status} {perm}")
# Показать доступ к ресурсам
resource_access = user.get('resource_access', {})
if resource_access:
servers = resource_access.get('servers', [])
if servers:
print(f" Серверы: {', '.join(servers)}")
if __name__ == "__main__":
print("=" * 50)
print("MC Panel - Миграция пользователей v1.1.0")
print("=" * 50)
# Запуск миграции
success = migrate_users()
if success:
# Показать результат
show_users()
print("\n" + "=" * 50)
print("📝 СЛЕДУЮЩИЕ ШАГИ:")
print("=" * 50)
print("1. Перезапустите панель")
print("2. Войдите как владелец")
print("3. Проверьте права пользователей")
print("4. Настройте доступ к ресурсам")
print("\n✨ Готово!")
else:
print("\n❌ Миграция не выполнена")
print(" Проверьте ошибки выше и попробуйте снова")

31
backend/oidc_config.py Normal file
View File

@@ -0,0 +1,31 @@
"""
Конфигурация OpenID Connect провайдеров
"""
import os
from typing import Dict, Any
# Конфигурация провайдеров OpenID Connect
OIDC_PROVIDERS = {
"zitadel": {
"name": "ZITADEL",
"client_id": os.getenv("ZITADEL_CLIENT_ID", ""),
"client_secret": os.getenv("ZITADEL_CLIENT_SECRET", ""),
"server_metadata_url": os.getenv("ZITADEL_ISSUER", "") + "/.well-known/openid-configuration",
"issuer": os.getenv("ZITADEL_ISSUER", ""),
"scopes": ["openid", "email", "profile"],
"icon": "🔐",
"color": "bg-purple-600 hover:bg-purple-700"
}
}
def get_enabled_providers() -> Dict[str, Dict[str, Any]]:
"""Получить список включённых провайдеров (с настроенными client_id)"""
enabled = {}
for provider_id, config in OIDC_PROVIDERS.items():
if config.get("client_id") and config.get("issuer"):
enabled[provider_id] = config
return enabled
def get_redirect_uri(provider_id: str, base_url: str = "http://localhost:8000") -> str:
"""Получить redirect URI для провайдера"""
return f"{base_url}/api/auth/oidc/{provider_id}/callback"

View File

@@ -7,6 +7,6 @@ python-multipart==0.0.6
pydantic==2.5.3
passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.0
authlib==1.3.0
httpx==0.26.0

58
backend/tickets.json Normal file
View File

@@ -0,0 +1,58 @@
{
"1": {
"id": "1",
"title": "ававп",
"description": "вап",
"author": "Root",
"status": "closed",
"created_at": "2026-01-16T09:30:34.640417",
"updated_at": "2026-01-16T09:35:07.708933",
"messages": [
{
"author": "Root",
"text": "вап",
"timestamp": "2026-01-16T09:30:34.640417"
},
{
"author": "Root",
"text": "ап",
"timestamp": "2026-01-16T09:30:38.095207"
},
{
"author": "Root",
"text": "вапвп",
"timestamp": "2026-01-16T09:30:40.438134"
},
{
"author": "Root",
"text": "вапвпвап",
"timestamp": "2026-01-16T09:30:42.195576"
},
{
"author": "Root",
"text": "вавап",
"timestamp": "2026-01-16T09:30:44.371194"
},
{
"author": "Root",
"text": "вапвап",
"timestamp": "2026-01-16T09:30:45.779656"
},
{
"author": "Root",
"text": "вавап",
"timestamp": "2026-01-16T09:30:47.736753"
},
{
"author": "system",
"text": "Статус изменён на: В работе",
"timestamp": "2026-01-16T09:35:01.602729"
},
{
"author": "system",
"text": "Статус изменён на: Закрыт",
"timestamp": "2026-01-16T09:35:07.708933"
}
]
}
}

View File

@@ -0,0 +1,320 @@
"""
API эндпоинты для управления пользователями (v1.1.0)
Требуется роль owner или admin
"""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional, List
import json
from pathlib import Path
router = APIRouter()
# Модели данных
class RoleChange(BaseModel):
role: str
class PermissionsUpdate(BaseModel):
permissions: dict
class ServerAccess(BaseModel):
server_name: str
class BanRequest(BaseModel):
reason: str = "Заблокирован администратором"
# Загрузка пользователей
def load_users():
users_file = Path("users.json")
if not users_file.exists():
return {}
with open(users_file, "r", encoding="utf-8") as f:
return json.load(f)
# Сохранение пользователей
def save_users(users):
with open("users.json", "w", encoding="utf-8") as f:
json.dump(users, f, indent=2, ensure_ascii=False)
# Проверка прав
def require_owner(current_user: dict):
if current_user.get("role") != "owner":
raise HTTPException(status_code=403, detail="Требуется роль владельца")
def require_admin_or_owner(current_user: dict):
if current_user.get("role") not in ["owner", "admin"]:
raise HTTPException(status_code=403, detail="Требуется роль администратора или владельца")
# 1. Получить список пользователей
@router.get("/api/users")
async def get_users(current_user: dict = Depends()):
require_admin_or_owner(current_user)
users = load_users()
# Возвращаем список пользователей (без паролей)
users_list = []
for username, user_data in users.items():
user_copy = user_data.copy()
user_copy.pop("password", None)
users_list.append(user_copy)
return users_list
# 2. Изменить роль пользователя
@router.put("/api/users/{username}/role")
async def change_user_role(username: str, role_data: RoleChange, current_user: dict = Depends()):
require_owner(current_user)
users = load_users()
if username not in users:
raise HTTPException(status_code=404, detail="Пользователь не найден")
if username == current_user.get("username"):
raise HTTPException(status_code=400, detail="Нельзя изменить свою роль")
# Проверка валидности роли
valid_roles = ["owner", "admin", "support", "user", "banned"]
if role_data.role not in valid_roles:
raise HTTPException(status_code=400, detail=f"Неверная роль. Доступные: {', '.join(valid_roles)}")
# Если назначается новый owner, текущий owner становится admin
if role_data.role == "owner":
for user in users.values():
if user.get("role") == "owner":
user["role"] = "admin"
# Изменяем роль
old_role = users[username].get("role", "user")
users[username]["role"] = role_data.role
# Обновляем права в зависимости от роли
if role_data.role == "owner":
users[username]["permissions"] = {
"manage_users": True,
"manage_roles": True,
"manage_servers": True,
"manage_tickets": True,
"manage_files": True,
"delete_users": True,
"view_all_resources": True
}
elif role_data.role == "admin":
users[username]["permissions"] = {
"manage_users": True,
"manage_roles": False,
"manage_servers": True,
"manage_tickets": True,
"manage_files": True,
"delete_users": False,
"view_all_resources": True
}
elif role_data.role == "support":
users[username]["permissions"] = {
"manage_users": False,
"manage_roles": False,
"manage_servers": False,
"manage_tickets": True,
"manage_files": False,
"delete_users": False,
"view_all_resources": False
}
elif role_data.role == "banned":
users[username]["permissions"] = {
"manage_users": False,
"manage_roles": False,
"manage_servers": False,
"manage_tickets": False,
"manage_files": False,
"delete_users": False,
"view_all_resources": False
}
else: # user
users[username]["permissions"] = {
"manage_users": False,
"manage_roles": False,
"manage_servers": True,
"manage_tickets": True,
"manage_files": True,
"delete_users": False,
"view_all_resources": False
}
save_users(users)
return {
"message": f"Роль пользователя {username} изменена с {old_role} на {role_data.role}",
"user": {
"username": username,
"role": role_data.role
}
}
# 3. Изменить права пользователя
@router.put("/api/users/{username}/permissions")
async def update_user_permissions(username: str, perms: PermissionsUpdate, current_user: dict = Depends()):
require_owner(current_user)
users = load_users()
if username not in users:
raise HTTPException(status_code=404, detail="Пользователь не найден")
users[username]["permissions"] = perms.permissions
save_users(users)
return {
"message": f"Права пользователя {username} обновлены",
"permissions": perms.permissions
}
# 4. Выдать доступ к серверу
@router.post("/api/users/{username}/access/servers")
async def grant_server_access(username: str, access: ServerAccess, current_user: dict = Depends()):
require_admin_or_owner(current_user)
users = load_users()
if username not in users:
raise HTTPException(status_code=404, detail="Пользователь не найден")
if "resource_access" not in users[username]:
users[username]["resource_access"] = {"servers": [], "tickets": [], "files": []}
if access.server_name not in users[username]["resource_access"]["servers"]:
users[username]["resource_access"]["servers"].append(access.server_name)
# Также добавляем в старое поле servers для совместимости
if "servers" not in users[username]:
users[username]["servers"] = []
if access.server_name not in users[username]["servers"]:
users[username]["servers"].append(access.server_name)
save_users(users)
return {
"message": f"Доступ к серверу {access.server_name} выдан пользователю {username}",
"server": access.server_name,
"user": username
}
# 5. Забрать доступ к серверу
@router.delete("/api/users/{username}/access/servers/{server_name}")
async def revoke_server_access(username: str, server_name: str, current_user: dict = Depends()):
require_admin_or_owner(current_user)
users = load_users()
if username not in users:
raise HTTPException(status_code=404, detail="Пользователь не найден")
if "resource_access" in users[username] and "servers" in users[username]["resource_access"]:
if server_name in users[username]["resource_access"]["servers"]:
users[username]["resource_access"]["servers"].remove(server_name)
# Также удаляем из старого поля servers
if "servers" in users[username] and server_name in users[username]["servers"]:
users[username]["servers"].remove(server_name)
save_users(users)
return {
"message": f"Доступ к серверу {server_name} отозван у пользователя {username}",
"server": server_name,
"user": username
}
# 6. Удалить пользователя
@router.delete("/api/users/{username}")
async def delete_user(username: str, current_user: dict = Depends()):
require_owner(current_user)
users = load_users()
if username not in users:
raise HTTPException(status_code=404, detail="Пользователь не найден")
if username == current_user.get("username"):
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
if users[username].get("role") == "owner":
raise HTTPException(status_code=400, detail="Нельзя удалить владельца")
del users[username]
save_users(users)
return {
"message": f"Пользователь {username} удалён",
"username": username
}
# 7. Заблокировать пользователя
@router.post("/api/users/{username}/ban")
async def ban_user(username: str, ban_data: BanRequest, current_user: dict = Depends()):
require_admin_or_owner(current_user)
users = load_users()
if username not in users:
raise HTTPException(status_code=404, detail="Пользователь не найден")
if username == current_user.get("username"):
raise HTTPException(status_code=400, detail="Нельзя заблокировать самого себя")
if users[username].get("role") == "owner":
raise HTTPException(status_code=400, detail="Нельзя заблокировать владельца")
users[username]["role"] = "banned"
users[username]["permissions"] = {
"manage_users": False,
"manage_roles": False,
"manage_servers": False,
"manage_tickets": False,
"manage_files": False,
"delete_users": False,
"view_all_resources": False
}
users[username]["ban_reason"] = ban_data.reason
save_users(users)
return {
"message": f"Пользователь {username} заблокирован",
"username": username,
"reason": ban_data.reason
}
# 8. Разблокировать пользователя
@router.post("/api/users/{username}/unban")
async def unban_user(username: str, current_user: dict = Depends()):
require_admin_or_owner(current_user)
users = load_users()
if username not in users:
raise HTTPException(status_code=404, detail="Пользователь не найден")
if users[username].get("role") != "banned":
raise HTTPException(status_code=400, detail="Пользователь не заблокирован")
users[username]["role"] = "user"
users[username]["permissions"] = {
"manage_users": False,
"manage_roles": False,
"manage_servers": True,
"manage_tickets": True,
"manage_files": True,
"delete_users": False,
"view_all_resources": False
}
users[username].pop("ban_reason", None)
save_users(users)
return {
"message": f"Пользователь {username} разблокирован",
"username": username
}

View File

@@ -1,17 +1,22 @@
{
"admin": {
"username": "admin",
"password": "$2b$12$0AJU/Cc6vI.gqUY6BfU8E.6adiK3QS/1EyZJ98MAExiHAf4HOhn4C",
"role": "admin",
"servers": []
"password": "$2b$12$PAaomoUWn3Ip5ov.S/uYPeTIRiDMq7DbA57ahyYQnw3QHT2zuYMlG",
"role": "owner",
"servers": [],
"permissions": {
"manage_users": true,
"manage_roles": true,
"manage_servers": true,
"manage_tickets": true,
"manage_files": true,
"delete_users": true,
"view_all_resources": true
},
"MihailPrud": {
"username": "MihailPrud",
"password": "$2b$12$GfbQN4scE.b.mtUHofWWE.Dn1tQpT1zwLAxeICv90sHP4zGv0dc2G",
"role": "user",
"servers": [
"test",
"nya"
]
"resource_access": {
"servers": [],
"tickets": [],
"files": []
}
}
}

11
daemon/.env Normal file
View File

@@ -0,0 +1,11 @@
# Daemon Configuration
DAEMON_ID=daemon-1
DAEMON_NAME=Main
DAEMON_PORT=24444
DAEMON_KEY=JLgYFjTlFOqdyT49vmCqlXrLAuVE6FjiCdqf3zsZfr4
# Panel Connection (optional, for WebSocket)
PANEL_URL=ws://0.0.0.0:8000
# Servers Directory
SERVERS_DIR=./servers

20
daemon/.env.example Normal file
View File

@@ -0,0 +1,20 @@
# MC Panel Daemon Configuration
# Уникальный ID демона
DAEMON_ID=daemon-1
# Название демона (отображается в панели)
DAEMON_NAME=Main Server
# Порт, на котором будет работать демон
DAEMON_PORT=24444
# Секретный ключ для аутентификации (должен совпадать с ключом в панели)
# Сгенерируйте случайный ключ: python -c "import secrets; print(secrets.token_urlsafe(32))"
DAEMON_KEY=your-secret-key-here
# URL основной панели (для WebSocket подключения, опционально)
PANEL_URL=http://your-panel-url:8000
# Директория для серверов
SERVERS_DIR=./servers

195
daemon/README.md Normal file
View File

@@ -0,0 +1,195 @@
# MC Panel Daemon
Удаленный демон для управления серверами Minecraft. Устанавливается на отдельные физические серверы и подключается к основной панели управления.
## Установка
### Windows
1. Установите Python 3.8 или выше
2. Скопируйте папку `daemon` на удаленный сервер
3. Откройте командную строку в папке daemon
4. Запустите установку зависимостей:
```
install.bat
```
### Linux
1. Установите Python 3.8 или выше
2. Скопируйте папку `daemon` на удаленный сервер
3. Установите зависимости:
```bash
pip install -r requirements.txt
```
## Настройка
1. Скопируйте `.env.example` в `.env`:
```
copy .env.example .env
```
2. Отредактируйте `.env` файл:
```env
DAEMON_ID=daemon-1
DAEMON_NAME=Main Server
DAEMON_PORT=24444
DAEMON_KEY=your-secret-key-here
SERVERS_DIR=./servers
```
- `DAEMON_ID` - уникальный ID демона
- `DAEMON_NAME` - отображаемое имя демона
- `DAEMON_PORT` - порт для API (по умолчанию 24444)
- `DAEMON_KEY` - секретный ключ для аутентификации
- `SERVERS_DIR` - директория для серверов
3. Создайте секретный ключ:
```python
import secrets
print(secrets.token_urlsafe(32))
```
## Запуск
### Windows
```
start.bat
```
### Linux
```bash
python main.py
```
### Как сервис (Linux)
Создайте файл `/etc/systemd/system/mcpanel-daemon.service`:
```ini
[Unit]
Description=MC Panel Daemon
After=network.target
[Service]
Type=simple
User=mcpanel
WorkingDirectory=/path/to/daemon
ExecStart=/usr/bin/python3 /path/to/daemon/main.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Запустите сервис:
```bash
sudo systemctl enable mcpanel-daemon
sudo systemctl start mcpanel-daemon
sudo systemctl status mcpanel-daemon
```
## Подключение к панели
1. Откройте основную панель управления
2. Перейдите в раздел "Демоны"
3. Нажмите "Добавить демон"
4. Заполните данные:
- **Название**: Main Server
- **IP адрес**: IP адрес сервера с демоном
- **Порт**: 24444 (или ваш порт)
- **Ключ демона**: ваш DAEMON_KEY из .env
5. Нажмите "Добавить"
## API Endpoints
Демон предоставляет следующие API endpoints:
- `GET /` - Информация о демоне
- `GET /api/status` - Статус демона и системы
- `GET /api/servers` - Список серверов
- `POST /api/servers/{name}/start` - Запустить сервер
- `POST /api/servers/{name}/stop` - Остановить сервер
- `POST /api/servers/{name}/command` - Отправить команду
- `GET /api/servers/{name}/stats` - Статистика сервера
## Безопасность
1. **Используйте сильный ключ** - генерируйте случайный ключ длиной минимум 32 символа
2. **Настройте файрвол** - разрешите доступ к порту демона только с IP основной панели
3. **Используйте HTTPS** - в продакшене используйте reverse proxy (nginx) с SSL
4. **Регулярно обновляйте** - следите за обновлениями и устанавливайте их
## Пример конфигурации nginx (с SSL)
```nginx
server {
listen 443 ssl http2;
server_name daemon.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:24444;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Структура директорий
```
daemon/
├── main.py # Основной файл демона
├── requirements.txt # Зависимости Python
├── .env # Конфигурация (создайте из .env.example)
├── .env.example # Пример конфигурации
├── install.bat # Скрипт установки (Windows)
├── start.bat # Скрипт запуска (Windows)
├── README.md # Эта документация
└── servers/ # Директория с серверами
├── server1/
│ ├── config.json
│ └── ...
└── server2/
├── config.json
└── ...
```
## Формат config.json для сервера
```json
{
"name": "server1",
"displayName": "My Minecraft Server",
"startCommand": "java -Xmx2G -Xms1G -jar server.jar nogui"
}
```
## Troubleshooting
### Демон не запускается
- Проверьте, что Python установлен: `python --version`
- Проверьте, что все зависимости установлены: `pip list`
- Проверьте логи в консоли
### Панель не может подключиться к демону
- Проверьте, что демон запущен
- Проверьте файрвол и порты
- Проверьте, что ключ в панели совпадает с DAEMON_KEY
- Проверьте IP адрес и порт
### Сервер не запускается
- Проверьте startCommand в config.json
- Проверьте права доступа к файлам
- Проверьте логи сервера
## Поддержка
Если у вас возникли проблемы, создайте тикет в системе поддержки панели.

9
daemon/install.bat Normal file
View File

@@ -0,0 +1,9 @@
@echo off
echo Installing MC Panel Daemon dependencies...
pip install -r requirements.txt
echo.
echo Installation complete!
echo.
echo Please configure .env file before starting the daemon
echo Copy .env.example to .env and edit it
pause

307
daemon/main.py Normal file
View File

@@ -0,0 +1,307 @@
"""
MC Panel Daemon - Удаленный демон для управления серверами
Устанавливается на отдельные серверы и подключается к основной панели
"""
import asyncio
import json
import os
import psutil
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
import websockets
from fastapi import FastAPI, HTTPException, Header
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from dotenv import load_dotenv
load_dotenv()
app = FastAPI(title="MC Panel Daemon")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Конфигурация
DAEMON_ID = os.getenv("DAEMON_ID", "daemon-1")
DAEMON_NAME = os.getenv("DAEMON_NAME", "Main Server")
DAEMON_PORT = int(os.getenv("DAEMON_PORT", "24444"))
DAEMON_KEY = os.getenv("DAEMON_KEY", "") # Ключ для аутентификации
PANEL_URL = os.getenv("PANEL_URL", "") # URL основной панели для WebSocket
SERVERS_DIR = Path(os.getenv("SERVERS_DIR", "./servers"))
# Создаем директорию для серверов
SERVERS_DIR.mkdir(exist_ok=True)
# Хранилище процессов серверов
server_processes: Dict[str, subprocess.Popen] = {}
def verify_key(authorization: str = Header(None)) -> bool:
"""Проверка API ключа"""
if not DAEMON_KEY:
return True # Если ключ не установлен, разрешаем доступ
if not authorization:
raise HTTPException(status_code=401, detail="Missing authorization header")
if authorization != f"Bearer {DAEMON_KEY}":
raise HTTPException(status_code=403, detail="Invalid daemon key")
return True
@app.get("/")
async def root():
"""Информация о демоне"""
return {
"daemon_id": DAEMON_ID,
"daemon_name": DAEMON_NAME,
"status": "online",
"version": "1.0.0"
}
@app.get("/api/status")
async def get_status(authorization: str = Header(None)):
"""Получить статус демона и системы"""
# Проверка ключа опциональна для статуса
if DAEMON_KEY and authorization:
if authorization != f"Bearer {DAEMON_KEY}":
raise HTTPException(status_code=403, detail="Invalid daemon key")
cpu_percent = psutil.cpu_percent(interval=1)
memory = psutil.virtual_memory()
disk = psutil.disk_usage('/')
return {
"daemon_id": DAEMON_ID,
"daemon_name": DAEMON_NAME,
"status": "online",
"system": {
"cpu_usage": cpu_percent,
"memory_total": memory.total,
"memory_used": memory.used,
"memory_percent": memory.percent,
"disk_total": disk.total,
"disk_used": disk.used,
"disk_percent": disk.percent
},
"servers": {
"total": len(list(SERVERS_DIR.iterdir())),
"running": len(server_processes)
},
"timestamp": datetime.now().isoformat()
}
@app.get("/api/servers")
async def list_servers(authorization: str = Header(None)):
"""Список всех серверов на этом демоне"""
verify_key(authorization)
servers = []
for server_dir in SERVERS_DIR.iterdir():
if server_dir.is_dir():
config_file = server_dir / "config.json"
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
servers.append({
"name": server_dir.name,
"display_name": config.get("displayName", server_dir.name),
"status": "running" if server_dir.name in server_processes else "stopped",
"daemon_id": DAEMON_ID
})
return servers
@app.post("/api/servers/{server_name}/start")
async def start_server(server_name: str, authorization: str = Header(None)):
"""Запустить сервер"""
verify_key(authorization)
server_dir = SERVERS_DIR / server_name
if not server_dir.exists():
raise HTTPException(status_code=404, detail="Server not found")
if server_name in server_processes:
raise HTTPException(status_code=400, detail="Server already running")
config_file = server_dir / "config.json"
if not config_file.exists():
raise HTTPException(status_code=400, detail="Server config not found")
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
start_command = config.get("startCommand", "")
if not start_command:
raise HTTPException(status_code=400, detail="Start command not configured")
try:
# Запускаем процесс
process = subprocess.Popen(
start_command,
shell=True,
cwd=str(server_dir),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.PIPE
)
server_processes[server_name] = process
return {
"success": True,
"message": f"Server {server_name} started",
"pid": process.pid
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to start server: {str(e)}")
@app.post("/api/servers/{server_name}/stop")
async def stop_server(server_name: str, authorization: str = Header(None)):
"""Остановить сервер"""
verify_key(authorization)
if server_name not in server_processes:
raise HTTPException(status_code=400, detail="Server not running")
try:
process = server_processes[server_name]
process.terminate()
# Ждем завершения процесса
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
del server_processes[server_name]
return {
"success": True,
"message": f"Server {server_name} stopped"
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to stop server: {str(e)}")
@app.post("/api/servers/{server_name}/command")
async def send_command(server_name: str, command: dict, authorization: str = Header(None)):
"""Отправить команду в консоль сервера"""
verify_key(authorization)
if server_name not in server_processes:
raise HTTPException(status_code=400, detail="Server not running")
try:
process = server_processes[server_name]
cmd = command.get("command", "")
if process.stdin:
process.stdin.write(f"{cmd}\n".encode())
process.stdin.flush()
return {
"success": True,
"message": "Command sent"
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to send command: {str(e)}")
@app.get("/api/servers/{server_name}/stats")
async def get_server_stats(server_name: str, authorization: str = Header(None)):
"""Получить статистику сервера"""
verify_key(authorization)
server_dir = SERVERS_DIR / server_name
if not server_dir.exists():
raise HTTPException(status_code=404, detail="Server not found")
is_running = server_name in server_processes
cpu = 0
memory = 0
if is_running:
try:
process = server_processes[server_name]
p = psutil.Process(process.pid)
cpu = p.cpu_percent(interval=0.1)
memory = p.memory_info().rss / 1024 / 1024 # MB
except:
pass
# Размер директории
disk_usage = sum(f.stat().st_size for f in server_dir.rglob('*') if f.is_file()) / 1024 / 1024 # MB
return {
"status": "running" if is_running else "stopped",
"cpu": cpu,
"memory": memory,
"disk": disk_usage
}
@app.post("/api/servers/create")
async def create_server_on_daemon(data: dict, authorization: str = Header(None)):
"""Создать сервер на этом демоне"""
verify_key(authorization)
server_name = data.get("name", "").strip()
if not server_name:
raise HTTPException(status_code=400, detail="Server name is required")
server_path = SERVERS_DIR / server_name
if server_path.exists():
raise HTTPException(status_code=400, detail="Server already exists")
try:
server_path.mkdir(parents=True)
# Сохраняем конфигурацию
config = {
"name": server_name,
"displayName": data.get("displayName", server_name),
"startCommand": data.get("startCommand", "java -Xmx2G -jar server.jar nogui"),
"owner": data.get("owner", "unknown")
}
config_file = server_path / "config.json"
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
return {"message": "Server created successfully", "name": server_name}
except Exception as e:
# Если что-то пошло не так, удаляем папку
if server_path.exists():
import shutil
shutil.rmtree(server_path)
raise HTTPException(status_code=500, detail=f"Failed to create server: {str(e)}")
if __name__ == "__main__":
print(f"Starting MC Panel Daemon: {DAEMON_NAME} ({DAEMON_ID})")
print(f"Port: {DAEMON_PORT}")
print(f"Servers directory: {SERVERS_DIR}")
uvicorn.run(
app,
host="0.0.0.0",
port=DAEMON_PORT,
log_level="info"
)

5
daemon/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
websockets==12.0
psutil==5.9.6
python-dotenv==1.0.0

4
daemon/start.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
echo Starting MC Panel Daemon...
python main.py
pause

61
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,61 @@
version: '3.8'
services:
# Backend для разработки
backend-dev:
build:
context: ./backend
dockerfile: Dockerfile
target: production
container_name: mc-panel-backend-dev
restart: unless-stopped
ports:
- "8000:8000"
environment:
- PORT=8000
- WORKERS=1
- PYTHONPATH=/app
- DEBUG=true
- LOG_LEVEL=DEBUG
volumes:
# Монтируем исходный код для hot reload
- ./backend:/app
- mc_servers_dev:/app/servers
- mc_data_dev:/app/data
- mc_logs_dev:/app/logs
networks:
- mc-panel-dev
command: ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
# Frontend для разработки
frontend-dev:
build:
context: ./frontend
dockerfile: Dockerfile
target: development
container_name: mc-panel-frontend-dev
restart: unless-stopped
ports:
- "5173:5173"
volumes:
# Монтируем исходный код для hot reload
- ./frontend:/app
- /app/node_modules
networks:
- mc-panel-dev
environment:
- VITE_API_URL=http://localhost:8000
depends_on:
- backend-dev
volumes:
mc_servers_dev:
driver: local
mc_data_dev:
driver: local
mc_logs_dev:
driver: local
networks:
mc-panel-dev:
driver: bridge

View File

@@ -0,0 +1,65 @@
version: '3.8'
services:
# Backend сервис (локальная сборка)
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: production
container_name: mc-panel-backend-local
restart: unless-stopped
ports:
- "8000:8000"
environment:
- PORT=8000
- WORKERS=2
- PYTHONPATH=/app
- DEBUG=false
env_file:
- ./backend/.env
volumes:
- mc_servers_local:/app/servers
- mc_data_local:/app/data
- mc_logs_local:/app/logs
networks:
- mc-panel-local
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# Frontend сервис (локальная сборка)
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: production
container_name: mc-panel-frontend-local
restart: unless-stopped
ports:
- "80:80"
depends_on:
- backend
networks:
- mc-panel-local
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
mc_servers_local:
driver: local
mc_data_local:
driver: local
mc_logs_local:
driver: local
networks:
mc-panel-local:
driver: bridge

59
docker-compose.yml Normal file
View File

@@ -0,0 +1,59 @@
version: '3.8'
services:
# Backend сервис
backend:
image: registry.nevetime.ru/mc-panel-backend:${IMAGE_TAG:-latest}
container_name: mc-panel-backend
restart: unless-stopped
ports:
- "8000:8000"
environment:
- PORT=8000
- WORKERS=2
- PYTHONPATH=/app
- DEBUG=false
env_file:
- ./backend/.env
volumes:
- mc_servers:/app/servers
- mc_data:/app/data
- mc_logs:/app/logs
networks:
- mc-panel-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# Frontend сервис
frontend:
image: registry.nevetime.ru/mc-panel-frontend:${IMAGE_TAG:-latest}
container_name: mc-panel-frontend
restart: unless-stopped
ports:
- "80:80"
depends_on:
- backend
networks:
- mc-panel-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
mc_servers:
driver: local
mc_data:
driver: local
mc_logs:
driver: local
networks:
mc-panel-network:
driver: bridge

207
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,207 @@
# ================================
# MC Panel Frontend - Multi-Stage Dockerfile
# ================================
# Stage 1: Build Stage
FROM node:20-alpine AS builder
# Метаданные
LABEL maintainer="MC Panel Team" \
version="2.0.0" \
description="MC Panel Frontend - React Build Stage" \
component="frontend"
# Устанавливаем зависимости для сборки
RUN apk add --no-cache git python3 make g++
# Создаем рабочую директорию
WORKDIR /app
# Копируем package files для кеширования зависимостей
COPY package*.json ./
# Устанавливаем зависимости
RUN npm ci --silent
# Копируем исходный код
COPY . ./
# Собираем приложение для production
RUN npm run build
# Проверяем размер сборки
RUN du -sh dist/ && \
echo "Build completed successfully"
# ================================
# Stage 2: Production Stage (Nginx)
# ================================
FROM nginx:alpine AS production
# Метаданные
LABEL maintainer="MC Panel Team" \
version="2.0.0" \
description="MC Panel Frontend - Nginx Production Server" \
component="frontend"
# Устанавливаем дополнительные пакеты
RUN apk add --no-cache curl tini
# Создаем пользователя nginx если его нет
RUN addgroup -g 1000 -S mcpanel && \
adduser -u 1000 -D -S -G mcpanel mcpanel
# Копируем собранное приложение из builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Создаем кастомную конфигурацию Nginx
RUN cat > /etc/nginx/conf.d/default.conf << 'EOF'
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Handle React Router (SPA)
location / {
try_files $uri $uri/ /index.html;
}
# API proxy (если нужно)
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket proxy (если нужно)
location /ws/ {
proxy_pass http://backend:8000/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
EOF
# Создаем кастомную конфигурацию nginx.conf
RUN cat > /etc/nginx/nginx.conf << 'EOF'
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
include /etc/nginx/conf.d/*.conf;
}
EOF
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost/health || exit 1
# Expose порт
EXPOSE 80
# Используем tini как init процесс
ENTRYPOINT ["/sbin/tini", "--"]
# Команда запуска
CMD ["nginx", "-g", "daemon off;"]
# ================================
# Stage 3: Development Stage
# ================================
FROM node:20-alpine AS development
# Метаданные
LABEL maintainer="MC Panel Team" \
version="2.0.0" \
description="MC Panel Frontend - Development Server" \
component="frontend"
# Устанавливаем зависимости для разработки
RUN apk add --no-cache git python3 make g++
# Создаем пользователя для разработки
RUN addgroup -g 1000 -S mcpanel && \
adduser -u 1000 -D -S -G mcpanel mcpanel
# Создаем рабочую директорию
WORKDIR /app
# Меняем владельца директории
RUN chown mcpanel:mcpanel /app
# Переключаемся на пользователя
USER mcpanel
# Копируем package files
COPY --chown=mcpanel:mcpanel package*.json ./
# Устанавливаем зависимости
RUN npm ci
# Копируем исходный код
COPY --chown=mcpanel:mcpanel . ./
# Expose порт для dev сервера
EXPOSE 5173
# Команда для разработки
CMD ["npm", "run", "dev"]

View File

@@ -1,17 +1,24 @@
import { useState, useEffect } from 'react';
import { Server, Play, Square, Terminal, FolderOpen, HardDrive, Settings, Plus, Users as UsersIcon, LogOut, Menu, X } from 'lucide-react';
import {
Server, Play, Square, Terminal, FolderOpen, Settings, Plus,
Users as UsersIcon, LogOut, Menu, X, MessageSquare, UserCircle,
Shield, Activity, HardDrive, Cpu, BarChart3, Home
} from 'lucide-react';
import Console from './components/Console';
import FileManager from './components/FileManager';
import Stats from './components/Stats';
import ServerSettings from './components/ServerSettings';
import CreateServerModal from './components/CreateServerModal';
import Users from './components/Users';
import UserManagement from './components/UserManagement';
import Tickets from './components/Tickets';
import Profile from './components/Profile';
import Daemons from './components/Daemons';
import Auth from './components/Auth';
import ErrorBoundary from './components/ErrorBoundary';
import ThemeSelector from './components/ThemeSelector';
import NotificationSystem, { notify } from './components/NotificationSystem';
import axios from 'axios';
import { API_URL } from './config';
import { getTheme } from './themes';
function App() {
const [token, setToken] = useState(localStorage.getItem('token'));
@@ -20,14 +27,27 @@ function App() {
const [selectedServer, setSelectedServer] = useState(null);
const [activeTab, setActiveTab] = useState('console');
const [showCreateModal, setShowCreateModal] = useState(false);
const [showUsers, setShowUsers] = useState(false);
const [showUserManagement, setShowUserManagement] = useState(false);
const [showDaemons, setShowDaemons] = useState(false);
const [showTickets, setShowTickets] = useState(false);
const [showProfile, setShowProfile] = useState(false);
const [connectionError, setConnectionError] = useState(false);
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'dark');
const [sidebarOpen, setSidebarOpen] = useState(true);
const currentTheme = getTheme(theme);
const [currentView, setCurrentView] = useState('dashboard');
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const callbackToken = urlParams.get('token');
const callbackUsername = urlParams.get('username');
if (callbackToken && callbackUsername) {
localStorage.setItem('token', callbackToken);
setToken(callbackToken);
setUser({ username: callbackUsername });
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
if (token) {
loadUser();
loadServers();
@@ -75,6 +95,7 @@ function App() {
localStorage.setItem('token', data.access_token);
setToken(data.access_token);
setUser({ username: data.username, role: data.role });
notify.success(`Добро пожаловать, ${data.username}!`);
};
const handleLogout = () => {
@@ -83,309 +104,483 @@ function App() {
setUser(null);
setServers([]);
setSelectedServer(null);
notify.info('Вы вышли из системы');
};
const handleServerDeleted = () => {
setSelectedServer(null);
loadServers();
};
const handleThemeChange = (newTheme) => {
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
};
const startServer = async (serverName) => {
const handleServerAction = async (serverName, action) => {
try {
const response = await axios.post(
`${API_URL}/api/servers/${serverName}/start`,
await axios.post(
`${API_URL}/api/servers/${serverName}/${action}`,
{},
{ headers: { Authorization: `Bearer ${token}` } }
);
console.log('Сервер запущен:', response.data);
setTimeout(() => {
notify.success(`Сервер ${action === 'start' ? 'запущен' : 'остановлен'}`);
loadServers();
}, 1000);
} catch (error) {
console.error('Ошибка запуска сервера:', error);
alert(error.response?.data?.detail || 'Ошибка запуска сервера');
}
};
const stopServer = async (serverName) => {
try {
const response = await axios.post(
`${API_URL}/api/servers/${serverName}/stop`,
{},
{ headers: { Authorization: `Bearer ${token}` } }
);
console.log('Сервер остановлен:', response.data);
setTimeout(() => {
loadServers();
}, 1000);
} catch (error) {
console.error('Ошибка остановки сервера:', error);
alert(error.response?.data?.detail || 'Ошибка остановки сервера');
notify.error(`Ошибка: ${error.response?.data?.detail || error.message}`);
}
};
if (!token) {
return <Auth onLogin={handleLogin} />;
}
if (showUsers) {
return (
<div className={`min-h-screen ${currentTheme.primary} ${currentTheme.text} transition-colors duration-300`}>
<header className={`${currentTheme.secondary} ${currentTheme.border} border-b backdrop-blur-sm bg-opacity-95 sticky top-0 z-40`}>
<div className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<div className={`${currentTheme.accent} p-2 rounded-lg`}>
<Server className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-bold">MC Panel</h1>
<p className={`text-xs ${currentTheme.textSecondary}`}>Управление серверами</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className={`px-3 py-1.5 rounded-lg ${currentTheme.card} ${currentTheme.border} border`}>
<span className={`text-sm ${currentTheme.textSecondary}`}>
{user?.username}
</span>
<span className={`ml-2 text-xs px-2 py-0.5 rounded ${currentTheme.accent} text-white`}>
{user?.role === 'admin' ? 'Админ' : 'Пользователь'}
</span>
</div>
<ThemeSelector currentTheme={theme} onThemeChange={handleThemeChange} />
<button
onClick={() => setShowUsers(false)}
className={`${currentTheme.card} ${currentTheme.hover} px-4 py-2 rounded-lg transition flex items-center gap-2`}
>
<Server className="w-4 h-4" />
Серверы
</button>
<button
onClick={handleLogout}
className={`${currentTheme.danger} hover:opacity-90 px-4 py-2 rounded-lg transition flex items-center gap-2 text-white`}
>
<LogOut className="w-4 h-4" />
Выход
</button>
</div>
</div>
</div>
</header>
<Users token={token} theme={currentTheme} />
</div>
<ErrorBoundary>
<Auth onLogin={handleLogin} />
<NotificationSystem />
</ErrorBoundary>
);
}
return (
<div className={`min-h-screen ${currentTheme.primary} ${currentTheme.text} transition-colors duration-300`}>
{/* Header */}
<header className={`${currentTheme.secondary} ${currentTheme.border} border-b backdrop-blur-sm bg-opacity-95 sticky top-0 z-40`}>
<div className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<ErrorBoundary>
<div className="flex h-screen bg-dark-900 overflow-hidden">
{/* Sidebar */}
<aside className={`${sidebarOpen ? 'w-64' : 'w-20'} bg-dark-850 border-r border-dark-700 transition-all duration-300 flex flex-col`}>
{/* Logo */}
<div className="h-16 flex items-center justify-between px-4 border-b border-dark-700">
{sidebarOpen && (
<div className="flex items-center gap-2">
<Server className="w-8 h-8 text-primary-500" />
<span className="text-xl font-bold bg-gradient-to-r from-primary-400 to-blue-500 bg-clip-text text-transparent">
MC Panel
</span>
</div>
)}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className={`lg:hidden ${currentTheme.hover} p-2 rounded-lg transition`}
className="btn-icon"
>
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
<div className="flex items-center gap-3">
<div className={`${currentTheme.accent} p-2 rounded-lg`}>
<Server className="w-6 h-6 text-white" />
</div>
<div>
<h1 className={`text-xl font-bold bg-gradient-to-r ${currentTheme.gradient} bg-clip-text text-transparent`}>MC Panel</h1>
<p className={`text-xs ${currentTheme.textSecondary}`}>Управление серверами</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
{connectionError && (
<div className="bg-red-500 bg-opacity-20 border border-red-500 px-3 py-1.5 rounded-lg text-sm text-red-400">
Нет связи
</div>
)}
<div className={`px-3 py-1.5 rounded-lg ${currentTheme.card} ${currentTheme.border} border`}>
<span className={`text-sm ${currentTheme.textSecondary}`}>
{user?.username}
</span>
<span className={`ml-2 text-xs px-2 py-0.5 rounded ${currentTheme.accent} text-white`}>
{user?.role === 'admin' ? 'Админ' : 'Пользователь'}
</span>
</div>
<ThemeSelector currentTheme={theme} onThemeChange={handleThemeChange} />
{user?.role === 'admin' && (
<button
onClick={() => setShowUsers(true)}
className={`${currentTheme.accent} ${currentTheme.accentHover} px-4 py-2 rounded-lg transition flex items-center gap-2 text-white`}
>
<UsersIcon className="w-4 h-4" />
<span className="hidden sm:inline">Пользователи</span>
</button>
)}
<button
onClick={handleLogout}
className={`${currentTheme.danger} hover:opacity-90 px-4 py-2 rounded-lg transition flex items-center gap-2 text-white`}
>
<LogOut className="w-4 h-4" />
<span className="hidden sm:inline">Выход</span>
</button>
</div>
</div>
</div>
</header>
<div className="flex h-[calc(100vh-89px)]">
{/* Sidebar */}
<aside className={`${sidebarOpen ? 'w-80' : 'w-0'} ${currentTheme.secondary} ${currentTheme.border} border-r transition-all duration-300 overflow-hidden`}>
<div className="p-4 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Мои серверы</h2>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
<button
onClick={() => {
setCurrentView('dashboard');
setSelectedServer(null);
}}
className={currentView === 'dashboard' ? 'sidebar-item-active w-full' : 'sidebar-item w-full'}
>
<Home className="w-5 h-5 flex-shrink-0" />
{sidebarOpen && <span>Главная</span>}
</button>
{sidebarOpen && (
<div className="pt-4 pb-2">
<div className="flex items-center justify-between px-4 mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase">Серверы</span>
<button
onClick={() => setShowCreateModal(true)}
className={`${currentTheme.accent} ${currentTheme.accentHover} p-2 rounded-lg transition text-white`}
title="Создать сервер"
className="p-1 rounded hover:bg-dark-700 text-primary-500"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2">
{servers.map((server) => (
<button
key={server.name}
onClick={() => {
setSelectedServer(server);
setCurrentView('server');
setActiveTab('console');
}}
className={selectedServer?.name === server.name ? 'sidebar-item-active w-full' : 'sidebar-item w-full'}
>
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${
server.status === 'running' ? 'bg-green-500 shadow-glow' : 'bg-gray-600'
}`} />
{sidebarOpen && (
<div className="flex-1 text-left truncate">
<div className="text-sm font-medium">{server.displayName}</div>
<div className="text-xs text-gray-500">{server.status === 'running' ? 'Запущен' : 'Остановлен'}</div>
</div>
)}
</button>
))}
{sidebarOpen && servers.length === 0 && (
<div className="text-center py-8 text-gray-500 text-sm">
<Server className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Нет серверов</p>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary mt-2 text-xs"
>
Создать сервер
</button>
</div>
)}
</nav>
{/* Bottom section */}
<div className="p-4 border-t border-dark-700 space-y-2">
{(user?.role === 'owner' || user?.role === 'admin') && (
<>
<button
onClick={() => {
setShowUserManagement(true);
setCurrentView('management');
}}
className="sidebar-item w-full"
>
<Shield className="w-5 h-5 flex-shrink-0 text-yellow-500" />
{sidebarOpen && <span>Управление</span>}
</button>
<button
onClick={() => {
setShowDaemons(true);
setCurrentView('daemons');
}}
className="sidebar-item w-full"
>
<Server className="w-5 h-5 flex-shrink-0 text-blue-500" />
{sidebarOpen && <span>Демоны</span>}
</button>
</>
)}
<button
onClick={() => {
setShowTickets(true);
setCurrentView('tickets');
}}
className="sidebar-item w-full"
>
<MessageSquare className="w-5 h-5 flex-shrink-0" />
{sidebarOpen && <span>Тикеты</span>}
</button>
<button
onClick={() => {
setShowProfile(true);
setCurrentView('profile');
}}
className="sidebar-item w-full"
>
<UserCircle className="w-5 h-5 flex-shrink-0" />
{sidebarOpen && <span>Профиль</span>}
</button>
<button
onClick={handleLogout}
className="sidebar-item w-full text-red-400 hover:text-red-300 hover:bg-red-600/10"
>
<LogOut className="w-5 h-5 flex-shrink-0" />
{sidebarOpen && <span>Выход</span>}
</button>
{sidebarOpen && user && (
<div className="pt-2 border-t border-dark-700">
<div className="text-xs text-gray-500">
<div className="font-medium text-gray-300">{user.username}</div>
<div className="capitalize">{user.role}</div>
</div>
</div>
)}
</div>
</aside>
{/* Main content */}
<main className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<header className="h-16 bg-dark-850 border-b border-dark-700 flex items-center justify-between px-6">
<div>
<h1 className="text-2xl font-bold text-gray-100">
{currentView === 'dashboard' && 'Панель управления'}
{currentView === 'server' && selectedServer?.displayName}
{currentView === 'management' && 'Управление пользователями'}
{currentView === 'daemons' && 'Управление демонами'}
{currentView === 'tickets' && 'Тикеты поддержки'}
{currentView === 'profile' && 'Профиль'}
</h1>
{currentView === 'server' && selectedServer && (
<p className="text-sm text-gray-500">
{selectedServer.name} {selectedServer.status === 'running' ? 'Запущен' : 'Остановлен'}
</p>
)}
</div>
{currentView === 'server' && selectedServer && (
<div className="flex items-center gap-2">
{selectedServer.status === 'stopped' ? (
<button
onClick={() => handleServerAction(selectedServer.name, 'start')}
className="btn-success flex items-center gap-2"
>
<Play className="w-4 h-4" />
Запустить
</button>
) : (
<button
onClick={() => handleServerAction(selectedServer.name, 'stop')}
className="btn-danger flex items-center gap-2"
>
<Square className="w-4 h-4" />
Остановить
</button>
)}
</div>
)}
</header>
{/* Content area */}
<div className="flex-1 overflow-auto p-6">
{connectionError && (
<div className="card p-4 mb-4 bg-red-600/10 border-red-600/30">
<p className="text-red-400"> Ошибка подключения к серверу</p>
</div>
)}
{currentView === 'dashboard' && (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="stat-card">
<div className="stat-icon">
<Server className="w-6 h-6" />
</div>
<div>
<div className="text-2xl font-bold">{servers.length}</div>
<div className="text-sm text-gray-500">Всего серверов</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon bg-green-600/20 text-green-400">
<Activity className="w-6 h-6" />
</div>
<div>
<div className="text-2xl font-bold text-green-400">
{servers.filter(s => s.status === 'running').length}
</div>
<div className="text-sm text-gray-500">Запущено</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon bg-gray-600/20 text-gray-400">
<Square className="w-6 h-6" />
</div>
<div>
<div className="text-2xl font-bold text-gray-400">
{servers.filter(s => s.status === 'stopped').length}
</div>
<div className="text-sm text-gray-500">Остановлено</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon bg-purple-600/20 text-purple-400">
<UserCircle className="w-6 h-6" />
</div>
<div>
<div className="text-2xl font-bold text-purple-400">{user?.username}</div>
<div className="text-sm text-gray-500 capitalize">{user?.role}</div>
</div>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Ваши серверы</h2>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Создать сервер
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{servers.map((server) => (
<div
key={server.name}
className={`p-4 rounded-lg cursor-pointer transition-all duration-200 ${
selectedServer === server.name
? `${currentTheme.accent} text-white shadow-lg`
: `${currentTheme.card} ${currentTheme.hover}`
}`}
onClick={() => setSelectedServer(server.name)}
onClick={() => {
setSelectedServer(server);
setCurrentView('server');
}}
className="server-card"
>
<div className="flex items-center justify-between mb-3">
<span className="font-medium truncate">{server.displayName}</span>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`p-3 rounded-lg ${
server.status === 'running'
? 'bg-green-600/20 text-green-400'
: 'bg-gray-600/20 text-gray-400'
}`}>
<Server className="w-6 h-6" />
</div>
<div>
<h3 className="font-semibold text-lg">{server.displayName}</h3>
<p className="text-sm text-gray-500">{server.name}</p>
</div>
</div>
<span className={server.status === 'running' ? 'badge-success' : 'badge-danger'}>
{server.status === 'running' ? 'Запущен' : 'Остановлен'}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${
server.status === 'running' ? 'bg-green-400 animate-pulse' : 'bg-gray-500'
}`}
/>
</div>
</div>
<div className="flex gap-2">
{server.status === 'stopped' ? (
<button
onClick={(e) => {
e.stopPropagation();
startServer(server.name);
handleServerAction(server.name, 'start');
}}
className="flex-1 bg-green-600 hover:bg-green-700 py-1.5 rounded-lg text-sm flex items-center justify-center gap-1.5 text-white transition"
className="btn-success flex-1 flex items-center justify-center gap-2"
>
<Play className="w-3.5 h-3.5" />
<Play className="w-4 h-4" />
Запустить
</button>
) : (
<button
onClick={(e) => {
e.stopPropagation();
stopServer(server.name);
handleServerAction(server.name, 'stop');
}}
className="flex-1 bg-red-600 hover:bg-red-700 py-1.5 rounded-lg text-sm flex items-center justify-center gap-1.5 text-white transition"
className="btn-danger flex-1 flex items-center justify-center gap-2"
>
<Square className="w-3.5 h-3.5" />
<Square className="w-4 h-4" />
Остановить
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setSelectedServer(server);
setCurrentView('server');
}}
className="btn-secondary"
>
<Terminal className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
{servers.length === 0 && (
<div className={`text-center py-12 ${currentTheme.textSecondary}`}>
<Server className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Нет серверов</p>
<p className="text-xs mt-1">Создайте первый сервер</p>
<div className="card p-12 text-center">
<Server className="w-16 h-16 mx-auto mb-4 text-gray-600" />
<h3 className="text-xl font-semibold mb-2">Нет серверов</h3>
<p className="text-gray-500 mb-4">Создайте свой первый сервер</p>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary"
>
<Plus className="w-4 h-4 inline mr-2" />
Создать сервер
</button>
</div>
)}
</div>
</div>
</aside>
)}
{/* Main Content */}
<main className="flex-1 flex flex-col overflow-hidden">
{selectedServer ? (
<>
{currentView === 'server' && selectedServer && (
<div className="space-y-4 animate-fade-in">
{/* Tabs */}
<div className={`${currentTheme.secondary} ${currentTheme.border} border-b flex overflow-x-auto`}>
{[
{ id: 'console', icon: Terminal, label: 'Консоль' },
{ id: 'files', icon: FolderOpen, label: 'Файлы' },
{ id: 'stats', icon: HardDrive, label: 'Статистика' },
{ id: 'settings', icon: Settings, label: 'Настройки' },
].map((tab) => (
<div className="card p-2 flex gap-2 overflow-x-auto">
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-6 py-4 flex items-center gap-2 transition-all duration-200 border-b-2 ${
activeTab === tab.id
? `${currentTheme.accent.replace('bg-', 'border-')} ${currentTheme.text}`
: `border-transparent ${currentTheme.textSecondary} ${currentTheme.hover}`
}`}
onClick={() => setActiveTab('console')}
className={activeTab === 'console' ? 'tab-active' : 'tab'}
>
<tab.icon className="w-4 h-4" />
<span className="font-medium">{tab.label}</span>
<Terminal className="w-4 h-4 inline mr-2" />
Консоль
</button>
<button
onClick={() => setActiveTab('files')}
className={activeTab === 'files' ? 'tab-active' : 'tab'}
>
<FolderOpen className="w-4 h-4 inline mr-2" />
Файлы
</button>
<button
onClick={() => setActiveTab('stats')}
className={activeTab === 'stats' ? 'tab-active' : 'tab'}
>
<BarChart3 className="w-4 h-4 inline mr-2" />
Статистика
</button>
<button
onClick={() => setActiveTab('settings')}
className={activeTab === 'settings' ? 'tab-active' : 'tab'}
>
<Settings className="w-4 h-4 inline mr-2" />
Настройки
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-hidden">
<ErrorBoundary>
{activeTab === 'console' && <Console serverName={selectedServer} token={token} theme={currentTheme} />}
{activeTab === 'files' && <FileManager serverName={selectedServer} token={token} theme={currentTheme} />}
{activeTab === 'stats' && <Stats serverName={selectedServer} token={token} theme={currentTheme} />}
{/* Tab content */}
<div className="card p-6">
{activeTab === 'console' && <Console serverName={selectedServer.name} token={token} />}
{activeTab === 'files' && <FileManager serverName={selectedServer.name} token={token} />}
{activeTab === 'stats' && <Stats serverName={selectedServer.name} token={token} />}
{activeTab === 'settings' && (
<ServerSettings
serverName={selectedServer}
serverName={selectedServer.name}
token={token}
user={user}
theme={currentTheme}
onDeleted={handleServerDeleted}
onUpdate={loadServers}
/>
)}
</ErrorBoundary>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className={`${currentTheme.card} p-8 rounded-2xl ${currentTheme.border} border`}>
<Server className={`w-16 h-16 mx-auto mb-4 ${currentTheme.textSecondary} opacity-50`} />
<p className="text-xl font-medium mb-2">Выберите сервер</p>
<p className={`text-sm ${currentTheme.textSecondary}`}>
Выберите сервер из списка слева или создайте новый
</p>
</div>
</div>
</div>
)}
</main>
</div>
</main>
{/* Modals */}
{showCreateModal && (
<CreateServerModal
token={token}
theme={currentTheme}
onClose={() => setShowCreateModal(false)}
onCreated={loadServers}
onSuccess={() => {
setShowCreateModal(false);
loadServers();
}}
/>
)}
{showUserManagement && (
<div className="modal-overlay" onClick={() => setShowUserManagement(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<UserManagement token={token} currentUser={user} />
</div>
</div>
)}
{showDaemons && (
<div className="modal-overlay" onClick={() => setShowDaemons(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<Daemons token={token} />
</div>
</div>
)}
{showTickets && (
<div className="modal-overlay" onClick={() => setShowTickets(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<Tickets token={token} user={user} onClose={() => setShowTickets(false)} />
</div>
</div>
)}
{showProfile && (
<div className="modal-overlay" onClick={() => setShowProfile(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<Profile token={token} user={user} onClose={() => setShowProfile(false)} />
</div>
</div>
)}
<NotificationSystem />
</div>
</ErrorBoundary>
);
}

View File

@@ -1,6 +1,8 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Server, Eye, EyeOff } from 'lucide-react';
import { getTheme } from '../themes';
import { API_URL } from '../config';
import axios from 'axios';
export default function Auth({ onLogin }) {
const [isLogin, setIsLogin] = useState(true);
@@ -9,10 +11,28 @@ export default function Auth({ onLogin }) {
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [theme] = useState(localStorage.getItem('theme') || 'dark');
const [theme] = useState(localStorage.getItem('theme') || 'modern');
const [oidcProviders, setOidcProviders] = useState({});
const currentTheme = getTheme(theme);
useEffect(() => {
loadOidcProviders();
}, []);
const loadOidcProviders = async () => {
try {
const { data } = await axios.get(`${API_URL}/api/auth/oidc/providers`);
setOidcProviders(data);
} catch (error) {
console.error('Ошибка загрузки OIDC провайдеров:', error);
}
};
const handleOidcLogin = (provider) => {
window.location.href = `${API_URL}/api/auth/oidc/${provider}/login`;
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
@@ -129,18 +149,47 @@ export default function Auth({ onLogin }) {
</button>
</form>
{/* OpenID Connect Providers */}
{Object.keys(oidcProviders).length > 0 && (
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className={`w-full border-t ${currentTheme.border}`} />
</div>
<div className="relative flex justify-center text-sm">
<span className={`${currentTheme.secondary} px-2 ${currentTheme.textSecondary}`}>
Или войдите через
</span>
</div>
</div>
<div className="mt-6 grid gap-3">
{Object.entries(oidcProviders).map(([providerId, provider]) => (
<button
key={providerId}
onClick={() => handleOidcLogin(providerId)}
className={`w-full flex justify-center items-center px-4 py-3 border border-transparent rounded-xl text-sm font-medium text-white ${provider.color} transition-colors duration-200 shadow-sm hover:shadow-md`}
>
<span className="mr-2 text-lg">{provider.icon}</span>
Войти через {provider.name}
</button>
))}
</div>
</div>
)}
{/* 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>
<p className={`${currentTheme.text} font-mono mt-1`}>none / none</p>
</div>
)}
</div>
{/* Footer */}
<div className={`text-center mt-6 text-sm ${currentTheme.textSecondary}`}>
<p>© 2024 MC Panel. Все права защищены.</p>
<p>© 2026 MC Panel. Все права защищены.</p>
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@ import { Send } from 'lucide-react';
import axios from 'axios';
import { API_URL, WS_URL } from '../config';
export default function Console({ serverName, token, theme }) {
export default function Console({ serverName, token }) {
const [logs, setLogs] = useState([]);
const [command, setCommand] = useState('');
const logsEndRef = useRef(null);
@@ -54,32 +54,63 @@ export default function Console({ serverName, token, theme }) {
}
};
// Функция для раскраски логов
const colorizeLog = (log) => {
// INFO - зеленый
if (log.includes('[INFO]') || log.includes('Done (')) {
return <span className="text-green-400">{log}</span>;
}
// WARN - желтый
if (log.includes('[WARN]') || log.includes('WARNING')) {
return <span className="text-yellow-400">{log}</span>;
}
// ERROR - красный
if (log.includes('[ERROR]') || log.includes('Exception')) {
return <span className="text-red-400">{log}</span>;
}
// Время - серый
if (log.match(/^\[\d{2}:\d{2}:\d{2}\]/)) {
const time = log.match(/^\[\d{2}:\d{2}:\d{2}\]/)[0];
const rest = log.substring(time.length);
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}`}>
<>
<span className="text-gray-500">{time}</span>
<span className="text-gray-300">{rest}</span>
</>
);
}
// Обычный текст
return <span className="text-gray-300">{log}</span>;
};
return (
<div className="flex flex-col h-full">
{/* Консоль */}
<div className="console-terminal flex-1 overflow-y-auto min-h-[400px] max-h-[600px]">
{logs.length === 0 ? (
<div className={theme.textSecondary}>Консоль пуста. Запустите сервер для просмотра логов.</div>
<div className="text-gray-500">Консоль пуста. Запустите сервер для просмотра логов.</div>
) : (
logs.map((log, index) => (
<div key={index} className={`${theme.text} whitespace-pre-wrap leading-relaxed`}>
{log}
<div key={index} className="whitespace-pre-wrap leading-relaxed">
{colorizeLog(log)}
</div>
))
)}
<div ref={logsEndRef} />
</div>
<form onSubmit={sendCommand} className={`${theme.border} border-t p-4 flex gap-2`}>
{/* Поле ввода команды */}
<form onSubmit={sendCommand} className="border-t border-dark-700 p-4 flex gap-2 bg-dark-850">
<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`}
className="input flex-1"
/>
<button
type="submit"
className={`${theme.accent} ${theme.accentHover} px-6 py-2 rounded-xl flex items-center gap-2 text-white transition`}
className="btn-success flex items-center gap-2"
>
<Send className="w-4 h-4" />
Отправить

View File

@@ -1,15 +1,36 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { useState, useEffect } from 'react';
import { X, Server } from 'lucide-react';
import axios from 'axios';
import { API_URL } from '../config';
import { notify } from './NotificationSystem';
export default function CreateServerModal({ token, theme, onClose, onCreated }) {
export default function CreateServerModal({ token, onClose, onSuccess }) {
const [formData, setFormData] = useState({
name: '',
displayName: '',
startCommand: 'java -Xmx2G -Xms1G -jar server.jar nogui'
startCommand: 'java -Xmx2G -Xms1G -jar server.jar nogui',
daemonId: 'local' // По умолчанию локальный
});
const [loading, setLoading] = useState(false);
const [daemons, setDaemons] = useState([]);
const [loadingDaemons, setLoadingDaemons] = useState(true);
useEffect(() => {
loadDaemons();
}, []);
const loadDaemons = async () => {
try {
const { data } = await axios.get(`${API_URL}/api/daemons`, {
headers: { Authorization: `Bearer ${token}` }
});
setDaemons(data.filter(d => d.status === 'online')); // Только онлайн демоны
} catch (error) {
console.error('Ошибка загрузки демонов:', error);
} finally {
setLoadingDaemons(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
@@ -21,10 +42,11 @@ export default function CreateServerModal({ token, theme, onClose, onCreated })
formData,
{ headers: { Authorization: `Bearer ${token}` } }
);
onCreated();
notify('success', 'Сервер создан', `Сервер "${formData.displayName}" успешно создан`);
if (onSuccess) onSuccess();
onClose();
} catch (error) {
alert(error.response?.data?.detail || 'Ошибка создания сервера');
notify('error', 'Ошибка создания', error.response?.data?.detail || 'Не удалось создать сервер');
} finally {
setLoading(false);
}
@@ -32,12 +54,12 @@ export default function CreateServerModal({ token, theme, onClose, onCreated })
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="bg-dark-800 rounded-2xl p-6 w-full max-w-md shadow-2xl border border-gray-700">
<div className="flex items-center justify-between mb-6">
<h2 className={`text-xl font-bold ${theme.text}`}>Создать сервер</h2>
<h2 className="text-xl font-bold text-white">Создать сервер</h2>
<button
onClick={onClose}
className={`${theme.textSecondary} hover:${theme.text} transition`}
className="text-gray-400 hover:text-white transition"
>
<X className="w-6 h-6" />
</button>
@@ -45,7 +67,34 @@ export default function CreateServerModal({ token, theme, onClose, onCreated })
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className={`block text-sm font-medium mb-2 ${theme.text}`}>
<label className="block text-sm font-medium mb-2 text-white">
Демон
</label>
{loadingDaemons ? (
<div className="input text-gray-400">Загрузка демонов...</div>
) : (
<select
value={formData.daemonId}
onChange={(e) => setFormData({ ...formData, daemonId: e.target.value })}
className="w-full bg-dark-700 border border-gray-600 rounded-xl px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition"
>
<option value="local">Локальный (эта машина)</option>
{daemons.map((daemon) => (
<option key={daemon.id} value={daemon.id}>
{daemon.name} ({daemon.address}:{daemon.port})
</option>
))}
</select>
)}
<p className="text-xs text-gray-400 mt-1">
{formData.daemonId === 'local'
? 'Сервер будет создан на этой машине'
: 'Сервер будет создан на удаленном демоне'}
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-white">
Имя папки (только латиница, цифры, _ и -)
</label>
<input
@@ -53,13 +102,13 @@ export default function CreateServerModal({ token, theme, onClose, onCreated })
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`}
className="input"
placeholder="my_server"
/>
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${theme.text}`}>
<label className="block text-sm font-medium mb-2 text-white">
Отображаемое имя
</label>
<input
@@ -67,13 +116,13 @@ export default function CreateServerModal({ token, theme, onClose, onCreated })
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`}
className="input"
placeholder="Мой сервер"
/>
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${theme.text}`}>
<label className="block text-sm font-medium mb-2 text-white">
Команда запуска
</label>
<input
@@ -81,7 +130,7 @@ export default function CreateServerModal({ token, theme, onClose, onCreated })
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`}
className="input"
/>
</div>
@@ -89,14 +138,14 @@ export default function CreateServerModal({ token, theme, onClose, onCreated })
<button
type="button"
onClick={onClose}
className={`flex-1 ${theme.card} ${theme.hover} px-4 py-2 rounded-xl transition`}
className="flex-1 bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-xl transition text-white"
>
Отмена
</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`}
className="flex-1 btn-primary disabled:opacity-50"
>
{loading ? 'Создание...' : 'Создать'}
</button>

View File

@@ -0,0 +1,93 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import axios from 'axios';
import { API_URL } from '../config';
export default function CreateTicketModal({ token, onClose, onCreated }) {
const [formData, setFormData] = useState({
title: '',
description: ''
});
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await axios.post(
`${API_URL}/api/tickets/create`,
formData,
{ headers: { Authorization: `Bearer ${token}` } }
);
onCreated();
} 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="bg-dark-800 rounded-2xl p-6 w-full max-w-md shadow-2xl border border-gray-700">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">Создать тикет</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white 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 text-white">
Тема тикета
</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="input"
placeholder="Краткое описание проблемы"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-white">
Описание
</label>
<textarea
required
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full bg-dark-800 border-gray-700 border rounded-xl px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition resize-none"
placeholder="Подробное описание проблемы"
rows={5}
/>
</div>
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-xl transition text-white"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="flex-1 btn-primary disabled:opacity-50"
>
{loading ? 'Создание...' : 'Создать'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,382 @@
import { useState, useEffect } from 'react';
import { Server, Plus, Trash2, Edit, RefreshCw, CheckCircle, XCircle, Activity } from 'lucide-react';
import axios from 'axios';
import { API_URL } from '../config';
import { notify } from './NotificationSystem';
export default function Daemons({ token }) {
const [daemons, setDaemons] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [editingDaemon, setEditingDaemon] = useState(null);
const [formData, setFormData] = useState({
name: '',
address: '',
port: 24444,
key: '',
remarks: ''
});
useEffect(() => {
loadDaemons();
const interval = setInterval(loadDaemons, 10000); // Обновляем каждые 10 секунд
return () => clearInterval(interval);
}, []);
const loadDaemons = async () => {
try {
const { data } = await axios.get(`${API_URL}/api/daemons`, {
headers: { Authorization: `Bearer ${token}` }
});
setDaemons(data);
setLoading(false);
} catch (error) {
console.error('Ошибка загрузки демонов:', error);
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (editingDaemon) {
await axios.put(
`${API_URL}/api/daemons/${editingDaemon.id}`,
formData,
{ headers: { Authorization: `Bearer ${token}` } }
);
notify('success', 'Демон обновлен', 'Демон успешно обновлен');
} else {
await axios.post(
`${API_URL}/api/daemons`,
formData,
{ headers: { Authorization: `Bearer ${token}` } }
);
notify('success', 'Демон добавлен', 'Демон успешно добавлен');
}
setShowAddModal(false);
setEditingDaemon(null);
setFormData({ name: '', address: '', port: 24444, key: '', remarks: '' });
loadDaemons();
} catch (error) {
notify('error', 'Ошибка', error.response?.data?.detail || 'Не удалось сохранить демон');
}
};
const handleDelete = async (daemonId) => {
if (!confirm('Вы уверены, что хотите удалить этот демон?')) return;
try {
await axios.delete(`${API_URL}/api/daemons/${daemonId}`, {
headers: { Authorization: `Bearer ${token}` }
});
notify('success', 'Демон удален', 'Демон успешно удален');
loadDaemons();
} catch (error) {
notify('error', 'Ошибка удаления', error.response?.data?.detail || 'Не удалось удалить демон');
}
};
const handleEdit = (daemon) => {
setEditingDaemon(daemon);
setFormData({
name: daemon.name,
address: daemon.address,
port: daemon.port,
key: daemon.key,
remarks: daemon.remarks || ''
});
setShowAddModal(true);
};
const formatBytes = (bytes) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-400">Загрузка демонов...</div>
</div>
);
}
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Server className="w-8 h-8 text-blue-400" />
<div>
<h2 className="text-2xl font-bold text-white">Демоны</h2>
<p className="text-gray-400">Управление удаленными серверами</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={loadDaemons}
className="btn-secondary flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Обновить
</button>
<button
onClick={() => {
setEditingDaemon(null);
setFormData({ name: '', address: '', port: 24444, key: '', remarks: '' });
setShowAddModal(true);
}}
className="btn-primary flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Добавить демон
</button>
</div>
</div>
{daemons.length === 0 ? (
<div className="card p-12 text-center">
<Server className="w-16 h-16 mx-auto mb-4 text-gray-500 opacity-50" />
<p className="text-lg font-medium mb-2">Нет демонов</p>
<p className="text-sm text-gray-400 mb-4">
Добавьте первый демон для управления удаленными серверами
</p>
<button
onClick={() => setShowAddModal(true)}
className="btn-primary"
>
Добавить демон
</button>
</div>
) : (
<div className="grid gap-4">
{daemons.map((daemon) => (
<div key={daemon.id} className="card p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-xl ${daemon.status === 'online' ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
<Server className={`w-6 h-6 ${daemon.status === 'online' ? 'text-green-400' : 'text-red-400'}`} />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-white">{daemon.name}</h3>
{daemon.status === 'online' ? (
<span className="px-2 py-1 bg-green-500/20 text-green-400 text-xs rounded flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Онлайн
</span>
) : (
<span className="px-2 py-1 bg-red-500/20 text-red-400 text-xs rounded flex items-center gap-1">
<XCircle className="w-3 h-3" />
Оффлайн
</span>
)}
</div>
<p className="text-sm text-gray-400 mt-1">
{daemon.address}:{daemon.port}
</p>
{daemon.remarks && (
<p className="text-sm text-gray-500 mt-1">{daemon.remarks}</p>
)}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleEdit(daemon)}
className="p-2 bg-dark-700 hover:bg-dark-600 rounded transition"
title="Редактировать"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(daemon.id)}
className="p-2 bg-dark-700 hover:bg-dark-600 rounded text-red-400 transition"
title="Удалить"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{daemon.status === 'online' && daemon.system && (
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-gray-700">
<div>
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-blue-400" />
<span className="text-sm text-gray-400">CPU</span>
</div>
<div className="text-xl font-bold text-white">{daemon.system.cpu_usage?.toFixed(1)}%</div>
<div className="w-full bg-dark-700 rounded-full h-2 mt-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${Math.min(daemon.system.cpu_usage || 0, 100)}%` }}
/>
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-green-400" />
<span className="text-sm text-gray-400">ОЗУ</span>
</div>
<div className="text-xl font-bold text-white">{daemon.system.memory_percent?.toFixed(1)}%</div>
<div className="text-xs text-gray-500">
{formatBytes(daemon.system.memory_used)} / {formatBytes(daemon.system.memory_total)}
</div>
<div className="w-full bg-dark-700 rounded-full h-2 mt-2">
<div
className="bg-green-500 h-2 rounded-full transition-all"
style={{ width: `${Math.min(daemon.system.memory_percent || 0, 100)}%` }}
/>
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-purple-400" />
<span className="text-sm text-gray-400">Диск</span>
</div>
<div className="text-xl font-bold text-white">{daemon.system.disk_percent?.toFixed(1)}%</div>
<div className="text-xs text-gray-500">
{formatBytes(daemon.system.disk_used)} / {formatBytes(daemon.system.disk_total)}
</div>
<div className="w-full bg-dark-700 rounded-full h-2 mt-2">
<div
className="bg-purple-500 h-2 rounded-full transition-all"
style={{ width: `${Math.min(daemon.system.disk_percent || 0, 100)}%` }}
/>
</div>
</div>
</div>
)}
{daemon.status === 'online' && daemon.servers && (
<div className="mt-4 pt-4 border-t border-gray-700">
<div className="flex items-center gap-2 text-sm text-gray-400">
<Server className="w-4 h-4" />
<span>Серверов: {daemon.servers.total || 0}</span>
<span className="text-gray-600"></span>
<span className="text-green-400">Запущено: {daemon.servers.running || 0}</span>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Модальное окно добавления/редактирования */}
{showAddModal && (
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
<div className="modal-content max-w-2xl" onClick={(e) => e.stopPropagation()}>
<h2 className="text-xl font-bold text-white mb-6">
{editingDaemon ? 'Редактировать демон' : 'Добавить демон'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2 text-white">
Название
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input"
placeholder="Main Server"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2 text-white">
IP адрес
</label>
<input
type="text"
required
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
className="input"
placeholder="192.168.1.100"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-white">
Порт
</label>
<input
type="number"
required
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) })}
className="input"
placeholder="24444"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-white">
Ключ демона
</label>
<input
type="text"
required
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
className="input"
placeholder="your-secret-key"
/>
<p className="text-xs text-gray-400 mt-1">
Ключ должен совпадать с DAEMON_KEY в .env файле демона
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-white">
Примечания (необязательно)
</label>
<textarea
value={formData.remarks}
onChange={(e) => setFormData({ ...formData, remarks: e.target.value })}
className="w-full bg-dark-800 border-gray-700 border rounded-xl px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition resize-none"
rows={3}
placeholder="Дополнительная информация о демоне"
/>
</div>
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={() => {
setShowAddModal(false);
setEditingDaemon(null);
}}
className="flex-1 btn-secondary"
>
Отмена
</button>
<button
type="submit"
className="flex-1 btn-primary"
>
{editingDaemon ? 'Сохранить' : 'Добавить'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -25,32 +25,32 @@ export default function FileEditorModal({ file, onClose, onSave }) {
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="bg-dark-800 rounded-lg w-full max-w-4xl h-[80vh] flex flex-col border border-gray-700">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold">Редактирование: {file.name}</h2>
<h2 className="text-xl font-bold text-white">Редактирование: {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"
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded flex items-center gap-2 disabled:opacity-50 text-white transition"
>
<Save className="w-4 h-4" />
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
<button
onClick={onClose}
className="text-gray-400 hover:text-white"
className="text-gray-400 hover:text-white transition"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
<div className="flex-1 overflow-hidden p-4 bg-gray-900">
<div className="flex-1 overflow-hidden p-4 bg-dark-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"
className="w-full h-full bg-dark-900 text-gray-100 font-mono text-sm p-4 rounded border border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
spellCheck={false}
/>
</div>

View File

@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react';
import { Folder, File, Download, Trash2, Upload, Edit, Eye } from 'lucide-react';
import { Folder, File, Download, Trash2, Upload, Edit, Eye, Search } from 'lucide-react';
import axios from 'axios';
import FileEditorModal from './FileEditorModal';
import FileViewerModal from './FileViewerModal';
import { API_URL } from '../config';
import { notify } from './NotificationSystem';
export default function FileManager({ serverName, token }) {
const [files, setFiles] = useState([]);
@@ -12,11 +13,30 @@ export default function FileManager({ serverName, token }) {
const [viewingFile, setViewingFile] = useState(null);
const [renamingFile, setRenamingFile] = useState(null);
const [newFileName, setNewFileName] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [selectedFiles, setSelectedFiles] = useState([]);
const [selectAll, setSelectAll] = useState(false);
const [showNewMenu, setShowNewMenu] = useState(false);
const [creatingNew, setCreatingNew] = useState(null); // 'file' or 'folder'
const [newItemName, setNewItemName] = useState('');
const [cutFiles, setCutFiles] = useState([]); // Файлы для перемещения
useEffect(() => {
loadFiles();
}, [serverName, currentPath]);
// Закрытие меню "Новый" при клике вне его
useEffect(() => {
const handleClickOutside = (e) => {
if (showNewMenu && !e.target.closest('.new-menu-container')) {
setShowNewMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showNewMenu]);
const loadFiles = async () => {
try {
const { data } = await axios.get(`${API_URL}/api/servers/${serverName}/files`, {
@@ -53,8 +73,10 @@ export default function FileManager({ serverName, token }) {
params: { path: filePath },
headers: { Authorization: `Bearer ${token}` }
});
notify('success', 'Файл удален', `"${fileName}" успешно удален`);
loadFiles();
} catch (error) {
notify('error', 'Ошибка удаления', 'Не удалось удалить файл');
alert('Ошибка удаления файла');
}
};
@@ -72,8 +94,10 @@ export default function FileManager({ serverName, token }) {
formData,
{ headers: { Authorization: `Bearer ${token}` } }
);
notify('success', 'Файл загружен', `"${file.name}" успешно загружен`);
loadFiles();
} catch (error) {
notify('error', 'Ошибка загрузки', 'Не удалось загрузить файл');
alert('Ошибка загрузки файла');
}
};
@@ -115,8 +139,10 @@ export default function FileManager({ serverName, token }) {
}
);
setEditingFile(null);
notify('success', 'Файл сохранен', 'Изменения успешно сохранены');
alert('Файл сохранен');
} catch (error) {
notify('error', 'Ошибка сохранения', error.response?.data?.detail || 'Не удалось сохранить файл');
alert(error.response?.data?.detail || 'Ошибка сохранения файла');
}
};
@@ -144,8 +170,10 @@ export default function FileManager({ serverName, token }) {
}
);
setRenamingFile(null);
notify('success', 'Файл переименован', `"${oldName}" → "${newFileName}"`);
loadFiles();
} catch (error) {
notify('error', 'Ошибка переименования', error.response?.data?.detail || 'Не удалось переименовать файл');
alert(error.response?.data?.detail || 'Ошибка переименования файла');
}
};
@@ -158,45 +186,408 @@ export default function FileManager({ serverName, token }) {
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
const filteredFiles = files.filter(file =>
file.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// Выбор всех файлов
const handleSelectAll = () => {
if (selectAll) {
setSelectedFiles([]);
} else {
setSelectedFiles(filteredFiles.map(f => f.name));
}
setSelectAll(!selectAll);
};
// Выбор отдельного файла
const handleSelectFile = (fileName) => {
if (selectedFiles.includes(fileName)) {
setSelectedFiles(selectedFiles.filter(f => f !== fileName));
} else {
setSelectedFiles([...selectedFiles, fileName]);
}
};
// Создание нового файла
const createNewFile = async () => {
if (!newItemName.trim()) {
alert('Введите имя файла');
return;
}
try {
console.log('Creating file:', {
type: 'file',
name: newItemName,
path: currentPath
});
const response = await axios.post(
`${API_URL}/api/servers/${serverName}/files/create`,
{
type: 'file',
name: newItemName,
path: currentPath
},
{ headers: { Authorization: `Bearer ${token}` } }
);
console.log('File created successfully:', response.data);
notify('success', 'Файл создан', `"${newItemName}" успешно создан`);
setCreatingNew(null);
setNewItemName('');
loadFiles();
} catch (error) {
console.error('Ошибка создания файла:', error);
console.error('Error details:', error.response?.data);
notify('error', 'Ошибка создания', error.response?.data?.detail || 'Не удалось создать файл');
alert(error.response?.data?.detail || 'Ошибка создания файла');
}
};
// Создание новой папки
const createNewFolder = async () => {
if (!newItemName.trim()) {
alert('Введите имя папки');
return;
}
try {
console.log('Creating folder:', {
type: 'folder',
name: newItemName,
path: currentPath
});
const response = await axios.post(
`${API_URL}/api/servers/${serverName}/files/create`,
{
type: 'folder',
name: newItemName,
path: currentPath
},
{ headers: { Authorization: `Bearer ${token}` } }
);
console.log('Folder created successfully:', response.data);
notify('success', 'Папка создана', `"${newItemName}" успешно создана`);
setCreatingNew(null);
setNewItemName('');
loadFiles();
} catch (error) {
console.error('Ошибка создания папки:', error);
console.error('Error details:', error.response?.data);
notify('error', 'Ошибка создания', error.response?.data?.detail || 'Не удалось создать папку');
alert(error.response?.data?.detail || 'Ошибка создания папки');
}
};
// Перемещение файла
const moveFile = async (sourcePath, destinationPath) => {
try {
console.log('Moving file:', { sourcePath, destinationPath });
const response = await axios.post(
`${API_URL}/api/servers/${serverName}/files/move`,
{
source: sourcePath,
destination: destinationPath
},
{ headers: { Authorization: `Bearer ${token}` } }
);
console.log('File moved successfully:', response.data);
const fileName = sourcePath.split('/').pop();
notify('success', 'Файл перемещен', `"${fileName}" успешно перемещен`);
loadFiles();
} catch (error) {
console.error('Ошибка перемещения файла:', error);
notify('error', 'Ошибка перемещения', error.response?.data?.detail || 'Не удалось переместить файл');
alert(error.response?.data?.detail || 'Ошибка перемещения файла');
}
};
// Вырезать файлы
const handleCut = () => {
if (selectedFiles.length === 0) {
alert('Выберите файлы для перемещения');
return;
}
const filesToCut = selectedFiles.map(fileName => {
const filePath = currentPath ? `${currentPath}/${fileName}` : fileName;
return { name: fileName, path: filePath };
});
setCutFiles(filesToCut);
console.log('Files cut:', filesToCut);
};
// Вставить файлы
const handlePaste = async () => {
if (cutFiles.length === 0) {
alert('Нет файлов для вставки');
return;
}
try {
// Перемещаем каждый файл
for (const file of cutFiles) {
await moveFile(file.path, currentPath);
}
notify('success', 'Файлы перемещены', `Перемещено файлов: ${cutFiles.length}`);
// Очищаем список вырезанных файлов
setCutFiles([]);
setSelectedFiles([]);
setSelectAll(false);
} catch (error) {
console.error('Ошибка вставки файлов:', error);
}
};
// Отмена вырезания
const handleCancelCut = () => {
setCutFiles([]);
};
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="h-full flex flex-col bg-dark-900">
{/* Header */}
<div className="border-b border-gray-700 p-4">
<h2 className="text-xl font-semibold mb-4 text-white">Управление файлами</h2>
<div className="flex items-center gap-3">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по названию ф..."
className="w-full bg-dark-800 border-gray-700 border rounded-lg pl-10 pr-4 py-2 text-white placeholder:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Buttons */}
<label className="bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg cursor-pointer flex items-center gap-2 text-white font-medium transition shadow-lg">
<Download className="w-4 h-4" />
Загрузить
<input type="file" onChange={uploadFile} className="hidden" />
</label>
<button
onClick={loadFiles}
className="bg-red-600 hover:bg-red-700 px-4 py-2 rounded-lg flex items-center gap-2 text-white font-medium transition shadow-lg"
>
Обновить
</button>
{/* Кнопки вырезать/вставить */}
<button
onClick={handleCut}
disabled={selectedFiles.length === 0}
className={`bg-orange-600 hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed px-4 py-2 rounded-lg flex items-center gap-2 text-white font-medium transition shadow-lg`}
title="Вырезать выбранные файлы"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.121 14.121L19 19m-7-7l7-7m-7 7l-2.879 2.879M12 12L9.121 9.121m0 5.758a3 3 0 10-4.243 4.243 3 3 0 004.243-4.243zm0-5.758a3 3 0 10-4.243-4.243 3 3 0 004.243 4.243z" />
</svg>
Вырезать {selectedFiles.length > 0 && `(${selectedFiles.length})`}
</button>
<button
onClick={handlePaste}
disabled={cutFiles.length === 0}
className={`bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed px-4 py-2 rounded-lg flex items-center gap-2 text-white font-medium transition shadow-lg`}
title="Вставить файлы"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
Вставить {cutFiles.length > 0 && `(${cutFiles.length})`}
</button>
{cutFiles.length > 0 && (
<button
onClick={handleCancelCut}
className="bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg flex items-center gap-2 text-white font-medium transition shadow-lg"
title="Отменить вырезание"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Отмена
</button>
)}
<div className="relative new-menu-container">
<button
onClick={() => setShowNewMenu(!showNewMenu)}
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg flex items-center gap-2 text-white font-medium transition shadow-lg"
>
Новый
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showNewMenu && (
<div className="absolute right-0 mt-2 w-48 bg-dark-800 rounded-lg shadow-xl border-gray-700 border z-10">
<button
onClick={() => {
setCreatingNew('file');
setShowNewMenu(false);
setNewItemName('');
}}
className="w-full text-left px-4 py-2 hover:bg-dark-700 text-white transition rounded-t-lg"
>
📄 Создать файл
</button>
<button
onClick={() => {
setCreatingNew('folder');
setShowNewMenu(false);
setNewItemName('');
}}
className="w-full text-left px-4 py-2 hover:bg-dark-700 text-white transition rounded-b-lg"
>
📁 Создать папку
</button>
</div>
)}
</div>
</div>
</div>
{/* Path */}
<div className="bg-dark-800 px-4 py-3 border-gray-700 border-b">
<div className="flex items-center gap-2">
{currentPath && (
<button
onClick={goBack}
className="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded"
className="hover:bg-dark-700 px-3 py-1 rounded text-sm text-white transition"
>
Назад
</button>
)}
<span className="text-gray-400">/{currentPath || 'root'}</span>
<span className="text-gray-400 font-mono text-sm">
/{currentPath || ''}
</span>
{/* Индикатор вырезанных файлов */}
{cutFiles.length > 0 && (
<span className="ml-auto text-sm text-orange-400 flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.121 14.121L19 19m-7-7l7-7m-7 7l-2.879 2.879M12 12L9.121 9.121m0 5.758a3 3 0 10-4.243 4.243 3 3 0 004.243-4.243zm0-5.758a3 3 0 10-4.243-4.243 3 3 0 004.243 4.243z" />
</svg>
Вырезано файлов: {cutFiles.length}
</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>
{/* Table */}
<div className="flex-1 overflow-y-auto">
<table className="w-full">
<thead className="bg-gray-800 sticky top-0">
<thead className="bg-dark-800 sticky top-0 border-gray-700 border-b">
<tr>
<th className="text-left p-4">Имя</th>
<th className="text-left p-4">Размер</th>
<th className="text-right p-4">Действия</th>
<th className="text-left p-4 text-gray-400 font-medium text-sm">
<input
type="checkbox"
className="mr-3 cursor-pointer"
checked={selectAll}
onChange={handleSelectAll}
/>
Имя
</th>
<th className="text-left p-4 text-gray-400 font-medium text-sm">Тип</th>
<th className="text-left p-4 text-gray-400 font-medium text-sm">Размер</th>
<th className="text-left p-4 text-gray-400 font-medium text-sm">Последнее изменение</th>
<th className="text-left p-4 text-gray-400 font-medium text-sm">Разрешение</th>
<th className="text-right p-4 text-gray-400 font-medium text-sm">Действия</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
{/* Форма создания нового файла/папки */}
{creatingNew && (
<tr className="border-gray-700 border-b bg-blue-900 bg-opacity-20">
<td className="p-4" colSpan="6">
<div className="flex items-center gap-3">
{creatingNew === 'file' ? (
<File className="w-5 h-5 text-gray-400" />
) : (
<Folder className="w-5 h-5 text-blue-400" />
)}
<input
type="text"
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
creatingNew === 'file' ? createNewFile() : createNewFolder();
}
if (e.key === 'Escape') {
setCreatingNew(null);
setNewItemName('');
}
}}
placeholder={creatingNew === 'file' ? 'Имя файла...' : 'Имя папки...'}
autoFocus
className="flex-1 bg-dark-800 border-gray-700 border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={creatingNew === 'file' ? createNewFile : createNewFolder}
className="bg-green-600 hover:bg-green-700 px-4 py-2 rounded text-sm text-white transition"
>
Создать
</button>
<button
onClick={() => {
setCreatingNew(null);
setNewItemName('');
}}
className="bg-red-600 hover:bg-red-700 px-4 py-2 rounded text-sm text-white transition"
>
Отмена
</button>
</div>
</td>
</tr>
)}
{filteredFiles.length === 0 ? (
<tr>
<td colSpan="6" className="text-center py-12">
<div className="text-gray-400">
<Folder className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No data</p>
</div>
</td>
</tr>
) : (
filteredFiles.map((file) => {
const isCut = cutFiles.some(f => f.name === file.name);
return (
<tr
key={file.name}
className="border-b border-gray-800 hover:bg-gray-800"
className={`border-gray-700 border-b hover:bg-dark-700 transition ${
isCut ? 'opacity-50 bg-orange-900 bg-opacity-20' : ''
}`}
>
<td className="p-4">
{renamingFile === file.name ? (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedFiles.includes(file.name)}
onChange={() => handleSelectFile(file.name)}
onClick={(e) => e.stopPropagation()}
/>
{file.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-400" />
) : (
@@ -212,7 +603,7 @@ export default function FileManager({ serverName, token }) {
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"
className="bg-dark-800 border-gray-700 border rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
) : (
@@ -221,53 +612,57 @@ export default function FileManager({ serverName, token }) {
onClick={() => file.type === 'directory' && openFolder(file.name)}
onDoubleClick={() => file.type === 'file' && viewFile(file.name)}
>
<input
type="checkbox"
checked={selectedFiles.includes(file.name)}
onChange={() => handleSelectFile(file.name)}
onClick={(e) => e.stopPropagation()}
/>
{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>
<span className="text-white">{file.name}</span>
</div>
)}
</td>
<td className="p-4 text-gray-400">{formatSize(file.size)}</td>
<td className="p-4 text-gray-400 text-sm">
{file.type === 'directory' ? 'Папка' : 'Файл'}
</td>
<td className="p-4 text-gray-400 text-sm">{formatSize(file.size)}</td>
<td className="p-4 text-gray-400 text-sm">-</td>
<td className="p-4 text-gray-400 text-sm">-</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"
className="bg-dark-800 hover:bg-dark-700 p-2 rounded transition"
title="Просмотр"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => editFile(file.name)}
className="bg-purple-600 hover:bg-purple-700 p-2 rounded"
className="bg-dark-800 hover:bg-dark-700 p-2 rounded transition"
title="Редактировать"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => downloadFile(file.name)}
className="bg-green-600 hover:bg-green-700 p-2 rounded"
className="bg-dark-800 hover:bg-dark-700 p-2 rounded transition"
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"
className="bg-dark-800 hover:bg-dark-700 p-2 rounded text-red-400 transition"
title="Удалить"
>
<Trash2 className="w-4 h-4" />
@@ -275,7 +670,9 @@ export default function FileManager({ serverName, token }) {
</div>
</td>
</tr>
))}
);
})
)}
</tbody>
</table>
</div>

View File

@@ -3,28 +3,28 @@ 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="bg-dark-800 rounded-lg w-full max-w-4xl h-[80vh] flex flex-col border border-gray-700">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold">{file.name}</h2>
<h2 className="text-xl font-bold text-white">{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"
className="bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded flex items-center gap-2 text-white transition"
>
<Edit className="w-4 h-4" />
Редактировать
</button>
<button
onClick={onClose}
className="text-gray-400 hover:text-white"
className="text-gray-400 hover:text-white transition"
>
<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">
<div className="flex-1 overflow-auto p-4 bg-dark-900">
<pre className="text-sm text-gray-100 font-mono whitespace-pre-wrap">
{file.content}
</pre>
</div>

View File

@@ -0,0 +1,95 @@
import { useState, useEffect } from 'react';
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
export default function NotificationSystem({ theme }) {
const [notifications, setNotifications] = useState([]);
useEffect(() => {
// Слушаем события уведомлений
const handleNotification = (event) => {
const { type, title, message } = event.detail;
addNotification(type, title, message);
};
window.addEventListener('notification', handleNotification);
return () => window.removeEventListener('notification', handleNotification);
}, []);
const addNotification = (type, title, message) => {
const id = Date.now();
const notification = { id, type, title, message };
setNotifications(prev => [...prev, notification]);
// Автоматически удаляем через 5 секунд
setTimeout(() => {
removeNotification(id);
}, 5000);
};
const removeNotification = (id) => {
setNotifications(prev => prev.filter(n => n.id !== id));
};
const getIcon = (type) => {
switch (type) {
case 'success':
return <CheckCircle className="w-5 h-5" />;
case 'error':
return <AlertCircle className="w-5 h-5" />;
case 'warning':
return <AlertTriangle className="w-5 h-5" />;
case 'info':
default:
return <Info className="w-5 h-5" />;
}
};
const getColors = (type) => {
switch (type) {
case 'success':
return 'bg-green-600 border-green-500';
case 'error':
return 'bg-red-600 border-red-500';
case 'warning':
return 'bg-yellow-600 border-yellow-500';
case 'info':
default:
return 'bg-blue-600 border-blue-500';
}
};
return (
<div className="fixed top-4 right-4 z-50 space-y-2 max-w-sm">
{notifications.map((notification) => (
<div
key={notification.id}
className={`${getColors(notification.type)} border-l-4 rounded-lg shadow-2xl p-4 text-white animate-slide-in-right`}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
{getIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm mb-1">{notification.title}</h4>
<p className="text-sm opacity-90">{notification.message}</p>
</div>
<button
onClick={() => removeNotification(notification.id)}
className="flex-shrink-0 hover:bg-white hover:bg-opacity-20 rounded p-1 transition"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
);
}
// Вспомогательная функция для отправки уведомлений
export const notify = (type, title, message) => {
window.dispatchEvent(new CustomEvent('notification', {
detail: { type, title, message }
}));
};

View File

@@ -0,0 +1,424 @@
import { useState, useEffect } from 'react';
import { User, Lock, Server, MessageSquare, Shield, TrendingUp, Eye, EyeOff } from 'lucide-react';
import axios from 'axios';
import { API_URL } from '../config';
import { notify } from './NotificationSystem';
export default function Profile({ token, user, onUsernameChange, viewingUsername }) {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('overview');
const isViewingOther = viewingUsername && viewingUsername !== user?.username;
// Форма смены имени
const [usernameForm, setUsernameForm] = useState({
new_username: '',
password: ''
});
const [usernameLoading, setUsernameLoading] = useState(false);
const [showUsernamePassword, setShowUsernamePassword] = useState(false);
// Форма смены пароля
const [passwordForm, setPasswordForm] = useState({
old_password: '',
new_password: '',
confirm_password: ''
});
const [passwordLoading, setPasswordLoading] = useState(false);
const [showOldPassword, setShowOldPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
useEffect(() => {
loadStats();
}, [viewingUsername]);
const loadStats = async () => {
try {
const endpoint = isViewingOther
? `${API_URL}/api/profile/stats/${viewingUsername}`
: `${API_URL}/api/profile/stats`;
const { data } = await axios.get(endpoint, {
headers: { Authorization: `Bearer ${token}` }
});
setStats(data);
setLoading(false);
} catch (error) {
console.error('Ошибка загрузки статистики:', error);
setLoading(false);
}
};
const handleUsernameChange = async (e) => {
e.preventDefault();
if (!usernameForm.new_username.trim() || !usernameForm.password) {
alert('Заполните все поля');
return;
}
setUsernameLoading(true);
try {
const { data } = await axios.put(
`${API_URL}/api/profile/username`,
usernameForm,
{ headers: { Authorization: `Bearer ${token}` } }
);
localStorage.setItem('token', data.access_token);
notify('success', 'Имя изменено', `Ваше новое имя: ${data.username}`);
alert('Имя пользователя успешно изменено!');
setUsernameForm({ new_username: '', password: '' });
if (onUsernameChange) {
onUsernameChange(data.access_token, data.username);
}
loadStats();
} catch (error) {
notify('error', 'Ошибка изменения', error.response?.data?.detail || 'Не удалось изменить имя');
alert(error.response?.data?.detail || 'Ошибка изменения имени пользователя');
} finally {
setUsernameLoading(false);
}
};
const handlePasswordChange = async (e) => {
e.preventDefault();
if (!passwordForm.old_password || !passwordForm.new_password || !passwordForm.confirm_password) {
alert('Заполните все поля');
return;
}
if (passwordForm.new_password !== passwordForm.confirm_password) {
alert('Новые пароли не совпадают');
return;
}
if (passwordForm.new_password.length < 6) {
alert('Новый пароль должен быть не менее 6 символов');
return;
}
setPasswordLoading(true);
try {
await axios.put(
`${API_URL}/api/profile/password`,
{
old_password: passwordForm.old_password,
new_password: passwordForm.new_password
},
{ headers: { Authorization: `Bearer ${token}` } }
);
notify('success', 'Пароль изменён', 'Ваш пароль успешно обновлен');
alert('Пароль успешно изменён!');
setPasswordForm({ old_password: '', new_password: '', confirm_password: '' });
} catch (error) {
notify('error', 'Ошибка изменения', error.response?.data?.detail || 'Не удалось изменить пароль');
alert(error.response?.data?.detail || 'Ошибка изменения пароля');
} finally {
setPasswordLoading(false);
}
};
const getRoleName = (role) => {
switch (role) {
case 'owner': return 'Владелец';
case 'admin': return 'Администратор';
case 'support': return 'Тех. поддержка';
case 'banned': return 'Забанен';
default: return 'Пользователь';
}
};
const getRoleColor = (role) => {
switch (role) {
case 'owner': return 'bg-yellow-500/20 text-yellow-500 border-yellow-500/50';
case 'admin': return 'bg-blue-500/20 text-blue-500 border-blue-500/50';
case 'support': return 'bg-purple-500/20 text-purple-500 border-purple-500/50';
case 'banned': return 'bg-red-500/20 text-red-500 border-red-500/50';
default: return 'bg-gray-500/20 text-gray-500 border-gray-500/50';
}
};
if (loading) {
return (
<div className="h-full bg-dark-900 text-white flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400">Загрузка профиля...</p>
</div>
</div>
);
}
return (
<div className="h-full bg-dark-900 text-white p-6 overflow-y-auto">
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-2">
{isViewingOther ? `Профиль пользователя: ${viewingUsername}` : 'Личный кабинет'}
</h1>
<p className="text-gray-400">
{isViewingOther ? 'Просмотр профиля другого пользователя' : 'Управление профилем и настройками'}
</p>
</div>
{!isViewingOther && (
<div className="card mb-6 p-2 flex gap-2">
<button
onClick={() => setActiveTab('overview')}
className={`flex-1 px-4 py-3 rounded-xl font-medium transition ${
activeTab === 'overview' ? 'bg-primary-600 text-white' : 'hover:bg-dark-700'
}`}
>
<TrendingUp className="w-4 h-4 inline mr-2" />
Обзор
</button>
<button
onClick={() => setActiveTab('username')}
className={`flex-1 px-4 py-3 rounded-xl font-medium transition ${
activeTab === 'username' ? 'bg-primary-600 text-white' : 'hover:bg-dark-700'
}`}
>
<User className="w-4 h-4 inline mr-2" />
Имя пользователя
</button>
<button
onClick={() => setActiveTab('password')}
className={`flex-1 px-4 py-3 rounded-xl font-medium transition ${
activeTab === 'password' ? 'bg-primary-600 text-white' : 'hover:bg-dark-700'
}`}
>
<Lock className="w-4 h-4 inline mr-2" />
Пароль
</button>
</div>
)}
{(activeTab === 'overview' || isViewingOther) && (
<div className="space-y-6">
<div className="card p-6">
<div className="flex items-center gap-4 mb-6">
<div className="bg-primary-600 p-4 rounded-2xl">
<User className="w-8 h-8 text-white" />
</div>
<div>
<h2 className="text-2xl font-bold">{stats?.username}</h2>
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-lg border mt-2 ${getRoleColor(stats?.role)}`}>
<Shield className="w-4 h-4" />
<span className="font-medium">{getRoleName(stats?.role)}</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="card p-6">
<div className="flex items-center gap-3 mb-4">
<div className="bg-primary-600 p-3 rounded-xl">
<Server className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-gray-400">Всего серверов</p>
<p className="text-2xl font-bold">{stats?.total_servers || 0}</p>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Мои серверы:</span>
<span className="font-medium">{stats?.owned_servers?.length || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Доступные:</span>
<span className="font-medium">{stats?.accessible_servers?.length || 0}</span>
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center gap-3 mb-4">
<div className="bg-primary-600 p-3 rounded-xl">
<MessageSquare className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-gray-400">Мои тикеты</p>
<p className="text-2xl font-bold">{stats?.tickets?.total || 0}</p>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-yellow-500">На рассмотрении:</span>
<span className="font-medium">{stats?.tickets?.pending || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-blue-500">В работе:</span>
<span className="font-medium">{stats?.tickets?.in_progress || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-green-500">Закрыто:</span>
<span className="font-medium">{stats?.tickets?.closed || 0}</span>
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center gap-3 mb-4">
<div className="bg-primary-600 p-3 rounded-xl">
<Shield className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-gray-400">Ваша роль</p>
<p className="text-xl font-bold">{getRoleName(stats?.role)}</p>
</div>
</div>
<div className="text-sm text-gray-400">
{stats?.role === 'owner' && '👑 Владелец панели - полный контроль над всеми функциями'}
{stats?.role === 'admin' && 'Полный доступ ко всем функциям панели'}
{stats?.role === 'support' && 'Доступ к системе тикетов и поддержке'}
{stats?.role === 'user' && 'Доступ к своим серверам и тикетам'}
{stats?.role === 'banned' && '⛔ Аккаунт заблокирован, доступ запрещён'}
</div>
</div>
</div>
{stats?.owned_servers?.length > 0 && (
<div className="card p-6">
<h3 className="text-lg font-bold mb-4">Мои серверы</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{stats.owned_servers.map((server) => (
<div key={server.name} className="bg-dark-700 border border-gray-700 rounded-xl p-4">
<div className="flex items-center gap-3">
<Server className="w-5 h-5" />
<div>
<p className="font-medium">{server.displayName}</p>
<p className="text-xs text-gray-400">{server.name}</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{activeTab === 'username' && (
<div className="card p-6 max-w-2xl mx-auto">
<h2 className="text-xl font-bold mb-6">Изменить имя пользователя</h2>
<form onSubmit={handleUsernameChange} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2 text-white">Текущее имя пользователя</label>
<input type="text" value={stats?.username} disabled className="input cursor-not-allowed opacity-50" />
</div>
<div>
<label className="block text-sm font-medium mb-2 text-white">Новое имя пользователя</label>
<input
type="text"
value={usernameForm.new_username}
onChange={(e) => setUsernameForm({ ...usernameForm, new_username: e.target.value })}
placeholder="Введите новое имя"
className="input"
/>
<p className="text-xs text-gray-400 mt-1">Минимум 3 символа</p>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-white">Подтвердите паролем</label>
<div className="relative">
<input
type={showUsernamePassword ? 'text' : 'password'}
value={usernameForm.password}
onChange={(e) => setUsernameForm({ ...usernameForm, password: e.target.value })}
placeholder="Введите текущий пароль"
className="input pr-12"
/>
<button
type="button"
onClick={() => setShowUsernamePassword(!showUsernamePassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition"
>
{showUsernamePassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div className="bg-dark-700 border border-gray-700 rounded-xl p-4">
<p className="text-sm text-gray-400">
После изменения имени пользователя вы будете автоматически перелогинены с новым именем.
</p>
</div>
<button type="submit" disabled={usernameLoading} className="btn-primary w-full disabled:opacity-50">
{usernameLoading ? 'Изменение...' : 'Изменить имя пользователя'}
</button>
</form>
</div>
)}
{activeTab === 'password' && (
<div className="card p-6 max-w-2xl mx-auto">
<h2 className="text-xl font-bold mb-6">Изменить пароль</h2>
<form onSubmit={handlePasswordChange} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2 text-white">Текущий пароль</label>
<div className="relative">
<input
type={showOldPassword ? 'text' : 'password'}
value={passwordForm.old_password}
onChange={(e) => setPasswordForm({ ...passwordForm, old_password: e.target.value })}
placeholder="Введите текущий пароль"
className="input pr-12"
/>
<button
type="button"
onClick={() => setShowOldPassword(!showOldPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition"
>
{showOldPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-white">Новый пароль</label>
<div className="relative">
<input
type={showNewPassword ? 'text' : 'password'}
value={passwordForm.new_password}
onChange={(e) => setPasswordForm({ ...passwordForm, new_password: e.target.value })}
placeholder="Введите новый пароль"
className="input pr-12"
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition"
>
{showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
<p className="text-xs text-gray-400 mt-1">Минимум 6 символов</p>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-white">Подтвердите новый пароль</label>
<input
type="password"
value={passwordForm.confirm_password}
onChange={(e) => setPasswordForm({ ...passwordForm, confirm_password: e.target.value })}
placeholder="Повторите новый пароль"
className="input"
/>
</div>
<div className="bg-dark-700 border border-gray-700 rounded-xl p-4">
<p className="text-sm text-gray-400">
После изменения пароля используйте новый пароль для входа в систему.
</p>
</div>
<button type="submit" disabled={passwordLoading} className="btn-primary w-full disabled:opacity-50">
{passwordLoading ? 'Изменение...' : 'Изменить пароль'}
</button>
</form>
</div>
)}
</div>
</div>
);
}

View File

@@ -29,17 +29,17 @@ export default function Stats({ serverName, token }) {
};
return (
<div className="p-8 bg-gray-900">
<h2 className="text-2xl font-bold mb-6">Статистика сервера</h2>
<div className="p-8 bg-dark-900">
<h2 className="text-2xl font-bold mb-6 text-white">Статистика сервера</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="card p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">CPU</h3>
<h3 className="text-lg font-semibold text-white">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="text-3xl font-bold mb-2 text-white">{stats.cpu}%</div>
<div className="w-full bg-dark-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${Math.min(stats.cpu, 100)}%` }}
@@ -47,13 +47,13 @@ export default function Stats({ serverName, token }) {
</div>
</div>
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div className="card p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">ОЗУ</h3>
<h3 className="text-lg font-semibold text-white">ОЗУ</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="text-3xl font-bold mb-2 text-white">{stats.memory} МБ</div>
<div className="w-full bg-dark-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)}%` }}
@@ -61,27 +61,27 @@ export default function Stats({ serverName, token }) {
</div>
</div>
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div className="card p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Диск</h3>
<h3 className="text-lg font-semibold text-white">Диск</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-3xl font-bold mb-2 text-white">{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="mt-8 card p-6">
<h3 className="text-lg font-semibold mb-4 text-white">Статус</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">
<span className="text-xl text-white">
{stats.status === 'running' ? 'Запущен' : 'Остановлен'}
</span>
</div>

View File

@@ -5,6 +5,7 @@ export default function ThemeSelector({ currentTheme, onThemeChange }) {
const theme = getTheme(currentTheme);
const themeColors = {
modern: 'bg-gradient-to-r from-green-600 to-emerald-600',
dark: 'bg-gray-800',
light: 'bg-gray-100',
purple: 'bg-purple-600',

View File

@@ -0,0 +1,279 @@
import { useState, useEffect, useRef } from 'react';
import { ArrowLeft, Send, Clock, AlertCircle, CheckCircle } from 'lucide-react';
import axios from 'axios';
import { API_URL } from '../config';
import { notify } from './NotificationSystem';
export default function TicketChat({ ticket, token, user, onBack }) {
const [messages, setMessages] = useState(ticket.messages || []);
const [newMessage, setNewMessage] = useState('');
const [currentTicket, setCurrentTicket] = useState(ticket);
const [loading, setLoading] = useState(false);
const [previousMessagesCount, setPreviousMessagesCount] = useState(ticket.messages?.length || 0);
const messagesEndRef = useRef(null);
useEffect(() => {
scrollToBottom();
const interval = setInterval(loadTicket, 3000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
scrollToBottom();
}, [messages]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const loadTicket = async () => {
try {
const { data } = await axios.get(`${API_URL}/api/tickets/${ticket.id}`, {
headers: { Authorization: `Bearer ${token}` }
});
// Проверяем новые сообщения
if (data.messages.length > previousMessagesCount) {
const newMessagesCount = data.messages.length - previousMessagesCount;
const lastMessage = data.messages[data.messages.length - 1];
// Уведомляем только если сообщение не от текущего пользователя
if (lastMessage.author !== user.username && lastMessage.author !== 'system') {
notify('info', 'Новое сообщение', `${lastMessage.author}: ${lastMessage.text.substring(0, 50)}${lastMessage.text.length > 50 ? '...' : ''}`);
}
setPreviousMessagesCount(data.messages.length);
}
// Проверяем изменение статуса
if (data.status !== currentTicket.status) {
const statusNames = {
'pending': 'На рассмотрении',
'in_progress': 'В работе',
'closed': 'Закрыт'
};
notify('info', 'Статус изменён', `Тикет #${ticket.id}: ${statusNames[data.status]}`);
}
setCurrentTicket(data);
setMessages(data.messages || []);
} catch (error) {
console.error('Ошибка загрузки тикета:', error);
}
};
const sendMessage = async (e) => {
e.preventDefault();
if (!newMessage.trim() || loading) return;
setLoading(true);
try {
const { data } = await axios.post(
`${API_URL}/api/tickets/${ticket.id}/message`,
{ text: newMessage.trim() },
{ headers: { Authorization: `Bearer ${token}` } }
);
setMessages(data.ticket.messages);
setCurrentTicket(data.ticket);
setPreviousMessagesCount(data.ticket.messages.length);
setNewMessage('');
notify('success', 'Сообщение отправлено', 'Ваше сообщение успешно отправлено');
} catch (error) {
console.error('Ошибка отправки сообщения:', error);
notify('error', 'Ошибка отправки', error.response?.data?.detail || 'Не удалось отправить сообщение');
alert(error.response?.data?.detail || 'Ошибка отправки сообщения');
} finally {
setLoading(false);
}
};
const changeStatus = async (newStatus) => {
const statusNames = {
'pending': 'На рассмотрении',
'in_progress': 'В работе',
'closed': 'Закрыт'
};
try {
const { data } = await axios.put(
`${API_URL}/api/tickets/${ticket.id}/status`,
{ status: newStatus },
{ headers: { Authorization: `Bearer ${token}` } }
);
setCurrentTicket(data.ticket);
setMessages(data.ticket.messages);
setPreviousMessagesCount(data.ticket.messages.length);
notify('success', 'Статус изменён', `Тикет #${ticket.id} теперь: ${statusNames[newStatus]}`);
} catch (error) {
console.error('Ошибка изменения статуса:', error);
notify('error', 'Ошибка изменения статуса', error.response?.data?.detail || 'Не удалось изменить статус');
alert(error.response?.data?.detail || 'Ошибка изменения статуса');
}
};
const getStatusIcon = (status) => {
switch (status) {
case 'pending':
return <Clock className="w-5 h-5 text-yellow-500" />;
case 'in_progress':
return <AlertCircle className="w-5 h-5 text-blue-500" />;
case 'closed':
return <CheckCircle className="w-5 h-5 text-green-500" />;
default:
return <Clock className="w-5 h-5" />;
}
};
const getStatusText = (status) => {
switch (status) {
case 'pending':
return 'На рассмотрении';
case 'in_progress':
return 'В работе';
case 'closed':
return 'Закрыт';
default:
return status;
}
};
const getStatusColor = (status) => {
switch (status) {
case 'pending':
return 'bg-yellow-500/20 text-yellow-500 border-yellow-500/50';
case 'in_progress':
return 'bg-blue-500/20 text-blue-500 border-blue-500/50';
case 'closed':
return 'bg-green-500/20 text-green-500 border-green-500/50';
default:
return 'bg-gray-500/20 text-gray-500 border-gray-500/50';
}
};
const canChangeStatus = user.role === 'owner' || user.role === 'admin' || user.role === 'support';
return (
<div className="h-full flex flex-col bg-dark-900">
{/* Header */}
<div className="bg-dark-800 border-gray-700 border-b p-4">
<div className="flex items-center gap-4 mb-3">
<button
onClick={onBack}
className="hover:bg-dark-700 p-2 rounded-lg transition"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex-1">
<h2 className="text-lg font-bold">{currentTicket.title}</h2>
<p className="text-sm text-gray-400">
Автор: {currentTicket.author} Создан: {new Date(currentTicket.created_at).toLocaleString('ru-RU')}
</p>
</div>
<div className={`px-3 py-2 rounded-lg border flex items-center gap-2 ${getStatusColor(currentTicket.status)}`}>
{getStatusIcon(currentTicket.status)}
<span className="font-medium">{getStatusText(currentTicket.status)}</span>
</div>
</div>
{/* Status Controls */}
{canChangeStatus && (
<div className="flex gap-2">
<button
onClick={() => changeStatus('pending')}
disabled={currentTicket.status === 'pending'}
className={`flex-1 px-3 py-2 rounded-lg border transition ${
currentTicket.status === 'pending'
? 'bg-yellow-500/20 text-yellow-500 border-yellow-500/50'
: 'bg-dark-800 hover:bg-dark-700 border-gray-700'
}`}
>
<Clock className="w-4 h-4 inline mr-2" />
На рассмотрении
</button>
<button
onClick={() => changeStatus('in_progress')}
disabled={currentTicket.status === 'in_progress'}
className={`flex-1 px-3 py-2 rounded-lg border transition ${
currentTicket.status === 'in_progress'
? 'bg-blue-500/20 text-blue-500 border-blue-500/50'
: 'bg-dark-800 hover:bg-dark-700 border-gray-700'
}`}
>
<AlertCircle className="w-4 h-4 inline mr-2" />
В работе
</button>
<button
onClick={() => changeStatus('closed')}
disabled={currentTicket.status === 'closed'}
className={`flex-1 px-3 py-2 rounded-lg border transition ${
currentTicket.status === 'closed'
? 'bg-green-500/20 text-green-500 border-green-500/50'
: 'bg-dark-800 hover:bg-dark-700 border-gray-700'
}`}
>
<CheckCircle className="w-4 h-4 inline mr-2" />
Закрыт
</button>
</div>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, index) => (
<div
key={index}
className={`flex ${msg.author === user.username ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] rounded-2xl px-4 py-3 ${
msg.author === 'system'
? 'bg-dark-700 border-gray-700 border text-center'
: msg.author === user.username
? 'bg-primary-600 text-white'
: 'bg-dark-800 border-gray-700 border'
}`}
>
{msg.author !== 'system' && msg.author !== user.username && (
<div className="text-xs font-semibold mb-1 text-gray-400">
{msg.author}
</div>
)}
<div className="whitespace-pre-wrap break-words">{msg.text}</div>
<div className={`text-xs mt-1 ${
msg.author === user.username ? 'text-white/70' : 'text-gray-400'
}`}>
{new Date(msg.timestamp).toLocaleTimeString('ru-RU')}
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
{currentTicket.status !== 'closed' && (
<form onSubmit={sendMessage} className="border-gray-700 border-t p-4">
<div className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Введите сообщение..."
disabled={loading}
className="input flex-1"
/>
<button
type="submit"
disabled={loading || !newMessage.trim()}
className="btn-primary px-6 py-3 flex items-center gap-2 disabled:opacity-50"
>
<Send className="w-4 h-4" />
Отправить
</button>
</div>
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import { MessageSquare, Plus, Clock, CheckCircle, AlertCircle } from 'lucide-react';
import axios from 'axios';
import { API_URL } from '../config';
import TicketChat from './TicketChat';
import CreateTicketModal from './CreateTicketModal';
import { notify } from './NotificationSystem';
export default function Tickets({ token, user }) {
const [tickets, setTickets] = useState([]);
const [selectedTicket, setSelectedTicket] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [loading, setLoading] = useState(true);
const [previousTickets, setPreviousTickets] = useState([]);
useEffect(() => {
loadTickets();
const interval = setInterval(loadTickets, 5000);
return () => clearInterval(interval);
}, []);
const loadTickets = async () => {
try {
const { data } = await axios.get(`${API_URL}/api/tickets`, {
headers: { Authorization: `Bearer ${token}` }
});
// Проверяем новые сообщения в тикетах
if (previousTickets.length > 0) {
data.forEach(ticket => {
const prevTicket = previousTickets.find(t => t.id === ticket.id);
if (prevTicket && ticket.messages.length > prevTicket.messages.length) {
const newMessage = ticket.messages[ticket.messages.length - 1];
// Уведомляем только если сообщение не от текущего пользователя
if (newMessage.author !== user.username) {
notify('info', 'Новое сообщение', `Тикет #${ticket.id}: ${newMessage.author} ответил`);
}
}
});
}
setPreviousTickets(data);
setTickets(data);
setLoading(false);
} catch (error) {
console.error('Ошибка загрузки тикетов:', error);
setLoading(false);
}
};
const handleTicketCreated = () => {
setShowCreateModal(false);
notify('success', 'Тикет создан', 'Ваш тикет успешно создан');
loadTickets();
};
const getStatusIcon = (status) => {
switch (status) {
case 'pending':
return <Clock className="w-4 h-4 text-yellow-500" />;
case 'in_progress':
return <AlertCircle className="w-4 h-4 text-blue-500" />;
case 'closed':
return <CheckCircle className="w-4 h-4 text-green-500" />;
default:
return <Clock className="w-4 h-4" />;
}
};
const getStatusText = (status) => {
switch (status) {
case 'pending':
return 'На рассмотрении';
case 'in_progress':
return 'В работе';
case 'closed':
return 'Закрыт';
default:
return status;
}
};
const getStatusColor = (status) => {
switch (status) {
case 'pending':
return 'bg-yellow-500/20 text-yellow-500 border-yellow-500/50';
case 'in_progress':
return 'bg-blue-500/20 text-blue-500 border-blue-500/50';
case 'closed':
return 'bg-green-500/20 text-green-500 border-green-500/50';
default:
return 'bg-gray-500/20 text-gray-500 border-gray-500/50';
}
};
if (selectedTicket) {
return (
<TicketChat
ticket={selectedTicket}
token={token}
user={user}
onBack={() => {
setSelectedTicket(null);
loadTickets();
}}
/>
);
}
return (
<div className="h-full bg-dark-900 text-white p-6">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold mb-2">Тикеты</h1>
<p className="text-gray-400">Система поддержки</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Создать тикет
</button>
</div>
{/* Tickets List */}
{loading ? (
<div className="text-center py-12">
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400">Загрузка тикетов...</p>
</div>
) : tickets.length === 0 ? (
<div className="card p-12 text-center">
<MessageSquare className="w-16 h-16 mx-auto mb-4 text-gray-500 opacity-50" />
<p className="text-lg font-medium mb-2">Нет тикетов</p>
<p className="text-sm text-gray-400 mb-4">
Создайте первый тикет для обращения в поддержку
</p>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary"
>
Создать тикет
</button>
</div>
) : (
<div className="grid gap-4">
{tickets.map((ticket) => (
<div
key={ticket.id}
onClick={() => setSelectedTicket(ticket)}
className="card p-6 cursor-pointer hover:border-gray-600 transition-all duration-200"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="text-lg font-semibold mb-2">{ticket.title}</h3>
<p className="text-sm text-gray-400 line-clamp-2">
{ticket.description}
</p>
</div>
<div className={`px-3 py-1 rounded-lg border flex items-center gap-2 ${getStatusColor(ticket.status)}`}>
{getStatusIcon(ticket.status)}
<span className="text-sm font-medium">{getStatusText(ticket.status)}</span>
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-400">
Автор: <span className="text-white">{ticket.author}</span>
</span>
<span className="text-gray-400"></span>
<span className="text-gray-400">
Сообщений: <span className="text-white">{ticket.messages?.length || 0}</span>
</span>
<span className="text-gray-400"></span>
<span className="text-gray-400">
{new Date(ticket.created_at).toLocaleString('ru-RU')}
</span>
</div>
</div>
))}
</div>
)}
</div>
{showCreateModal && (
<CreateTicketModal
token={token}
onClose={() => setShowCreateModal(false)}
onCreated={handleTicketCreated}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,327 @@
import { useState, useEffect } from 'react';
import { Users, Shield, Ban, Trash2, UserCheck, Server } from 'lucide-react';
import axios from 'axios';
import { notify } from './NotificationSystem';
const UserManagement = ({ token, currentUser }) => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedUser, setSelectedUser] = useState(null);
const [showRoleModal, setShowRoleModal] = useState(false);
const [showAccessModal, setShowAccessModal] = useState(false);
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Загрузка пользователей
const loadUsers = async () => {
try {
const response = await axios.get(`${API_URL}/api/users`, {
headers: { Authorization: `Bearer ${token}` }
});
// Преобразуем объект в массив если нужно
const usersData = Array.isArray(response.data)
? response.data
: Object.values(response.data);
setUsers(usersData);
setLoading(false);
} catch (error) {
console.error('Ошибка загрузки пользователей:', error);
notify('error', 'Ошибка загрузки', 'Не удалось загрузить пользователей');
setLoading(false);
}
};
useEffect(() => {
loadUsers();
}, []);
// Изменить роль
const changeRole = async (username, newRole) => {
try {
const token = localStorage.getItem('token');
await axios.put(
`${API_URL}/api/users/${username}/role`,
{ role: newRole },
{ headers: { Authorization: `Bearer ${token}` } }
);
notify('success', 'Роль изменена', `Роль пользователя ${username} изменена на ${newRole}`);
loadUsers();
setShowRoleModal(false);
} catch (error) {
console.error('Ошибка изменения роли:', error);
notify('error', 'Ошибка изменения', error.response?.data?.detail || 'Не удалось изменить роль');
}
};
// Заблокировать пользователя
const banUser = async (username) => {
if (!confirm(`Заблокировать пользователя ${username}?`)) return;
try {
const token = localStorage.getItem('token');
await axios.post(
`${API_URL}/api/users/${username}/ban`,
{ reason: 'Заблокирован администратором' },
{ headers: { Authorization: `Bearer ${token}` } }
);
notify('success', 'Пользователь заблокирован', `${username} успешно заблокирован`);
loadUsers();
} catch (error) {
console.error('Ошибка блокировки:', error);
notify('error', 'Ошибка блокировки', error.response?.data?.detail || 'Не удалось заблокировать пользователя');
}
};
// Разблокировать пользователя
const unbanUser = async (username) => {
try {
const token = localStorage.getItem('token');
await axios.post(
`${API_URL}/api/users/${username}/unban`,
{},
{ headers: { Authorization: `Bearer ${token}` } }
);
notify('success', 'Пользователь разблокирован', `${username} успешно разблокирован`);
loadUsers();
} catch (error) {
console.error('Ошибка разблокировки:', error);
notify('error', 'Ошибка разблокировки', error.response?.data?.detail || 'Не удалось разблокировать пользователя');
}
};
// Удалить пользователя
const deleteUser = async (username) => {
if (!confirm(`Удалить пользователя ${username}? Это действие необратимо!`)) return;
try {
const token = localStorage.getItem('token');
await axios.delete(
`${API_URL}/api/users/${username}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
notify('success', 'Пользователь удалён', `${username} успешно удалён`);
loadUsers();
} catch (error) {
console.error('Ошибка удаления:', error);
notify('error', 'Ошибка удаления', error.response?.data?.detail || 'Не удалось удалить пользователя');
}
};
// Цвета ролей
const getRoleColor = (role) => {
switch (role) {
case 'owner': return 'text-yellow-400';
case 'admin': return 'text-red-400';
case 'support': return 'text-blue-400';
case 'user': return 'text-green-400';
case 'banned': return 'text-gray-400';
default: return 'text-gray-400';
}
};
const getRoleName = (role) => {
switch (role) {
case 'owner': return 'Владелец';
case 'admin': return 'Администратор';
case 'support': return 'Поддержка';
case 'user': return 'Пользователь';
case 'banned': return 'Заблокирован';
default: return role;
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-400">Загрузка пользователей...</div>
</div>
);
}
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Users className="w-8 h-8 text-blue-400" />
<div>
<h2 className="text-2xl font-bold text-white">Управление пользователями</h2>
<p className="text-gray-400">Всего пользователей: {users.length}</p>
</div>
</div>
</div>
{/* Список пользователей */}
<div className="grid gap-4">
{users.map((user) => (
<div
key={user.username}
className="bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-colors"
>
<div className="flex items-center justify-between">
{/* Информация о пользователе */}
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gray-700 rounded-full flex items-center justify-center">
<Users className="w-6 h-6 text-gray-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-white">{user.username}</h3>
{user.role === 'owner' && (
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded">
👑 Владелец
</span>
)}
{user.role === 'admin' && (
<span className="px-2 py-1 bg-red-500/20 text-red-400 text-xs rounded">
🛡 Админ
</span>
)}
{user.role === 'support' && (
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded">
💬 Поддержка
</span>
)}
{user.role === 'banned' && (
<span className="px-2 py-1 bg-gray-500/20 text-gray-400 text-xs rounded">
🚫 Заблокирован
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-gray-400">
<span className={getRoleColor(user.role)}>
{getRoleName(user.role)}
</span>
{user.resource_access?.servers && user.resource_access.servers.length > 0 && (
<span className="flex items-center gap-1">
<Server className="w-4 h-4" />
{user.resource_access.servers.length} серверов
</span>
)}
</div>
{/* Права */}
{user.permissions && (
<div className="flex flex-wrap gap-2 mt-2">
{user.permissions.manage_users && (
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded">
Управление пользователями
</span>
)}
{user.permissions.manage_servers && (
<span className="px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded">
Управление серверами
</span>
)}
{user.permissions.view_all_resources && (
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">
Просмотр всех ресурсов
</span>
)}
</div>
)}
</div>
</div>
{/* Действия */}
{currentUser.role === 'owner' && user.username !== currentUser.username && (
<div className="flex items-center gap-2">
{/* Изменить роль */}
<button
onClick={() => {
setSelectedUser(user);
setShowRoleModal(true);
}}
className="px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors flex items-center gap-2"
title="Изменить роль"
>
<Shield className="w-4 h-4" />
Роль
</button>
{/* Заблокировать/Разблокировать */}
{user.role !== 'banned' ? (
<button
onClick={() => banUser(user.username)}
className="px-3 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded transition-colors flex items-center gap-2"
title="Заблокировать"
>
<Ban className="w-4 h-4" />
</button>
) : (
<button
onClick={() => unbanUser(user.username)}
className="px-3 py-2 bg-green-500 hover:bg-green-600 text-white rounded transition-colors flex items-center gap-2"
title="Разблокировать"
>
<UserCheck className="w-4 h-4" />
</button>
)}
{/* Удалить */}
<button
onClick={() => deleteUser(user.username)}
className="px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded transition-colors flex items-center gap-2"
title="Удалить"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
))}
</div>
{/* Модальное окно изменения роли */}
{showRoleModal && selectedUser && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-lg p-6 w-96 border border-gray-700">
<h3 className="text-xl font-bold text-white mb-4">
Изменить роль: {selectedUser.username}
</h3>
<div className="space-y-2">
{['owner', 'admin', 'support', 'user', 'banned'].map((role) => (
<button
key={role}
onClick={() => changeRole(selectedUser.username, role)}
className={`w-full px-4 py-3 rounded text-left transition-colors ${
selectedUser.role === role
? 'bg-blue-500 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
<div className="font-semibold">{getRoleName(role)}</div>
<div className="text-sm opacity-75">
{role === 'owner' && 'Полный контроль над панелью'}
{role === 'admin' && 'Управление панелью без изменения ролей'}
{role === 'support' && 'Работа с тикетами поддержки'}
{role === 'user' && 'Базовые возможности'}
{role === 'banned' && 'Доступ заблокирован'}
</div>
</button>
))}
</div>
<button
onClick={() => setShowRoleModal(false)}
className="w-full mt-4 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors"
>
Отмена
</button>
</div>
</div>
)}
</div>
);
};
export default UserManagement;

View File

@@ -3,7 +3,7 @@ import { Users as UsersIcon, Trash2, Shield, User } from 'lucide-react';
import axios from 'axios';
import { API_URL } from '../config';
export default function Users({ token }) {
export default function Users({ token, onViewProfile }) {
const [users, setUsers] = useState([]);
const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -50,13 +50,7 @@ export default function Users({ token }) {
}
};
const toggleRole = async (username, currentRole) => {
const newRole = currentRole === 'admin' ? 'user' : 'admin';
if (!confirm(`Изменить роль пользователя ${username} на ${newRole}?`)) {
return;
}
const changeRole = async (username, newRole) => {
try {
await axios.put(
`${API_URL}/api/users/${username}/role`,
@@ -108,7 +102,7 @@ export default function Users({ token }) {
<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' ? 'bg-blue-600' : user.role === 'support' ? 'bg-purple-600' : user.role === 'banned' ? 'bg-red-600' : 'bg-gray-700'
}`}>
{user.role === 'admin' ? (
<Shield className="w-6 h-6" />
@@ -117,20 +111,30 @@ export default function Users({ token }) {
)}
</div>
<div>
<h3 className="text-lg font-semibold">{user.username}</h3>
<button
onClick={() => onViewProfile && onViewProfile(user.username)}
className="text-lg font-semibold hover:text-blue-400 transition cursor-pointer text-left"
title="Просмотреть профиль"
>
{user.username}
</button>
<p className="text-sm text-gray-400">
{user.role === 'admin' ? 'Администратор' : 'Пользователь'}
{user.role === 'admin' ? 'Администратор' : user.role === 'support' ? 'Тех. поддержка' : user.role === 'banned' ? 'Забанен' : 'Пользователь'}
</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"
<select
value={user.role}
onChange={(e) => changeRole(user.username, e.target.value)}
className="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded text-sm border border-gray-600 focus:outline-none focus:border-blue-500"
>
{user.role === 'admin' ? 'Сделать пользователем' : 'Сделать админом'}
</button>
<option value="user">Пользователь</option>
<option value="support">Тех. поддержка</option>
<option value="admin">Администратор</option>
<option value="banned">Забанен</option>
</select>
<button
onClick={() => deleteUser(user.username)}
className="bg-red-600 hover:bg-red-700 p-2 rounded"
@@ -141,7 +145,7 @@ export default function Users({ token }) {
</div>
</div>
{user.role !== 'admin' && (
{user.role === 'user' && (
<div>
<h4 className="text-sm font-medium mb-2 text-gray-400">
Доступ к серверам:
@@ -175,6 +179,18 @@ export default function Users({ token }) {
Администратор имеет доступ ко всем серверам
</p>
)}
{user.role === 'support' && (
<p className="text-sm text-gray-400">
Тех. поддержка имеет доступ к системе тикетов
</p>
)}
{user.role === 'banned' && (
<p className="text-sm text-red-400">
Пользователь заблокирован и не имеет доступа к панели
</p>
)}
</div>
))}
</div>

View File

@@ -2,21 +2,235 @@
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* MCSManager Style Global Styles */
@layer base {
* {
@apply border-dark-700;
}
body {
body {
@apply bg-dark-900 text-gray-100 antialiased;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar styling */
::-webkit-scrollbar {
@apply w-2 h-2;
}
::-webkit-scrollbar-track {
@apply bg-dark-850;
}
::-webkit-scrollbar-thumb {
@apply bg-dark-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-dark-500;
}
}
#root {
width: 100%;
height: 100vh;
@layer components {
/* Card styles */
.card {
@apply bg-dark-800 rounded-lg border border-dark-700 shadow-lg;
}
.card-hover {
@apply card transition-all duration-200 hover:border-primary-500 hover:shadow-glow;
}
/* Button styles */
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-900;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700
focus:ring-primary-500 shadow-md hover:shadow-glow;
}
.btn-secondary {
@apply btn bg-dark-700 text-gray-200 hover:bg-dark-600
focus:ring-dark-500 border border-dark-600;
}
.btn-success {
@apply btn bg-green-600 text-white hover:bg-green-700
focus:ring-green-500 shadow-md;
}
.btn-danger {
@apply btn bg-red-600 text-white hover:bg-red-700
focus:ring-red-500 shadow-md;
}
.btn-warning {
@apply btn bg-yellow-600 text-white hover:bg-yellow-700
focus:ring-yellow-500 shadow-md;
}
.btn-icon {
@apply p-2 rounded-lg transition-all duration-200
hover:bg-dark-700 focus:outline-none focus:ring-2
focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-dark-900;
}
/* Input styles */
.input {
@apply w-full px-4 py-2 bg-dark-850 border border-dark-700 rounded-lg
text-gray-100 placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
transition-all duration-200;
}
.input:focus {
@apply shadow-glow;
}
/* Badge styles */
.badge {
@apply px-2 py-1 text-xs font-semibold rounded-full;
}
.badge-success {
@apply badge bg-green-600/20 text-green-400 border border-green-600/30;
}
.badge-danger {
@apply badge bg-red-600/20 text-red-400 border border-red-600/30;
}
.badge-warning {
@apply badge bg-yellow-600/20 text-yellow-400 border border-yellow-600/30;
}
.badge-info {
@apply badge bg-blue-600/20 text-blue-400 border border-blue-600/30;
}
/* Sidebar styles */
.sidebar-item {
@apply flex items-center gap-3 px-4 py-3 rounded-lg
text-gray-300 hover:text-white hover:bg-dark-700
transition-all duration-200 cursor-pointer;
}
.sidebar-item-active {
@apply sidebar-item bg-primary-600/20 text-primary-400
border-l-4 border-primary-500 hover:bg-primary-600/30;
}
/* Tab styles */
.tab {
@apply px-4 py-2 font-medium text-gray-400 hover:text-gray-200
border-b-2 border-transparent hover:border-dark-600
transition-all duration-200 cursor-pointer;
}
.tab-active {
@apply tab text-primary-400 border-primary-500 hover:border-primary-500;
}
/* Stats card */
.stat-card {
@apply card p-4 flex items-center gap-4;
}
.stat-icon {
@apply p-3 rounded-lg bg-primary-600/20 text-primary-400;
}
/* Server card */
.server-card {
@apply card-hover p-6 cursor-pointer;
}
.server-card-selected {
@apply server-card border-primary-500 bg-primary-600/10 shadow-glow;
}
/* Modal overlay */
.modal-overlay {
@apply fixed inset-0 bg-black/70 backdrop-blur-sm z-40
flex items-center justify-center p-4;
}
.modal-content {
@apply card p-6 max-w-5xl w-full max-h-[90vh] overflow-y-auto;
}
/* Console terminal */
.console-terminal {
@apply bg-dark-950 rounded-lg p-4 font-mono text-sm
text-green-400 overflow-auto border border-dark-700;
}
/* Loading spinner */
.spinner {
@apply animate-spin rounded-full border-2 border-gray-600
border-t-primary-500;
}
/* Tooltip */
.tooltip {
@apply absolute z-50 px-2 py-1 text-xs font-medium text-white
bg-dark-700 rounded shadow-lg pointer-events-none;
}
}
/* Animations */
@keyframes slideIn {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-slide-in {
animation: slideIn 0.3s ease-out;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
/* Glow effect for active elements */
.glow-effect {
position: relative;
}
.glow-effect::before {
content: '';
position: absolute;
inset: -2px;
border-radius: inherit;
padding: 2px;
background: linear-gradient(45deg, #0ea5e9, #3b82f6, #8b5cf6);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.3s;
}
.glow-effect:hover::before {
opacity: 0.5;
}

View File

@@ -1,4 +1,27 @@
export const themes = {
modern: {
name: 'Современная',
gradient: 'from-green-400 to-emerald-600',
primary: 'bg-[#0f1115]',
secondary: 'bg-[#1a1d24]',
tertiary: 'bg-[#23262e]',
accent: 'bg-green-600',
accentHover: 'hover:bg-green-700',
text: 'text-gray-100',
textSecondary: 'text-gray-400',
border: 'border-gray-800',
hover: 'hover:bg-[#23262e]',
input: 'bg-[#0f1115] border-gray-700',
card: 'bg-[#1a1d24]',
cardHover: 'hover:bg-[#23262e]',
success: 'bg-green-600',
successHover: 'hover:bg-green-700',
danger: 'bg-gray-700',
dangerHover: 'hover:bg-gray-600',
warning: 'bg-yellow-600',
console: 'bg-[#0f1115]',
consoleText: 'text-gray-300',
},
dark: {
name: 'Тёмная',
gradient: 'from-blue-400 to-purple-600',

View File

@@ -5,7 +5,44 @@ export default {
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
extend: {
colors: {
// MCSManager style dark theme
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
dark: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
850: '#1a2332',
900: '#0f172a',
950: '#020617',
},
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
},
boxShadow: {
'glow': '0 0 20px rgba(14, 165, 233, 0.3)',
'glow-lg': '0 0 30px rgba(14, 165, 233, 0.4)',
},
},
},
plugins: [],
}

2
key.py Normal file
View File

@@ -0,0 +1,2 @@
import secrets
print(secrets.token_urlsafe(32))

97
nginx/default.conf Normal file
View File

@@ -0,0 +1,97 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Логирование
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Основные настройки
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
client_max_body_size 100M;
# Gzip сжатие
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/atom+xml image/svg+xml;
# Upstream для backend API
upstream mc_panel_api {
server mc-panel:8000;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Статические файлы frontend
location / {
try_files $uri $uri/ /index.html;
# Кэширование статических файлов
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# API запросы к backend
location /api/ {
proxy_pass http://mc_panel_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Таймауты
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# WebSocket для консоли
location /ws/ {
proxy_pass http://mc_panel_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket таймауты
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
# Health check
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Безопасность
location ~ /\. {
deny all;
}
}
}

151
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,151 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/rss+xml font/truetype font/opentype
application/vnd.ms-fontobject image/svg+xml;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
# Upstream для MC Panel
upstream mc_panel {
server mc-panel:8000;
}
# HTTP сервер (редирект на HTTPS)
server {
listen 80;
server_name _;
# Для Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Редирект на HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS сервер
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL сертификаты
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
# SSL настройки
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# API endpoints
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://mc_panel;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Таймауты
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# WebSocket для консоли
location /ws/ {
proxy_pass http://mc_panel;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket таймауты
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
# Login endpoint с ограничением
location /api/auth/login {
limit_req zone=login_limit burst=3 nodelay;
proxy_pass http://mc_panel;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Статические файлы фронтенда
location / {
proxy_pass http://mc_panel;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
}

View File

View File

View File

@@ -0,0 +1,7 @@
{
"name": "sdfsfsf",
"displayName": "sdfsdf",
"startCommand": "java -Xmx2G -Xms1G -jar server.jar nogui",
"owner": "Leuteg",
"daemonId": "local"
}

View File

@@ -0,0 +1,6 @@
{
"name": "sfsf",
"displayName": "sdf",
"startCommand": "java -Xmx2G -Xms1G -jar server.jar nogui",
"owner": "Leuteg"
}

38
users.json Normal file
View File

@@ -0,0 +1,38 @@
{
"Root": {
"username": "Root",
"password": "$2b$12$PAaomoUWn3Ip5ov.S/uYPeTIRiDMq7DbA57ahyYQnw3QHT2zuYMlG",
"role": "owner",
"servers": [
"dfgdfgdfg"
],
"permissions": {
"manage_users": true,
"manage_roles": true,
"manage_servers": true,
"manage_tickets": true,
"manage_files": true,
"delete_users": true,
"view_all_resources": true
},
"resource_access": {
"servers": [],
"tickets": [],
"files": []
}
},
"Leuteg": {
"username": "Leuteg",
"password": "$2b$12$EAK2ougYahmHPhdaP/vm5O9RMPgnvtCYb.8Z9HpSqNrVxComaZ68C",
"role": "owner",
"servers": [
"sfsf"
],
"permissions": {
"servers": true,
"tickets": true,
"users": false,
"files": true
}
}
}

View File

@@ -1,78 +0,0 @@
# 🚀 Быстрый старт MC Panel
## Запуск панели
### 1⃣ Запустите бэкенд
Откройте первый терминал:
```bash
cd backend
python main_new.py
```
### 2⃣ Запустите фронтенд
Откройте второй терминал:
```bash
cd frontend
npm run dev
```
### 3⃣ Откройте в браузере
```
http://localhost:3000
```
### 4⃣ Войдите в систему
- **Логин**: `admin`
- **Пароль**: `admin`
## 🎨 Смена темы
1. Нажмите на иконку палитры (🎨) в правом верхнем углу
2. Выберите тему:
- 🌑 Тёмная
- ☀️ Светлая
- 💜 Фиолетовая
- 💙 Синяя
- 💚 Зелёная
## 📋 Основные функции
### Создание сервера
1. Нажмите кнопку "+" в левой панели
2. Заполните форму:
- Имя папки (латиница, цифры, _ и -)
- Отображаемое имя
- Команда запуска
3. Нажмите "Создать"
### Управление сервером
- **Запуск**: кнопка "Запустить" на карточке сервера
- **Остановка**: кнопка "Остановить" на карточке сервера
- **Консоль**: вкладка "Консоль" для просмотра логов
- **Файлы**: вкладка "Файлы" для управления файлами
- **Статистика**: вкладка "Статистика" для мониторинга ресурсов
- **Настройки**: вкладка "Настройки" для изменения параметров
### Управление пользователями (только для админов)
1. Нажмите кнопку "Пользователи" в header
2. Создайте новых пользователей
3. Выдайте доступ к серверам
### Выдача доступа к серверу
1. Выберите сервер
2. Перейдите в "Настройки"
3. В разделе "Управление доступом" выберите пользователя
4. Нажмите "Выдать доступ"
## 🌐 Доступ через сеть
Панель автоматически определяет ваш IP-адрес и работает через:
- Локальную сеть
- Radmin VPN
- Другие VPN-сети
Друзья могут подключиться по вашему IP-адресу на порту 3000.
## ✅ Готово!
Панель готова к использованию. Создавайте серверы, управляйте ими и наслаждайтесь современным интерфейсом! 🎉