feat: initial commit - ServerManager Pro v2.0.0

This commit is contained in:
2025-11-26 23:21:44 +03:00
commit af51c68d7f
39 changed files with 23191 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# Production builds
dist/
build/
# Node modules
node_modules/
# Environment
.env
.env.local
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+103
View File
@@ -0,0 +1,103 @@
# Инструкции по сборке
## 🏗️ Система сборки
### Основные скрипты:
- **`build.sh`** - основной интерфейс для разработки
- **`build.prod.sh`** - продакшен сборка для Linux и Windows
### Команды сборки:
```bash
# Разработка
./build.sh dev # Запуск в режиме разработки
# Сборка
./build.sh build # Сборка для всех платформ
./build.sh build-linux # Только Linux (.deb)
./build.sh build-windows # Только Windows (.exe)
# Утилиты
./build.sh clean # Очистка сборок
./build.sh install-linux # Установка на Linux
./build.sh status # Статус сборки
./build.sh version # Показать версию
```
## 🔄 Процесс сборки
### 1. Подготовка:
```bash
# Убедитесь что Node.js установлен
node --version # Должно быть 16+
# Установите зависимости
npm install
```
### 2. Разработка:
```bash
./build.sh dev
```
Запускает React dev server и Electron с горячей перезагрузкой.
### 3. Продакшен сборка:
```bash
./build.sh build
```
Создает:
- `dist/servermanager-pro_*.deb` (Linux)
- `dist/servermanager-pro_*.exe` (Windows)
## 📦 Дистрибуция
### Создание релиза:
1. Обновите версию:
```bash
npm version patch # 2.0.0 → 2.0.1
# или
npm version minor # 2.0.0 → 2.1.0
```
2. Соберите пакеты:
```bash
./build.sh build
```
3. Протестируйте пакеты
4. Создайте релиз на GitHub с файлами из `dist/`
## 🐛 Устранение неисправностей
### Проблемы со сборкой:
```bash
# Очистите и пересоберите
./build.sh clean
npm install
./build.sh build
```
### Проблемы с зависимостями:
```bash
# Переустановите зависимости
rm -rf node_modules
npm install
```
### Проблемы с Electron:
```bash
# Пересоберите Electron зависимости
npm rebuild
```
## 🔧 Кастомизация
### Изменение иконки:
1. Положите `icon.png` в `public/`
2. Пересоберите проект
### Изменение названия:
Отредактируйте `productName` в `package.json`
+86
View File
@@ -0,0 +1,86 @@
# ServerManager Pro - Руководство разработчика
## 🏗️ Структура проекта
wireguard-manager/
├── 📁 installers/ # Скрипты установки
├── 📁 public/ # Статические файлы Electron
├── 📁 scripts/ # Вспомогательные скрипты
├── 📁 src/ # Исходный код React
│ ├── 📁 components/ # Компоненты приложения
│ └── 📄 App.js # Главный компонент
├── 📄 build.sh # Основной скрипт сборки
├── 📄 build.prod.sh # Продакшен сборка
└── 📄 package.json # Конфигурация проекта
## 🎯 Назначение компонентов
### Основные компоненты:
- **Dashboard** (`Dashboard.js`) - Главная панель управления
- **ServerList** (`ServerList.js`) - Управление серверами
- **ServerMonitoring** (`ServerMonitoring.js`) - Мониторинг серверов
- **NetworkMonitor** (`NetworkMonitor.js`) - Мониторинг сети
- **PasswordManager** (`PasswordManager.js`) - Менеджер паролей
- **CommandTemplates** (`CommandTemplates.js`) - Шаблоны команд
- **Notes** (`Notes.js`) - Система заметок
- **Settings** (`Settings.js`) - Настройки приложения
### Вспомогательные компоненты:
- **BatchOperations** - Пакетные операции
- **NetworkTools** - Сетевые инструменты
- **ServerHealth** - Проверка здоровья серверов
## 🔧 Ключевые файлы
### Electron файлы:
- `public/electron.js` - Главный процесс Electron
- `public/preload.js` - Безопасный мост между процессами
- `public/index.html` - Базовая HTML структура
### Конфигурационные файлы:
- `package.json` - Зависимости и скрипты
- `tailwind.config.js` - Конфигурация Tailwind CSS
- `.env` - Переменные окружения
## 🚀 Быстрый старт при разработке
# Установка зависимостей
npm install
# Запуск в режиме разработки
./build.sh dev
# Сборка для продакшена
./build.sh build # Сборка Linux (.deb) + Windows (.exe)
# Только Linux сборка
./build.sh build-linux
# Очистка сборок
./build.sh clean
# !! ВАЖНО !! Настройка Wine для сборки Windows приложений
## Установка Wine на Ubuntu/Debian:
sudo apt update
sudo apt install -y wine
## 🎨 Архитектура
### Frontend (React):
- **Тёмная тема** по умолчанию
- **Tailwind CSS** для стилей
- **Компонентный подход**
- **Состояние через useState/useEffect**
### Backend (Electron):
- **Главный процесс** - управление окнами
- **Предзагрузка** - безопасный API мост
- **Файловая система** - хранение данных
### Хранение данных:
- **servers.json** - список серверов
- **passwords.json** - менеджер паролей
- **notes.json** - система заметок
- **command-templates.json** - шаблоны команд
+74
View File
@@ -0,0 +1,74 @@
# ServerManager Pro
🚀 **Профессиональный инструмент для управления серверами**
Поддержка Linux и Windows систем
![Version](https://img.shields.io/badge/version-2.0.0-blue)
![Platform](https://img.shields.io/badge/platform-linux%20%7C%20windows-green)
## ✨ Возможности
- 🖥️ **Управление серверами** - централизованное управление SSH серверами
- 📊 **Мониторинг** - отслеживание состояния серверов в реальном времени
- 🔐 **Менеджер паролей** - безопасное хранение учетных данных
-**Шаблоны команд** - быстрый доступ к часто используемым командам
- 📝 **Система заметок** - ведение документации по серверам
- 🌐 **Сетевые инструменты** - диагностика и мониторинг сети
- 🔄 **Пакетные операции** - массовое выполнение команд
## 🚀 Быстрый старт
### Установка на Linux:
```bash
# Скачайте .deb пакет из релизов
sudo dpkg -i servermanager-pro_*.deb
# Или используйте скрипт установки
chmod +x installers/install-linux.sh
sudo ./installers/install-linux.sh
```
### Установка на Windows:
1. Скачайте `.exe` файл из релизов
2. Запустите установщик от имени администратора
3. Следуйте инструкциям установщика
## 🛠️ Для разработчиков
См. [PROJECT_GUIDE.md](PROJECT_GUIDE.md) для полного руководства по разработке.
### Основные команды:
```bash
./build.sh dev # Режим разработки
./build.sh build # Сборка для производства
./build.sh clean # Очистка сборок
```
## 📁 Структура проекта
```
src/components/
├── ServerList.js # Управление серверами
├── ServerMonitoring.js # Мониторинг серверов
├── NetworkMonitor.js # Мониторинг сети
├── PasswordManager.js # Менеджер паролей
├── CommandTemplates.js # Шаблоны команд
├── Notes.js # Система заметок
├── Dashboard.js # Главная панель
└── Settings.js # Настройки приложения
```
## 🔧 Требования
- **Node.js** 16+
- **npm** 8+
- **Linux**: libgtk-3-0, libnss3, libxss1 (устанавливаются автоматически)
- **Windows**: 10/11, .NET Framework 4.5+
## 📄 Лицензия
MIT License - смотрите файл LICENSE для деталей.
## 🤝 Поддержка
Нашли баг или есть предложение? Создайте issue в репозитории проекта.
Executable
+198
View File
@@ -0,0 +1,198 @@
#!/bin/bash
# ServerManager Pro - Production Build for Linux & Windows
# Version: 2.0.0
set -e
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
APP_NAME="ServerManager Pro"
VERSION=$(node -p "require('./package.json').version")
echo -e "${BLUE}=== $APP_NAME - Production Build ===${NC}"
echo -e "${GREEN}Version: $VERSION${NC}"
echo "======================================"
print_status() { echo -e "${BLUE}[$(date +%H:%M:%S)]${NC} $1"; }
print_success() { echo -e "${GREEN}$1${NC}"; }
print_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
print_error() { echo -e "${RED}$1${NC}"; }
# Check environment
check_environment() {
print_status "Checking environment..."
if [ ! -f "package.json" ]; then
print_error "package.json not found. Run from project root."
exit 1
fi
if ! command -v node >/dev/null; then
print_error "Node.js not installed"
exit 1
fi
print_success "Environment check passed"
}
# Clean builds
clean_builds() {
print_status "Cleaning previous builds..."
rm -rf build dist
print_success "Clean completed"
}
# Install dependencies
install_deps() {
print_status "Installing dependencies..."
if [ ! -d "node_modules" ]; then
npm install
else
npm install
fi
print_success "Dependencies installed"
}
# Build React app
build_react() {
print_status "Building React application..."
npm run build
if [ ! -d "build" ]; then
print_error "React build failed"
exit 1
fi
print_success "React app built"
}
# Create icon if missing
create_icon() {
if [ ! -f "public/icon.png" ]; then
print_status "Creating application icon..."
if command -v convert >/dev/null; then
convert -size 512x512 gradient:blue-darkblue -pointsize 100 -fill white -gravity center -annotate +0+0 "SM" public/icon.png
print_success "Icon created"
else
print_warning "ImageMagick not available - using default icon"
fi
fi
}
# Build Linux package
build_linux() {
print_status "Building Linux package (.deb)..."
npx electron-builder --linux deb
if ls dist/*.deb >/dev/null 2>&1; then
DEB_FILE=$(ls dist/*.deb | head -1)
print_success "Linux package: $(basename $DEB_FILE) ($(du -h $DEB_FILE | cut -f1))"
return 0
else
print_error "Linux package build failed"
return 1
fi
}
# Build Windows package
build_windows() {
print_status "Building Windows package (.exe)..."
npx electron-builder --win
if ls dist/*.exe >/dev/null 2>&1; then
EXE_FILE=$(ls dist/*.exe | head -1)
print_success "Windows package: $(basename $EXE_FILE) ($(du -h $EXE_FILE | cut -f1))"
return 0
else
print_error "Windows package build failed"
return 1
fi
}
# Generate report
generate_report() {
echo ""
echo -e "${GREEN}=== BUILD COMPLETE ===${NC}"
echo "Version: $VERSION"
echo "Build date: $(date)"
echo ""
echo -e "${BLUE}Generated Packages:${NC}"
# Linux packages
if ls dist/*.deb >/dev/null 2>&1; then
echo -e " 🐧 Linux (.deb):"
ls dist/*.deb | while read file; do
echo " 📦 $(basename $file) ($(du -h "$file" | cut -f1))"
done
else
echo -e " 🐧 Linux: ❌ Not built"
fi
# Windows packages
if ls dist/*.exe >/dev/null 2>&1; then
echo -e " 🪟 Windows (.exe):"
ls dist/*.exe | while read file; do
echo " 📦 $(basename $file) ($(du -h "$file" | cut -f1))"
done
else
echo -e " 🪟 Windows: ❌ Not built"
fi
echo ""
echo -e "${GREEN}Installation Instructions:${NC}"
echo " Linux: sudo dpkg -i dist/*.deb"
echo " Windows: Run the .exe file as Administrator"
echo ""
echo -e "${YELLOW}Next Steps:${NC}"
echo " • Test the application on both platforms"
echo " • Use IT"
}
# Main build process
main() {
local platform=${1:-all}
case $platform in
"linux")
check_environment
clean_builds
install_deps
create_icon
build_react
build_linux
;;
"windows")
check_environment
clean_builds
install_deps
create_icon
build_react
build_windows
;;
"all"|*)
check_environment
clean_builds
install_deps
create_icon
build_react
build_linux
build_windows
;;
esac
generate_report
}
main "$@"
Executable
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
# ServerManager Pro - Simple Build Interface
# For daily development use
set -e
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
APP_NAME="ServerManager Pro"
VERSION=$(node -p "require('./package.json').version")
show_help() {
echo -e "${GREEN}$APP_NAME - Build Manager${NC}"
echo -e "Version: ${BLUE}$VERSION${NC}"
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " build Build for Linux & Windows (production)"
echo " build-linux Build only Linux package"
echo " build-windows Build only Windows package"
echo " dev Start development mode"
echo " clean Remove build files"
echo " install-linux Install Linux package"
echo " status Show build status"
echo " version Show version"
echo ""
echo "Examples:"
echo " $0 build # Production build"
echo " $0 dev # Development mode"
echo " $0 install-linux # Install on Linux"
}
case "${1:-help}" in
"build")
echo -e "${GREEN}Building production packages...${NC}"
./build-prod.sh
;;
"build-linux")
echo -e "${GREEN}Building Linux package...${NC}"
./build-prod.sh linux
;;
"build-windows")
echo -e "${GREEN}Building Windows package...${NC}"
./build-prod.sh windows
;;
"dev")
echo -e "${GREEN}Starting development mode...${NC}"
npm run electron-dev
;;
"clean")
echo -e "${YELLOW}Cleaning build files...${NC}"
rm -rf build dist
echo -e "${GREEN}Clean complete${NC}"
;;
"install-linux")
DEB_FILE=$(find dist -name "*.deb" | head -1)
if [ -n "$DEB_FILE" ]; then
echo -e "${GREEN}Installing $(basename $DEB_FILE)...${NC}"
sudo dpkg -i "$DEB_FILE" || sudo apt-get install -f
echo -e "${GREEN}Installation complete!${NC}"
else
echo -e "${YELLOW}No .deb package found. Run './build.sh build' first.${NC}"
fi
;;
"status")
echo -e "${GREEN}Build Status${NC}"
echo "Version: $VERSION"
echo ""
if [ -d "build" ]; then
echo -e "React build: ${GREEN}✅ Present${NC}"
else
echo -e "React build: ${YELLOW}❌ Missing${NC}"
fi
echo -e "Packages:"
ls dist/*.deb 2>/dev/null && echo -e " ${GREEN}🐧 Linux: ✅ Present${NC}" || echo -e " ${YELLOW}🐧 Linux: ❌ Missing${NC}"
ls dist/*.exe 2>/dev/null && echo -e " ${GREEN}🪟 Windows: ✅ Present${NC}" || echo -e " ${YELLOW}🪟 Windows: ❌ Missing${NC}"
;;
"version")
echo -e "Version: ${GREEN}$VERSION${NC}"
;;
"help"|"--help"|"-h")
show_help
;;
*)
echo -e "${YELLOW}Unknown command: $1${NC}"
show_help
;;
esac
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# ServerManager Pro - Linux Installer
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${GREEN}ServerManager Pro - Linux Installer${NC}"
echo "======================================"
# Detect architecture
ARCH=$(uname -m)
case $ARCH in
x86_64) DEB_ARCH="amd64" ;;
aarch64) DEB_ARCH="arm64" ;;
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
esac
# Find appropriate .deb file
DEB_FILE=$(find ../dist -name "*${DEB_ARCH}.deb" -o -name "*.deb" | head -1)
if [ -z "$DEB_FILE" ]; then
echo -e "${RED}No .deb package found${NC}"
echo "Please build the package first: ./build-all-platforms.sh"
exit 1
fi
echo -e "Installing: ${GREEN}$(basename $DEB_FILE)${NC}"
echo -e "Architecture: ${GREEN}$ARCH${NC}"
# Install dependencies
echo -e "${YELLOW}Installing dependencies...${NC}"
sudo apt update
sudo apt install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xdg-utils libatspi2.0-0 libuuid1 libappindicator3-1 libsecret-1-0
# Install the package
echo -e "${YELLOW}Installing package...${NC}"
sudo dpkg -i "$DEB_FILE" || {
echo -e "${YELLOW}Fixing dependencies...${NC}"
sudo apt -f install -y
}
echo -e "${GREEN}Installation completed successfully!${NC}"
echo ""
echo -e "You can now run: ${GREEN}servermanager-pro${NC}"
echo -e "Or find 'ServerManager Pro' in your application menu"
+18246
View File
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
{
"name": "servermanager-pro",
"version": "2.0.0",
"description": "Professional Server Management Tool for Linux & Windows",
"main": "public/electron.js",
"homepage": "./",
"private": true,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"electron": "electron . --no-sandbox --disable-setuid-sandbox",
"electron-dev": "concurrently \"npm start\" \"wait-on http://localhost:3000 && electron . --no-sandbox --disable-setuid-sandbox\"",
"dist": "npm run build && electron-builder",
"dist:linux": "npm run build && electron-builder --linux deb",
"dist:windows": "npm run build && electron-builder --win",
"dist:all": "npm run build && electron-builder -lw"
},
"dependencies": {
"concurrently": "^7.6.0",
"electron-is-dev": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"wait-on": "^7.2.0"
},
"devDependencies": {
"electron": "^22.0.0",
"electron-builder": "^23.6.0"
},
"build": {
"appId": "com.servermanager.pro",
"productName": "ServerManager Pro",
"directories": {
"output": "dist"
},
"files": [
"build/**/*",
"public/electron.js",
"public/preload.js",
"public/icon.png"
],
"linux": {
"target": [
{
"target": "deb",
"arch": [
"x64",
"arm64"
]
},
{
"target": "AppImage",
"arch": [
"x64",
"arm64"
]
}
],
"category": "Development",
"desktop": {
"Name": "ServerManager Pro",
"Comment": "Professional Server Management Tool",
"Categories": "Development;"
},
"icon": "public/icon.png"
},
"win": {
"target": "nsis",
"icon": "public/icon.png"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
},
"author": {
"name": "ServerManager Team",
"email": "support@servermanager.com"
},
"keywords": [
"server",
"management",
"ssh",
"network",
"monitoring",
"linux",
"windows"
],
"license": "MIT",
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
+555
View File
@@ -0,0 +1,555 @@
const { app, BrowserWindow, ipcMain, shell, dialog, clipboard } = require('electron');
const path = require('path');
const isDev = require('electron-is-dev');
const { exec } = require('child_process');
const fs = require('fs');
// КРИТИЧЕСКИ ВАЖНО ДЛЯ LINUX - исправление песочницы
app.commandLine.appendSwitch('--no-sandbox');
app.commandLine.appendSwitch('--disable-setuid-sandbox');
app.commandLine.appendSwitch('--disable-gpu');
// Дополнительные флаги для Linux
if (process.platform === 'linux') {
app.commandLine.appendSwitch('--no-zygote');
app.commandLine.appendSwitch('--disable-dev-shm-usage');
}
app.disableHardwareAcceleration();
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
webSecurity: false,
},
title: 'ServerManager Pro',
icon: path.join(__dirname, 'icon.png'),
show: false,
titleBarStyle: 'default'
});
const startUrl = isDev
? 'http://localhost:3000'
: `file://${path.join(__dirname, '../build/index.html')}`;
mainWindow.loadURL(startUrl);
mainWindow.once('ready-to-show', () => {
mainWindow.show();
mainWindow.focus();
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// Открываем DevTools только в режиме разработки
if (isDev) {
mainWindow.webContents.openDevTools();
}
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// ==================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ====================
function ensureUserDataDir() {
const userDataPath = app.getPath('userData');
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
return userDataPath;
}
ipcMain.handle('connect-ssh', async (event, server) => {
return new Promise((resolve) => {
try {
const { ip, port = '22', username = 'root', password, sshKey } = server;
// Очищаем IP от лишних символов
const cleanIp = ip.trim().replace(/[^0-9.:]/g, '');
let sshCommand = `ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${username}@${cleanIp} -p ${port}`;
if (sshKey) {
const tempKeyPath = `/tmp/ssh_key_${Date.now()}`;
fs.writeFileSync(tempKeyPath, sshKey);
fs.chmodSync(tempKeyPath, 0o600);
sshCommand = `ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${tempKeyPath} ${username}@${cleanIp} -p ${port}`;
}
const terminals = [
{ cmd: 'gnome-terminal --', test: 'gnome-terminal' },
{ cmd: 'konsole -e', test: 'konsole' },
{ cmd: 'xfce4-terminal -x', test: 'xfce4-terminal' },
{ cmd: 'mate-terminal -x', test: 'mate-terminal' },
{ cmd: 'xterm -e', test: 'xterm' }
];
const tryTerminal = (index) => {
if (index >= terminals.length) {
// Если терминал не найден, показываем команду для копирования
const message = `Терминал не найден\n\nКоманда SSH:\n${sshCommand}\n\nПароль: ${password || 'не установлен'}\n\nСкопируйте команду и выполните вручную в терминале.`;
dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'SSH Подключение',
message: 'Терминал не найден',
detail: message
}).then(() => {
resolve({ success: true });
});
return;
}
const terminal = terminals[index];
// Проверяем, доступен ли терминал
exec(`which ${terminal.test}`, (error) => {
if (error) {
tryTerminal(index + 1);
} else {
console.log('Using terminal:', terminal.cmd);
const fullCommand = `${terminal.cmd} bash -c "${sshCommand}; echo 'Нажмите Enter для закрытия...'; read"`;
exec(fullCommand, (error, stdout, stderr) => {
if (error) {
console.error('SSH terminal error:', error);
// Показываем ошибку пользователю
dialog.showMessageBox(mainWindow, {
type: 'error',
title: 'Ошибка SSH',
message: `Не удалось подключиться: ${error.message}`,
detail: stderr || 'Проверьте параметры подключения'
});
}
});
resolve({ success: true });
}
});
};
tryTerminal(0);
} catch (error) {
console.error('SSH connection error:', error);
resolve({ success: false, error: error.message });
}
});
});
// ==================== ВЕБ-ИНТЕРФЕЙС ====================
ipcMain.handle('open-web-interface', async (event, server) => {
try {
const { ip, webPort = '51821' } = server;
// Очищаем IP
const cleanIp = ip.trim().replace(/[^0-9.:]/g, '');
const url = `http://${cleanIp}:${webPort}`;
console.log('Opening web interface:', url);
// Проверяем доступность URL перед открытием
const { exec } = require('child_process');
// Проверяем доступность с помощью curl
exec(`curl -s --head --connect-timeout 3 ${url}`, (error) => {
if (error) {
// Если недоступно, показываем предупреждение
dialog.showMessageBox(mainWindow, {
type: 'warning',
title: 'Веб-интерфейс может быть недоступен',
message: 'Сервер может не отвечать',
detail: `URL: ${url}\n\nПроверьте:\n• Доступность сервера\n• Правильность порта\n• Запущен ли веб-сервис`
});
}
});
await shell.openExternal(url);
return { success: true };
} catch (error) {
console.error('Error opening web interface:', error);
const { ip, webPort = '51821' } = server;
const cleanIp = ip.trim().replace(/[^0-9.:]/g, '');
const url = `http://${cleanIp}:${webPort}`;
dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'Веб-интерфейс',
message: 'Откройте в браузере',
detail: `URL: ${url}\n\nЕсли не открывается автоматически, скопируйте ссылку в браузер.`
});
return { success: false, error: error.message };
}
});
// ==================== УПРАВЛЕНИЕ ДАННЫМИ ====================
// Серверы
ipcMain.handle('load-servers', async () => {
try {
const userDataPath = ensureUserDataDir();
const serversPath = path.join(userDataPath, 'servers.json');
if (fs.existsSync(serversPath)) {
const data = fs.readFileSync(serversPath, 'utf8');
return { success: true, servers: JSON.parse(data) };
}
return { success: true, servers: [] };
} catch (error) {
return { success: false, error: error.message, servers: [] };
}
});
ipcMain.handle('save-servers', async (event, servers) => {
try {
const userDataPath = ensureUserDataDir();
const serversPath = path.join(userDataPath, 'servers.json');
fs.writeFileSync(serversPath, JSON.stringify(servers, null, 2));
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// Пароли
ipcMain.handle('load-passwords', async () => {
try {
const userDataPath = ensureUserDataDir();
const passwordsPath = path.join(userDataPath, 'passwords.json');
if (fs.existsSync(passwordsPath)) {
const data = fs.readFileSync(passwordsPath, 'utf8');
return { success: true, passwords: JSON.parse(data) };
}
return { success: true, passwords: [] };
} catch (error) {
return { success: false, error: error.message, passwords: [] };
}
});
ipcMain.handle('save-passwords', async (event, passwords) => {
try {
const userDataPath = ensureUserDataDir();
const passwordsPath = path.join(userDataPath, 'passwords.json');
fs.writeFileSync(passwordsPath, JSON.stringify(passwords, null, 2));
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// Шаблоны команд
ipcMain.handle('load-command-templates', async () => {
try {
const userDataPath = ensureUserDataDir();
const templatesPath = path.join(userDataPath, 'command-templates.json');
if (fs.existsSync(templatesPath)) {
const data = fs.readFileSync(templatesPath, 'utf8');
return { success: true, templates: JSON.parse(data) };
}
return { success: true, templates: [] };
} catch (error) {
return { success: false, error: error.message, templates: [] };
}
});
ipcMain.handle('save-command-templates', async (event, templates) => {
try {
const userDataPath = ensureUserDataDir();
const templatesPath = path.join(userDataPath, 'command-templates.json');
fs.writeFileSync(templatesPath, JSON.stringify(templates, null, 2));
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// Базовые утилиты
ipcMain.handle('copy-to-clipboard', async (event, text) => {
try {
clipboard.writeText(text);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// Экспорт/импорт данных
ipcMain.handle('export-data', async (event, data) => {
const result = await dialog.showSaveDialog(mainWindow, {
title: 'Экспорт данных',
defaultPath: `servermanager-backup-${new Date().toISOString().split('T')[0]}.json`,
filters: [{ name: 'JSON', extensions: ['json'] }]
});
if (result.canceled) return { success: false, error: 'Отменено' };
try {
fs.writeFileSync(result.filePath, JSON.stringify(data, null, 2));
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('import-data', async (event) => {
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Импорт данных',
filters: [{ name: 'JSON', extensions: ['json'] }],
properties: ['openFile']
});
if (result.canceled) return { success: false, error: 'Отменено' };
try {
const data = fs.readFileSync(result.filePaths[0], 'utf8');
return { success: true, data: JSON.parse(data) };
} catch (error) {
return { success: false, error: error.message };
}
});
// Добавьте после других ipcMain.handle
// Уведомления
ipcMain.handle('show-notification', async (event, title, body) => {
try {
// Используем встроенные уведомления Electron
const notification = {
title: title,
body: body,
silent: true
};
// Показываем уведомление в главном процессе
const notif = new Notification(notification);
notif.show();
return { success: true };
} catch (error) {
console.error('Notification error:', error);
// Если уведомления не работают, просто логируем
console.log(`Уведомление: ${title} - ${body}`);
return { success: true };
}
});
// Логирование действий
ipcMain.handle('log-action', async (event, action) => {
try {
const userDataPath = ensureUserDataDir();
const logPath = path.join(userDataPath, 'actions.log');
const timestamp = new Date().toISOString();
const logEntry = `${timestamp}: ${action}\n`;
fs.appendFileSync(logPath, logEntry);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// Ping функция для проверки серверов
ipcMain.handle('ping-server', async (event, server) => {
try {
const { ip } = server;
const cleanIp = ip.trim().replace(/[^0-9.:]/g, '');
// Используем системный ping
const { exec } = require('child_process');
return new Promise((resolve) => {
exec(`ping -c 2 -W 1 ${cleanIp}`, (error, stdout, stderr) => {
if (error) {
resolve({
success: true,
online: false,
responseTime: null,
packetLoss: 100
});
} else {
// Парсим вывод ping для получения времени ответа
const timeMatch = stdout.match(/time=(\d+\.?\d*) ms/);
const responseTime = timeMatch ? parseFloat(timeMatch[1]) : null;
resolve({
success: true,
online: true,
responseTime: responseTime,
packetLoss: 0
});
}
});
});
} catch (error) {
return {
success: false,
online: false,
error: error.message
};
}
});
// ==================== СЕТЕВЫЕ ДАННЫЕ ====================
// Получение сетевых адаптеров
ipcMain.handle('get-network-adapters', async () => {
try {
const { networkInterfaces } = require('os');
const interfaces = networkInterfaces();
const adapters = [];
for (const [name, details] of Object.entries(interfaces)) {
for (const detail of details) {
if (detail.family === 'IPv4' && !detail.internal) {
adapters.push({
name: name,
description: `${name} Network Interface`,
status: 'Up',
ipAddress: detail.address,
macAddress: detail.mac,
type: getInterfaceType(name)
});
}
}
}
return { success: true, adapters };
} catch (error) {
console.error('Error getting network adapters:', error);
return { success: false, error: error.message, adapters: [] };
}
});
// Получение VPN подключений
ipcMain.handle('get-vpn-connections', async () => {
try {
const connections = [];
// Проверяем WireGuard
try {
const { execSync } = require('child_process');
const wireguardResult = execSync('ip link show type wireguard 2>/dev/null || echo ""', { encoding: 'utf8' });
if (wireguardResult && wireguardResult.includes('wireguard')) {
connections.push({
name: 'WireGuard VPN',
interface: 'wg0',
status: 'Connected',
description: 'WireGuard VPN Tunnel'
});
}
} catch (e) {
// WireGuard не установлен или нет интерфейсов
}
// Проверяем OpenVPN
try {
const { execSync } = require('child_process');
const openvpnResult = execSync('ps aux | grep openvpn | grep -v grep || echo ""', { encoding: 'utf8' });
if (openvpnResult && openvpnResult.includes('openvpn')) {
connections.push({
name: 'OpenVPN',
interface: 'tun0',
status: 'Connected',
description: 'OpenVPN Connection'
});
}
} catch (e) {
// OpenVPN не запущен
}
return { success: true, connections };
} catch (error) {
console.error('Error getting VPN connections:', error);
return { success: false, error: error.message, connections: [] };
}
});
// Вспомогательная функция для определения типа интерфейса
function getInterfaceType(name) {
if (name.includes('wl') || name.includes('wlan') || name.includes('wifi')) {
return 'Wi-Fi';
} else if (name.includes('eth') || name.includes('enp') || name.includes('ens')) {
return 'Ethernet';
} else if (name.includes('tun') || name.includes('tap')) {
return 'VPN';
} else if (name.includes('lo')) {
return 'Loopback';
} else {
return 'Ethernet';
}
}
// Заметки
ipcMain.handle('load-notes', async () => {
try {
const userDataPath = ensureUserDataDir();
const notesPath = path.join(userDataPath, 'notes.json');
if (fs.existsSync(notesPath)) {
const data = fs.readFileSync(notesPath, 'utf8');
return { success: true, notes: JSON.parse(data) };
}
return { success: true, notes: [] };
} catch (error) {
return { success: false, error: error.message, notes: [] };
}
});
ipcMain.handle('save-notes', async (event, notes) => {
try {
const userDataPath = ensureUserDataDir();
const notesPath = path.join(userDataPath, 'notes.json');
fs.writeFileSync(notesPath, JSON.stringify(notes, null, 2));
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('load-note-folders', async () => {
try {
const userDataPath = ensureUserDataDir();
const foldersPath = path.join(userDataPath, 'note-folders.json');
if (fs.existsSync(foldersPath)) {
const data = fs.readFileSync(foldersPath, 'utf8');
return { success: true, folders: JSON.parse(data) };
}
return { success: true, folders: [] };
} catch (error) {
return { success: false, error: error.message, folders: [] };
}
});
ipcMain.handle('save-note-folders', async (event, folders) => {
try {
const userDataPath = ensureUserDataDir();
const foldersPath = path.join(userDataPath, 'note-folders.json');
fs.writeFileSync(foldersPath, JSON.stringify(folders, null, 2));
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0ea5e9;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0369a1;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="512" height="512" rx="80" fill="url(#grad)"/>
<text x="256" y="280" font-family="Arial, sans-serif" font-size="180" font-weight="bold"
text-anchor="middle" fill="white">SM</text>
<text x="256" y="380" font-family="Arial, sans-serif" font-size="40"
text-anchor="middle" fill="white">Pro</text>
</svg>

After

Width:  |  Height:  |  Size: 673 B

+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="WireGuard Manager - управление VPN серверами"
/>
<title>WireGuard Manager</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+40
View File
@@ -0,0 +1,40 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
// Основные функции
loadServers: () => ipcRenderer.invoke('load-servers'),
saveServers: (servers) => ipcRenderer.invoke('save-servers', servers),
connectSSH: (server) => ipcRenderer.invoke('connect-ssh', server),
openWebInterface: (server) => ipcRenderer.invoke('open-web-interface', server),
// Пароли
loadPasswords: () => ipcRenderer.invoke('load-passwords'),
savePasswords: (passwords) => ipcRenderer.invoke('save-passwords', passwords),
// Шаблоны команд
loadCommandTemplates: () => ipcRenderer.invoke('load-command-templates'),
saveCommandTemplates: (templates) => ipcRenderer.invoke('save-command-templates', templates),
// Утилиты
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
exportData: (data) => ipcRenderer.invoke('export-data', data),
importData: () => ipcRenderer.invoke('import-data'),
// Новые функции
showNotification: (title, body) => ipcRenderer.invoke('show-notification', title, body),
logAction: (action) => ipcRenderer.invoke('log-action', action),
// Мониторинг сети
getNetworkAdapters: () => ipcRenderer.invoke('get-network-adapters'),
getVpnConnections: () => ipcRenderer.invoke('get-vpn-connections'),
// Ping функция
pingServer: (server) => ipcRenderer.invoke('ping-server', server),
// Заметки
loadNotes: () => ipcRenderer.invoke('load-notes'),
saveNotes: (notes) => ipcRenderer.invoke('save-notes', notes),
loadNoteFolders: () => ipcRenderer.invoke('load-note-folders'),
saveNoteFolders: (folders) => ipcRenderer.invoke('save-note-folders', folders),
});
+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Version=2.0.0
Name=ServerManager Pro
Comment=Professional Server Management Tool
Exec=/opt/ServerManager Pro/servermanager-pro
Icon=/opt/ServerManager Pro/icon.png
Terminal=false
Type=Application
Categories=Development;Network;
StartupWMClass=ServerManager Pro
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# Create a simple icon if none exists
mkdir -p public
cd public
# Create a simple SVG icon if icon.png doesn't exist
if [ ! -f "icon.png" ]; then
echo "Creating default icon..."
# Create SVG icon
cat > icon.svg << 'EOF'
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0ea5e9;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0369a1;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="512" height="512" rx="80" fill="url(#grad)"/>
<text x="256" y="280" font-family="Arial, sans-serif" font-size="180" font-weight="bold"
text-anchor="middle" fill="white">SM</text>
<text x="256" y="380" font-family="Arial, sans-serif" font-size="40"
text-anchor="middle" fill="white">Pro</text>
</svg>
EOF
# Convert SVG to PNG (requires imagemagick)
if command -v convert &> /dev/null; then
convert -background none -resize 512x512 icon.svg icon.png
echo "Icon created: public/icon.png"
else
echo "Install imagemagick to convert SVG to PNG: sudo apt install imagemagick"
echo "Using SVG icon for now..."
fi
fi
cd ..
+38
View File
@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+184
View File
@@ -0,0 +1,184 @@
import React, { useState, useEffect } from 'react';
import ServerList from './components/ServerList';
import NetworkMonitor from './components/NetworkMonitor';
import PasswordManager from './components/PasswordManager';
import ServerMonitoring from './components/ServerMonitoring';
import CommandTemplates from './components/CommandTemplates';
import Settings from './components/Settings';
import Dashboard from './components/Dashboard';
import Notes from './components/Notes';
const App = () => {
const [activeTab, setActiveTab] = useState('dashboard');
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
// Всегда темная тема
useEffect(() => {
document.documentElement.classList.add('dark');
}, []);
// Закрываем сайдбар при изменении размера окна на десктоп
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 768) {
setIsSidebarOpen(true);
} else {
setIsSidebarOpen(false);
}
};
window.addEventListener('resize', handleResize);
handleResize(); // Вызываем сразу при загрузке
return () => window.removeEventListener('resize', handleResize);
}, []);
const renderContent = () => {
switch (activeTab) {
case 'servers': return <ServerList />;
case 'monitoring': return <ServerMonitoring />;
case 'network': return <NetworkMonitor />;
case 'templates': return <CommandTemplates />;
case 'passwords': return <PasswordManager />;
case 'dashboard': return <Dashboard />;
case 'settings': return <Settings />;
case 'notes': return <Notes />;
default: return <Dashboard />;
}
};
const getTabStyle = (tabName) => {
return `w-full text-left p-3 rounded-lg transition-all flex items-center space-x-3 text-sm ${
activeTab === tabName
? 'bg-primary-600 text-white shadow-lg'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`;
};
const handleTabClick = (tabName) => {
setActiveTab(tabName);
if (window.innerWidth < 768) {
setIsSidebarOpen(false);
}
};
return (
<div className="flex h-screen bg-gray-900 dark:bg-gray-900">
{/* Мобильное меню */}
<div className="md:hidden fixed top-4 left-4 z-50">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="p-2 bg-gray-800 rounded-lg text-white shadow-lg"
>
{isSidebarOpen ? '✕' : '☰'}
</button>
</div>
{/* Боковая панель */}
<div className={`
fixed md:relative inset-y-0 left-0 z-40
w-64 bg-gray-800 text-white flex flex-col shadow-2xl
transform transition-transform duration-300 ease-in-out
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
`}>
<div className="p-6 border-b border-gray-700">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-primary-500 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">SM</span>
</div>
<div>
<h1 className="text-xl font-bold">ServerManager</h1>
<p className="text-gray-400 text-sm">v2.0</p>
</div>
</div>
</div>
<nav className="flex-1 p-4">
<ul className="space-y-1">
<li>
<button onClick={() => handleTabClick('dashboard')} className={getTabStyle('dashboard')}>
<span className="text-lg">📊</span>
<span>Дашборд</span>
</button>
</li>
<li>
<button onClick={() => handleTabClick('servers')} className={getTabStyle('servers')}>
<span className="text-lg">🖥</span>
<span>Серверы</span>
</button>
</li>
<li>
<button onClick={() => handleTabClick('monitoring')} className={getTabStyle('monitoring')}>
<span className="text-lg">📊</span>
<span>Мониторинг</span>
</button>
</li>
<li>
<button onClick={() => handleTabClick('network')} className={getTabStyle('network')}>
<span className="text-lg">🌐</span>
<span>Сеть</span>
</button>
</li>
<li>
<button onClick={() => handleTabClick('templates')} className={getTabStyle('templates')}>
<span className="text-lg"></span>
<span>Команды</span>
</button>
</li>
<li>
<button onClick={() => handleTabClick('passwords')} className={getTabStyle('passwords')}>
<span className="text-lg">🔐</span>
<span>Пароли</span>
</button>
</li>
<li>
<button onClick={() => handleTabClick('notes')} className={getTabStyle('notes')}>
<span className="text-lg">📝</span>
<span>Заметки</span>
</button>
</li>
<li>
<button onClick={() => handleTabClick('settings')} className={getTabStyle('settings')}>
<span className="text-lg"></span>
<span>Настройки</span>
</button>
</li>
</ul>
</nav>
<div className="p-4 border-t border-gray-700">
<div className="text-xs text-gray-400 text-center">
ServerManager © 2024
</div>
</div>
</div>
{/* Overlay для мобильных */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-30 md:hidden"
onClick={() => setIsSidebarOpen(false)}
></div>
)}
{/* Основной контент */}
<div className="flex-1 overflow-auto md:ml-0">
<div className="md:hidden p-4 bg-gray-800 border-b border-gray-700">
<h1 className="text-lg font-bold text-white">
{activeTab === 'dashboard' && 'Дашборд'}
{activeTab === 'servers' && 'Серверы'}
{activeTab === 'monitoring' && 'Мониторинг'}
{activeTab === 'network' && 'Сеть'}
{activeTab === 'templates' && 'Шаблоны команд'}
{activeTab === 'passwords' && 'Менеджер паролей'}
{activeTab === 'notes' && 'Заметки'}
{activeTab === 'settings' && 'Настройки'}
</h1>
</div>
{renderContent()}
</div>
</div>
);
};
export default App;
+8
View File
@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+247
View File
@@ -0,0 +1,247 @@
import React, { useState, useEffect } from 'react';
const BatchOperations = () => {
const [servers, setServers] = useState([]);
const [selectedServers, setSelectedServers] = useState([]);
const [command, setCommand] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [commandHistory, setCommandHistory] = useState([]);
useEffect(() => {
loadServers();
loadCommandHistory();
}, []);
const loadServers = async () => {
if (window.electronAPI) {
const result = await window.electronAPI.loadServers();
if (result.success) {
setServers(result.servers);
// По умолчанию выбираем все серверы
setSelectedServers(result.servers.map(s => s.id));
}
}
};
const loadCommandHistory = async () => {
// Загружаем историю команд из localStorage
const history = localStorage.getItem('batchCommandHistory');
if (history) {
setCommandHistory(JSON.parse(history));
}
};
const saveCommandToHistory = (cmd) => {
const newHistory = [cmd, ...commandHistory.filter(c => c !== cmd)].slice(0, 10);
setCommandHistory(newHistory);
localStorage.setItem('batchCommandHistory', JSON.stringify(newHistory));
};
const executeBatchCommand = async () => {
if (!command.trim()) {
alert('Введите команду для выполнения');
return;
}
if (selectedServers.length === 0) {
alert('Выберите хотя бы один сервер');
return;
}
setLoading(true);
setResults([]);
const selectedServerObjects = servers.filter(s => selectedServers.includes(s.id));
try {
const result = await window.electronAPI.executeBatchCommand(selectedServerObjects, command);
if (result.success) {
setResults(result.results);
saveCommandToHistory(command);
} else {
setResults([{ server: 'System', success: false, error: 'Failed to execute batch command' }]);
}
} catch (error) {
setResults([{ server: 'System', success: false, error: error.message }]);
} finally {
setLoading(false);
}
};
const toggleServerSelection = (serverId) => {
setSelectedServers(prev =>
prev.includes(serverId)
? prev.filter(id => id !== serverId)
: [...prev, serverId]
);
};
const selectAllServers = () => {
setSelectedServers(servers.map(s => s.id));
};
const deselectAllServers = () => {
setSelectedServers([]);
};
const getSuccessCount = () => results.filter(r => r.success).length;
const getTotalCount = () => results.length;
return (
<div className="p-4 md:p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Пакетные операции</h1>
<p className="text-gray-400">Массовое выполнение команд на нескольких серверах</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Левая колонка - выбор серверов */}
<div className="lg:col-span-1">
<div className="bg-gray-800 rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-white">Серверы</h3>
<div className="flex gap-2">
<button
onClick={selectAllServers}
className="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
>
Все
</button>
<button
onClick={deselectAllServers}
className="px-3 py-1 bg-gray-600 text-white rounded text-sm hover:bg-gray-700"
>
Ничего
</button>
</div>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{servers.map(server => (
<label key={server.id} className="flex items-center gap-3 p-2 hover:bg-gray-700 rounded cursor-pointer">
<input
type="checkbox"
checked={selectedServers.includes(server.id)}
onChange={() => toggleServerSelection(server.id)}
className="rounded border-gray-600 bg-gray-700"
/>
<div>
<div className="text-white font-medium">{server.name}</div>
<div className="text-gray-400 text-sm">{server.ip}</div>
</div>
</label>
))}
</div>
<div className="mt-4 p-3 bg-gray-700 rounded text-sm text-gray-300">
Выбрано: {selectedServers.length} из {servers.length} серверов
</div>
</div>
{/* История команд */}
{commandHistory.length > 0 && (
<div className="bg-gray-800 rounded-lg p-4 mt-4">
<h3 className="font-semibold text-white mb-3">История команд</h3>
<div className="space-y-2">
{commandHistory.map((cmd, index) => (
<button
key={index}
onClick={() => setCommand(cmd)}
className="w-full text-left p-2 text-sm bg-gray-700 rounded hover:bg-gray-600 text-gray-300 font-mono truncate"
title={cmd}
>
{cmd}
</button>
))}
</div>
</div>
)}
</div>
{/* Правая колонка - команда и результаты */}
<div className="lg:col-span-2">
<div className="bg-gray-800 rounded-lg p-6">
<div className="mb-6">
<h3 className="text-lg font-semibold text-white mb-3">Команда для выполнения</h3>
<textarea
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="Введите команду для выполнения на выбранных серверах..."
rows="4"
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-400 focus:border-blue-500 focus:outline-none font-mono text-sm"
/>
<div className="flex justify-between items-center mt-3">
<div className="text-gray-400 text-sm">
Примеры: df -h, uptime, systemctl status nginx
</div>
<button
onClick={executeBatchCommand}
disabled={loading || selectedServers.length === 0}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center gap-2"
>
{loading ? '🔄' : '⚡'}
{loading ? 'Выполнение...' : `Выполнить (${selectedServers.length})`}
</button>
</div>
</div>
{/* Results */}
{results.length > 0 && (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-white">Результаты</h3>
<div className={`px-3 py-1 rounded text-sm font-medium ${
getSuccessCount() === getTotalCount()
? 'bg-green-600 text-white'
: getSuccessCount() > 0
? 'bg-yellow-600 text-white'
: 'bg-red-600 text-white'
}`}>
{getSuccessCount()}/{getTotalCount()} успешно
</div>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto">
{results.map((result, index) => (
<div
key={index}
className={`p-4 rounded-lg border ${
result.success
? 'bg-green-900 border-green-600'
: 'bg-red-900 border-red-600'
}`}
>
<div className="flex justify-between items-start mb-2">
<span className="font-semibold text-white">{result.server}</span>
<span className={`px-2 py-1 rounded text-xs ${
result.success ? 'bg-green-600 text-white' : 'bg-red-600 text-white'
}`}>
{result.success ? 'УСПЕХ' : 'ОШИБКА'}
</span>
</div>
{result.success ? (
<pre className="text-green-200 text-sm font-mono whitespace-pre-wrap">
{result.output}
</pre>
) : (
<div className="text-red-200 text-sm">
{result.error || result.output}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default BatchOperations;
+369
View File
@@ -0,0 +1,369 @@
import React, { useState, useEffect } from 'react';
const CommandTemplates = () => {
const [templates, setTemplates] = useState([]);
const [servers, setServers] = useState([]);
const [showAddForm, setShowAddForm] = useState(false);
const [selectedServer, setSelectedServer] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState('');
const [commandOutput, setCommandOutput] = useState('');
const [newTemplate, setNewTemplate] = useState({
name: '',
command: '',
description: '',
category: 'system'
});
useEffect(() => {
loadTemplates();
loadServers();
}, []);
const loadTemplates = async () => {
if (window.electronAPI) {
const result = await window.electronAPI.loadCommandTemplates();
if (result.success) {
setTemplates(result.templates || []);
}
}
};
const loadServers = async () => {
if (window.electronAPI) {
const result = await window.electronAPI.loadServers();
if (result.success) {
setServers(result.servers || []);
}
}
};
const saveTemplates = async (templatesList) => {
if (window.electronAPI) {
await window.electronAPI.saveCommandTemplates(templatesList);
}
};
const addTemplate = async () => {
if (!newTemplate.name || !newTemplate.command) {
alert('Заполните название и команду');
return;
}
const template = {
...newTemplate,
id: Date.now().toString(),
createdAt: new Date().toISOString()
};
const updatedTemplates = [...templates, template];
setTemplates(updatedTemplates);
await saveTemplates(updatedTemplates);
setShowAddForm(false);
setNewTemplate({
name: '',
command: '',
description: '',
category: 'system'
});
};
const deleteTemplate = async (templateId) => {
if (confirm('Удалить шаблон?')) {
const updatedTemplates = templates.filter(t => t.id !== templateId);
setTemplates(updatedTemplates);
await saveTemplates(updatedTemplates);
}
};
const executeCommand = async () => {
if (!selectedServer || !selectedTemplate) {
alert('Выберите сервер и шаблон команды');
return;
}
const server = servers.find(s => s.id === selectedServer);
const template = templates.find(t => t.id === selectedTemplate);
if (!server || !template) return;
setCommandOutput('Выполнение команды...');
// Имитация выполнения команды
setTimeout(() => {
setCommandOutput(`Команда выполнена на сервере ${server.name}\n\nКоманда: ${template.command}\n\nРезультат: Команда успешно выполнена\nВремя выполнения: 0.5с`);
}, 2000);
};
const scrollToExecuteSection = () => {
setTimeout(() => {
const element = document.getElementById('execute-section');
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
};
const getCategoryColor = (category) => {
const colors = {
system: 'bg-blue-500 text-white',
network: 'bg-green-500 text-white',
security: 'bg-red-500 text-white',
monitoring: 'bg-purple-500 text-white',
other: 'bg-gray-500 text-white'
};
return colors[category] || colors.other;
};
const predefinedTemplates = [
{
name: 'Проверка диска',
command: 'df -h',
description: 'Показать использование дискового пространства',
category: 'system'
},
{
name: 'Загрузка системы',
command: 'uptime',
description: 'Показать время работы и нагрузку системы',
category: 'system'
},
{
name: 'Сетевые соединения',
command: 'netstat -tulpn',
description: 'Показать активные сетевые соединения',
category: 'network'
},
{
name: 'Процессы',
command: 'top -bn1 | head -20',
description: 'Показать топ процессов по использованию ресурсов',
category: 'monitoring'
}
];
const addPredefinedTemplate = (template) => {
const newTemplate = {
...template,
id: Date.now().toString(),
createdAt: new Date().toISOString()
};
const updatedTemplates = [...templates, newTemplate];
setTemplates(updatedTemplates);
saveTemplates(updatedTemplates);
};
return (
<div className="p-4 md:p-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 md:mb-6 gap-4">
<div>
<h1 className="text-xl md:text-2xl font-bold text-white">Шаблоны команд</h1>
<p className="text-gray-400 text-sm md:text-base">Быстрое выполнение часто используемых команд</p>
</div>
<button
onClick={() => setShowAddForm(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center space-x-2 w-full md:w-auto justify-center"
>
<span>+</span>
<span>Добавить шаблон</span>
</button>
</div>
{/* Быстрое выполнение */}
<div id="execute-section" className="bg-gray-800 rounded-lg shadow-lg p-4 md:p-6 mb-4 md:mb-6 border border-gray-700">
<h3 className="text-lg font-semibold mb-4 text-white">Быстрое выполнение</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Сервер</label>
<select
value={selectedServer}
onChange={(e) => setSelectedServer(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="">Выберите сервер</option>
{servers.map(server => (
<option key={server.id} value={server.id}>{server.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Шаблон команды</label>
<select
value={selectedTemplate}
onChange={(e) => setSelectedTemplate(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="">Выберите шаблон</option>
{templates.map(template => (
<option key={template.id} value={template.id}>{template.name}</option>
))}
</select>
</div>
<div className="flex items-end">
<button
onClick={executeCommand}
disabled={!selectedServer || !selectedTemplate}
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Выполнить
</button>
</div>
</div>
{commandOutput && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Результат:</label>
<pre className="bg-gray-900 p-4 rounded-lg text-sm font-mono whitespace-pre-wrap border border-gray-700 text-white max-h-60 overflow-y-auto">
{commandOutput}
</pre>
</div>
)}
</div>
{/* Предопределенные шаблоны */}
<div className="bg-gray-800 rounded-lg shadow-lg p-4 md:p-6 mb-4 md:mb-6 border border-gray-700">
<h3 className="text-lg font-semibold mb-4 text-white">Готовые шаблоны</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{predefinedTemplates.map((template, index) => (
<div key={index} className="border border-gray-700 rounded-lg p-4 bg-gray-750">
<div className="flex justify-between items-start mb-2">
<h4 className="font-semibold text-white text-base md:text-lg">{template.name}</h4>
<span className={`px-2 py-1 rounded text-xs ${getCategoryColor(template.category)}`}>
{template.category}
</span>
</div>
<p className="text-sm text-gray-400 mb-3">{template.description}</p>
<code className="block bg-gray-900 p-2 rounded text-sm font-mono mb-3 text-white">
{template.command}
</code>
<button
onClick={() => addPredefinedTemplate(template)}
className="w-full px-3 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm"
>
Добавить в мои шаблоны
</button>
</div>
))}
</div>
</div>
{/* Форма добавления шаблона */}
{showAddForm && (
<div className="bg-gray-800 rounded-lg shadow-lg p-4 md:p-6 mb-4 md:mb-6 border border-gray-700">
<h3 className="text-lg font-semibold mb-4 text-white">Добавить шаблон команды</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Название</label>
<input
type="text"
value={newTemplate.name}
onChange={(e) => setNewTemplate({...newTemplate, name: e.target.value})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Моя команда"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Команда</label>
<textarea
value={newTemplate.command}
onChange={(e) => setNewTemplate({...newTemplate, command: e.target.value})}
rows="3"
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono"
placeholder="Введите команду для выполнения на сервере"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Описание</label>
<textarea
value={newTemplate.description}
onChange={(e) => setNewTemplate({...newTemplate, description: e.target.value})}
rows="2"
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Описание команды"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Категория</label>
<select
value={newTemplate.category}
onChange={(e) => setNewTemplate({...newTemplate, category: e.target.value})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="system">Система</option>
<option value="network">Сеть</option>
<option value="security">Безопасность</option>
<option value="monitoring">Мониторинг</option>
<option value="other">Другое</option>
</select>
</div>
<div className="flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-3">
<button
onClick={() => setShowAddForm(false)}
className="px-4 py-2 border border-gray-600 text-gray-300 rounded-lg hover:bg-gray-700 w-full md:w-auto"
>
Отмена
</button>
<button
onClick={addTemplate}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 w-full md:w-auto"
>
Сохранить
</button>
</div>
</div>
</div>
)}
{/* Мои шаблоны */}
<div className="bg-gray-800 rounded-lg shadow-lg p-4 md:p-6 border border-gray-700">
<h3 className="text-lg font-semibold mb-4 text-white">Мои шаблоны команд</h3>
{templates.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<div className="text-4xl mb-2"></div>
<p>Нет сохраненных шаблонов команд</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{templates.map(template => (
<div key={template.id} className="border border-gray-700 rounded-lg p-4 bg-gray-750">
<div className="flex justify-between items-start mb-2">
<h4 className="font-semibold text-white text-base md:text-lg">{template.name}</h4>
<div className="flex space-x-2">
<button
onClick={() => {
setSelectedTemplate(template.id);
scrollToExecuteSection();
}}
className="text-green-500 hover:text-green-400"
title="Выполнить"
>
</button>
<button
onClick={() => deleteTemplate(template.id)}
className="text-red-500 hover:text-red-400"
title="Удалить"
>
🗑
</button>
</div>
</div>
<p className="text-sm text-gray-400 mb-2">{template.description}</p>
<code className="block bg-gray-900 p-2 rounded text-sm font-mono mb-2 text-white">
{template.command}
</code>
<div className="flex justify-between items-center text-xs text-gray-500">
<span className={`px-2 py-1 rounded ${getCategoryColor(template.category)}`}>
{template.category}
</span>
<span>Создано: {new Date(template.createdAt).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default CommandTemplates;
+275
View File
@@ -0,0 +1,275 @@
import React, { useState, useEffect } from 'react';
const Dashboard = () => {
const [stats, setStats] = useState({
totalServers: 0,
onlineServers: 0,
totalPasswords: 0,
totalTemplates: 0,
totalNotes: 0,
recentActivity: [],
systemStatus: {
cpu: 0,
memory: 0,
disk: 0
}
});
useEffect(() => {
loadDashboardData();
const interval = setInterval(loadDashboardData, 30000); // Обновление каждые 30 секунд
return () => clearInterval(interval);
}, []);
const loadDashboardData = async () => {
if (window.electronAPI) {
try {
const servers = await window.electronAPI.loadServers();
const passwords = await window.electronAPI.loadPasswords();
const templates = await window.electronAPI.loadCommandTemplates();
const notes = await window.electronAPI.loadNotes();
let serverList = [];
let onlineCount = 0;
if (servers.success) {
serverList = servers.servers || [];
// Проверяем статус серверов
for (const server of serverList) {
const pingResult = await window.electronAPI.pingServer(server);
if (pingResult.success && pingResult.online) {
onlineCount++;
}
}
}
// Получаем реальные данные о системе
const systemData = await getSystemInfo();
// Формируем реальную активность
const activities = await getRecentActivity();
setStats({
totalServers: serverList.length,
onlineServers: onlineCount,
totalPasswords: passwords.success ? (passwords.passwords?.length || 0) : 0,
totalTemplates: templates.success ? (templates.templates?.length || 0) : 0,
totalNotes: notes.success ? (notes.notes?.length || 0) : 0,
recentActivity: activities,
systemStatus: systemData
});
} catch (error) {
console.error('Error loading dashboard data:', error);
}
}
};
const getSystemInfo = async () => {
// В реальном приложении здесь был бы вызов к systeminformation
// Пока используем заглушку
return {
cpu: Math.floor(Math.random() * 30) + 10, // 10-40%
memory: Math.floor(Math.random() * 50) + 30, // 30-80%
disk: Math.floor(Math.random() * 40) + 20 // 20-60%
};
};
const getRecentActivity = async () => {
const activities = [];
const now = new Date();
// Получаем последние серверы
const servers = await window.electronAPI.loadServers();
if (servers.success && servers.servers) {
const recentServers = servers.servers
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 2);
recentServers.forEach(server => {
activities.push({
id: `server-${server.id}`,
action: `Добавлен сервер: ${server.name}`,
timestamp: server.createdAt,
type: 'server'
});
});
}
// Получаем последние заметки
const notes = await window.electronAPI.loadNotes();
if (notes.success && notes.notes) {
const recentNotes = notes.notes
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 2);
recentNotes.forEach(note => {
activities.push({
id: `note-${note.id}`,
action: `Создана заметка: ${note.title}`,
timestamp: note.createdAt,
type: 'note'
});
});
}
// Сортируем по времени и берем последние 4
return activities
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
.slice(0, 4);
};
const getActivityIcon = (type) => {
switch (type) {
case 'server': return '🖥️';
case 'note': return '📝';
case 'password': return '🔐';
case 'template': return '⚡';
default: return '📌';
}
};
const getStatusColor = (value) => {
if (value < 70) return 'text-green-500';
if (value < 90) return 'text-yellow-500';
return 'text-red-500';
};
return (
<div className="p-4 md:p-6">
<div className="mb-4 md:mb-6">
<h1 className="text-xl md:text-2xl font-bold text-white">Дашборд</h1>
<p className="text-gray-400 text-sm md:text-base">Обзор состояния системы</p>
</div>
{/* Статистика */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-4 md:mb-6">
<div className="bg-gray-800 p-4 rounded-lg text-center">
<div className="text-2xl md:text-3xl font-bold text-blue-500">{stats.totalServers}</div>
<div className="text-xs md:text-sm text-gray-400">Всего серверов</div>
<div className="text-xs text-green-500 mt-1">{stats.onlineServers} онлайн</div>
</div>
<div className="bg-gray-800 p-4 rounded-lg text-center">
<div className="text-2xl md:text-3xl font-bold text-purple-500">{stats.totalPasswords}</div>
<div className="text-xs md:text-sm text-gray-400">Сохранено паролей</div>
</div>
<div className="bg-gray-800 p-4 rounded-lg text-center">
<div className="text-2xl md:text-3xl font-bold text-yellow-500">{stats.totalTemplates}</div>
<div className="text-xs md:text-sm text-gray-400">Шаблонов команд</div>
</div>
<div className="bg-gray-800 p-4 rounded-lg text-center">
<div className="text-2xl md:text-3xl font-bold text-green-500">{stats.totalNotes}</div>
<div className="text-xs md:text-sm text-gray-400">Заметок</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
{/* Системный статус */}
<div className="bg-gray-800 rounded-lg p-4 md:p-6 lg:col-span-1">
<h3 className="text-lg font-semibold text-white mb-4">Системный статус</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">CPU</span>
<span className={getStatusColor(stats.systemStatus.cpu)}>
{stats.systemStatus.cpu}%
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${stats.systemStatus.cpu}%` }}
></div>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">Память</span>
<span className={getStatusColor(stats.systemStatus.memory)}>
{stats.systemStatus.memory}%
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${stats.systemStatus.memory}%` }}
></div>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">Диск</span>
<span className={getStatusColor(stats.systemStatus.disk)}>
{stats.systemStatus.disk}%
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-purple-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${stats.systemStatus.disk}%` }}
></div>
</div>
</div>
</div>
</div>
{/* Недавняя активность */}
<div className="bg-gray-800 rounded-lg p-4 md:p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-white mb-4">Недавняя активность</h3>
<div className="space-y-3">
{stats.recentActivity.length > 0 ? (
stats.recentActivity.map(activity => (
<div key={activity.id} className="border-l-2 border-primary-500 pl-3 py-2">
<div className="flex items-center space-x-2">
<span className="text-sm">{getActivityIcon(activity.type)}</span>
<p className="text-white text-sm flex-1">{activity.action}</p>
</div>
<p className="text-gray-500 text-xs mt-1">
{new Date(activity.timestamp).toLocaleString()}
</p>
</div>
))
) : (
<div className="text-center py-4 text-gray-400">
<div className="text-2xl mb-2">📊</div>
<p>Активности пока нет</p>
</div>
)}
</div>
</div>
</div>
{/* Быстрые действия */}
<div className="mt-4 md:mt-6 bg-gray-800 rounded-lg p-4 md:p-6">
<h3 className="text-lg font-semibold text-white mb-4">Быстрые действия</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<button
onClick={() => window.location.hash = 'servers'}
className="p-3 bg-primary-600 rounded-lg hover:bg-primary-700 transition-colors text-white text-sm"
>
🖥 Добавить сервер
</button>
<button
onClick={() => window.location.hash = 'notes'}
className="p-3 bg-green-600 rounded-lg hover:bg-green-700 transition-colors text-white text-sm"
>
📝 Новая заметка
</button>
<button
onClick={() => window.location.hash = 'templates'}
className="p-3 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors text-white text-sm"
>
Шаблоны команд
</button>
<button
onClick={() => window.location.hash = 'passwords'}
className="p-3 bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors text-white text-sm"
>
🔐 Менеджер паролей
</button>
</div>
</div>
</div>
);
};
export default Dashboard;
+206
View File
@@ -0,0 +1,206 @@
import React, { useState, useEffect } from 'react';
const NetworkMonitor = () => {
const [networkAdapters, setNetworkAdapters] = useState([]);
const [vpnConnections, setVpnConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState(null);
useEffect(() => {
loadNetworkData();
const interval = setInterval(loadNetworkData, 10000);
return () => clearInterval(interval);
}, []);
// Fallback данные
const getFallbackAdapters = () => [
{
name: 'eth0',
description: 'Ethernet Interface',
status: 'Up',
ipAddress: '192.168.1.100',
macAddress: '00:1B:44:11:3A:B7',
type: 'Ethernet'
}
];
const loadNetworkData = async () => {
setLoading(true);
try {
// Получаем реальные данные из Electron
if (window.electronAPI) {
const adaptersResult = await window.electronAPI.getNetworkAdapters();
const vpnResult = await window.electronAPI.getVpnConnections();
if (adaptersResult.success) {
setNetworkAdapters(adaptersResult.adapters || getFallbackAdapters());
} else {
setNetworkAdapters(getFallbackAdapters());
}
if (vpnResult.success) {
setVpnConnections(vpnResult.connections || []);
} else {
setVpnConnections([]);
}
} else {
// Fallback данные если API недоступно
setNetworkAdapters(getFallbackAdapters());
setVpnConnections([]);
}
} catch (error) {
console.error('Error loading network data:', error);
setNetworkAdapters(getFallbackAdapters());
setVpnConnections([]);
} finally {
setLoading(false);
setLastUpdate(new Date());
}
};
const getStatusColor = (status) => {
return status === 'Up' || status === 'Connected'
? 'bg-green-500'
: 'bg-red-500';
};
const getAdapterIcon = (type) => {
switch (type) {
case 'Ethernet': return '🔌';
case 'Wi-Fi': return '📶';
case 'VPN': return '🛡️';
case 'Loopback': return '🔄';
default: return '🌐';
}
};
return (
<div className="p-4 md:p-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 md:mb-6 gap-4">
<div>
<h1 className="text-xl md:text-2xl font-bold text-white">Мониторинг сети</h1>
<p className="text-gray-400 text-sm md:text-base">
{lastUpdate ? `Обновлено: ${lastUpdate.toLocaleTimeString()}` : 'Загрузка...'}
</p>
</div>
<button
onClick={loadNetworkData}
disabled={loading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 w-full md:w-auto"
>
{loading ? 'Загрузка...' : 'Обновить'}
</button>
</div>
{/* Статистика */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-4 md:mb-6">
<div className="bg-gray-800 p-3 md:p-4 rounded-lg text-center">
<div className="text-lg md:text-2xl font-bold text-white">{networkAdapters.length}</div>
<div className="text-xs md:text-sm text-gray-400">Интерфейсы</div>
</div>
<div className="bg-gray-800 p-3 md:p-4 rounded-lg text-center">
<div className="text-lg md:text-2xl font-bold text-green-500">
{networkAdapters.filter(a => a.status === 'Up').length}
</div>
<div className="text-xs md:text-sm text-gray-400">Активные</div>
</div>
<div className="bg-gray-800 p-3 md:p-4 rounded-lg text-center">
<div className="text-lg md:text-2xl font-bold text-blue-500">{vpnConnections.length}</div>
<div className="text-xs md:text-sm text-gray-400">VPN</div>
</div>
<div className="bg-gray-800 p-3 md:p-4 rounded-lg text-center">
<div className="text-lg md:text-2xl font-bold text-purple-500">
{networkAdapters.filter(a => a.type === 'Wi-Fi').length}
</div>
<div className="text-xs md:text-sm text-gray-400">Wi-Fi</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
{/* Сетевые адаптеры */}
<div className="bg-gray-800 rounded-lg p-4 md:p-6">
<h2 className="text-lg md:text-xl font-semibold text-white mb-3 md:mb-4">Сетевые интерфейсы</h2>
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
<p className="text-gray-400 mt-2">Загрузка данных...</p>
</div>
) : networkAdapters.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<div className="text-4xl mb-2">🔍</div>
<p>Нет данных о сетевых интерфейсах</p>
</div>
) : (
<div className="space-y-3 md:space-y-4">
{networkAdapters.map((adapter, index) => (
<div key={index} className="border border-gray-700 rounded-lg p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-3">
<span className="text-xl">{getAdapterIcon(adapter.type)}</span>
<div>
<h3 className="font-semibold text-white text-sm md:text-base">{adapter.name}</h3>
<p className="text-gray-400 text-xs md:text-sm">{adapter.description}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${getStatusColor(adapter.status)}`}></div>
<span className="text-xs text-gray-400">{adapter.status}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs md:text-sm">
<div>
<span className="text-gray-500">IP:</span>
<span className="text-white ml-1">{adapter.ipAddress}</span>
</div>
<div>
<span className="text-gray-500">MAC:</span>
<span className="text-white ml-1">{adapter.macAddress}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* VPN подключения */}
<div className="bg-gray-800 rounded-lg p-4 md:p-6">
<h2 className="text-lg md:text-xl font-semibold text-white mb-3 md:mb-4">VPN подключения</h2>
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
<p className="text-gray-400 mt-2">Загрузка данных...</p>
</div>
) : vpnConnections.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<div className="text-4xl mb-2">🔒</div>
<p>Нет активных VPN подключений</p>
</div>
) : (
<div className="space-y-3 md:space-y-4">
{vpnConnections.map((connection, index) => (
<div key={index} className="border border-gray-700 rounded-lg p-3 md:p-4 bg-gradient-to-r from-blue-900/20 to-purple-900/20">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-white text-sm md:text-base">{connection.name}</h3>
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${getStatusColor(connection.status)}`}></div>
<span className="text-xs text-gray-400">{connection.status}</span>
</div>
</div>
<p className="text-gray-400 text-xs md:text-sm mb-2">{connection.description}</p>
<div className="text-xs md:text-sm">
<span className="text-gray-500">Интерфейс: </span>
<span className="text-white">{connection.interface}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default NetworkMonitor;
+199
View File
@@ -0,0 +1,199 @@
import React, { useState } from 'react';
const NetworkTools = () => {
const [activeTool, setActiveTool] = useState('traceroute');
const [input, setInput] = useState('');
const [results, setResults] = useState('');
const [loading, setLoading] = useState(false);
const [portScanResults, setPortScanResults] = useState([]);
const tools = [
{ id: 'traceroute', name: 'Traceroute', icon: '🔄', placeholder: 'example.com или IP' },
{ id: 'whois', name: 'WHOIS Lookup', icon: '🔍', placeholder: 'domain.com' },
{ id: 'port-scan', name: 'Port Scan', icon: '🔒', placeholder: '192.168.1.1' },
{ id: 'dns-lookup', name: 'DNS Lookup', icon: '🌐', placeholder: 'domain.com' },
{ id: 'speed-test', name: 'Speed Test', icon: '🚀', placeholder: 'Нажмите для запуска' }
];
const executeTool = async () => {
if (!input.trim() && activeTool !== 'speed-test') {
alert('Введите значение для проверки');
return;
}
setLoading(true);
setResults('Выполнение...');
try {
let result;
switch (activeTool) {
case 'traceroute':
result = await window.electronAPI.traceroute(input);
break;
case 'whois':
result = await window.electronAPI.whois(input);
break;
case 'port-scan':
result = await window.electronAPI.portScan(input, '21,22,80,443,8080,3306,5432,27017');
if (result.success) {
setPortScanResults(result.results);
setResults(`Найдено открытых портов: ${result.results.filter(r => r.open).length}`);
}
break;
case 'dns-lookup':
result = await window.electronAPI.dnsLookup(input);
break;
case 'speed-test':
result = await window.electronAPI.speedTest({});
break;
default:
result = { success: false, error: 'Неизвестный инструмент' };
}
if (result.success) {
if (activeTool !== 'port-scan') {
setResults(result.output || 'Успешно выполнено');
}
} else {
setResults(`Ошибка: ${result.error}`);
}
} catch (error) {
setResults(`Ошибка: ${error.message}`);
} finally {
setLoading(false);
}
};
const getToolDescription = () => {
const descriptions = {
'traceroute': 'Трассировка маршрута до указанного хоста',
'whois': 'Информация о домене из WHOIS базы данных',
'port-scan': 'Сканирование открытых портов на указанном хосте',
'dns-lookup': 'DNS запрос для получения IP адресов домена',
'speed-test': 'Тест скорости интернет соединения'
};
return descriptions[activeTool] || '';
};
return (
<div className="p-4 md:p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Сетевые инструменты</h1>
<p className="text-gray-400">Диагностика и анализ сетевых соединений</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Боковая панель с инструментами */}
<div className="lg:col-span-1">
<div className="bg-gray-800 rounded-lg p-4">
<h3 className="font-semibold text-white mb-4">Инструменты</h3>
<div className="space-y-2">
{tools.map(tool => (
<button
key={tool.id}
onClick={() => {
setActiveTool(tool.id);
setResults('');
setPortScanResults([]);
if (tool.id === 'speed-test') setInput('');
}}
className={`w-full text-left p-3 rounded-lg transition-colors flex items-center gap-3 ${
activeTool === tool.id
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700'
}`}
>
<span className="text-lg">{tool.icon}</span>
<span>{tool.name}</span>
</button>
))}
</div>
</div>
</div>
{/* Основная область */}
<div className="lg:col-span-3">
<div className="bg-gray-800 rounded-lg p-6">
<div className="mb-6">
<h3 className="text-lg font-semibold text-white mb-2">
{tools.find(t => t.id === activeTool)?.name}
</h3>
<p className="text-gray-400 text-sm">{getToolDescription()}</p>
</div>
{/* Input area */}
{activeTool !== 'speed-test' && (
<div className="flex gap-3 mb-6">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={tools.find(t => t.id === activeTool)?.placeholder}
className="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:outline-none"
/>
<button
onClick={executeTool}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
{loading ? '🔄' : '▶️'}
{loading ? 'Выполнение...' : 'Запуск'}
</button>
</div>
)}
{activeTool === 'speed-test' && (
<div className="mb-6">
<button
onClick={executeTool}
disabled={loading}
className="w-full py-4 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-3 text-lg"
>
{loading ? '🔄' : '🚀'}
{loading ? 'Тестирование скорости...' : 'Запустить Speed Test'}
</button>
</div>
)}
{/* Results area */}
<div className="bg-gray-900 rounded-lg p-4 min-h-[200px]">
{activeTool === 'port-scan' && portScanResults.length > 0 ? (
<div>
<h4 className="text-white font-semibold mb-3">Результаты сканирования портов:</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{portScanResults.map((result, index) => (
<div
key={index}
className={`p-3 rounded-lg border ${
result.open
? 'bg-green-900 border-green-600'
: 'bg-gray-800 border-gray-600'
}`}
>
<div className="flex justify-between items-center">
<span className="text-white font-mono">Port {result.port}</span>
<span className={`text-xs px-2 py-1 rounded ${
result.open ? 'bg-green-600 text-white' : 'bg-gray-600 text-gray-300'
}`}>
{result.open ? 'OPEN' : 'CLOSED'}
</span>
</div>
<div className="text-gray-400 text-xs mt-1">{result.service}</div>
</div>
))}
</div>
</div>
) : (
<pre className="text-gray-300 whitespace-pre-wrap font-mono text-sm">
{results}
</pre>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default NetworkTools;
+664
View File
@@ -0,0 +1,664 @@
import React, { useState, useEffect, useRef } from 'react';
const Notes = () => {
const [notes, setNotes] = useState([]);
const [folders, setFolders] = useState([]);
const [activeFolder, setActiveFolder] = useState('all');
const [showAddForm, setShowAddForm] = useState(false);
const [showFolderForm, setShowFolderForm] = useState(false);
const [editingNote, setEditingNote] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [viewMode, setViewMode] = useState('grid'); // 'grid' или 'list'
const [newNote, setNewNote] = useState({
title: '',
content: '',
folder: '',
tags: [],
color: 'blue',
isRichText: false
});
const [newFolder, setNewFolder] = useState('');
const editorRef = useRef(null);
useEffect(() => {
loadNotes();
loadFolders();
}, []);
const loadNotes = async () => {
if (window.electronAPI) {
const result = await window.electronAPI.loadNotes();
if (result.success) {
setNotes(result.notes || []);
}
}
};
const loadFolders = async () => {
if (window.electronAPI) {
const result = await window.electronAPI.loadNoteFolders();
if (result.success) {
setFolders(result.folders || []);
}
}
};
const saveNotes = async (notesList) => {
if (window.electronAPI) {
await window.electronAPI.saveNotes(notesList);
}
};
const saveFolders = async (foldersList) => {
if (window.electronAPI) {
await window.electronAPI.saveNoteFolders(foldersList);
}
};
// Функции для форматирования текста
const formatText = (command, value = null) => {
document.execCommand(command, false, value);
if (editorRef.current) {
setNewNote({...newNote, content: editorRef.current.innerHTML});
}
};
const insertList = (type) => {
formatText('insert' + (type === 'ordered' ? 'OrderedList' : 'UnorderedList'));
};
const addNote = async () => {
if (!newNote.title.trim()) {
alert('Введите заголовок заметки');
return;
}
const note = {
...newNote,
id: Date.now().toString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
const updatedNotes = [...notes, note];
setNotes(updatedNotes);
await saveNotes(updatedNotes);
setShowAddForm(false);
setNewNote({
title: '',
content: '',
folder: '',
tags: [],
color: 'blue',
isRichText: false
});
};
const updateNote = async () => {
if (!editingNote.title.trim()) return;
const updatedNotes = notes.map(note =>
note.id === editingNote.id
? { ...editingNote, updatedAt: new Date().toISOString() }
: note
);
setNotes(updatedNotes);
await saveNotes(updatedNotes);
setEditingNote(null);
};
const deleteNote = async (noteId) => {
if (confirm('Удалить эту заметку?')) {
const updatedNotes = notes.filter(note => note.id !== noteId);
setNotes(updatedNotes);
await saveNotes(updatedNotes);
}
};
const addFolder = async () => {
if (!newFolder.trim()) {
alert('Введите название папки');
return;
}
const folder = {
id: Date.now().toString(),
name: newFolder,
color: 'gray'
};
const updatedFolders = [...folders, folder];
setFolders(updatedFolders);
await saveFolders(updatedFolders);
setShowFolderForm(false);
setNewFolder('');
};
const deleteFolder = async (folderId) => {
if (confirm('Удалить папку? Заметки из этой папки будут перемещены в "Без папки"')) {
const updatedNotes = notes.map(note =>
note.folder === folderId ? { ...note, folder: '' } : note
);
setNotes(updatedNotes);
await saveNotes(updatedNotes);
const updatedFolders = folders.filter(folder => folder.id !== folderId);
setFolders(updatedFolders);
await saveFolders(updatedFolders);
}
};
const getFolderNotesCount = (folderId) => {
return notes.filter(note => note.folder === folderId).length;
};
const filteredNotes = notes.filter(note => {
const matchesFolder = activeFolder === 'all' || note.folder === activeFolder;
const matchesSearch = note.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
note.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
note.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()));
return matchesFolder && matchesSearch;
});
const getColorClass = (color) => {
const colorMap = {
blue: 'bg-blue-500',
green: 'bg-green-500',
red: 'bg-red-500',
yellow: 'bg-yellow-500',
purple: 'bg-purple-500',
pink: 'bg-pink-500',
gray: 'bg-gray-500'
};
return colorMap[color] || 'bg-blue-500';
};
const formatContent = (content) => {
if (!content) return '';
// Простая обработка переносов строк
return content.split('\n').map((line, index) => (
<div key={index}>
{line || <br />}
</div>
));
};
return (
<div className="p-4 md:p-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 md:mb-6 gap-4">
<div>
<h1 className="text-xl md:text-2xl font-bold text-white">Заметки</h1>
<p className="text-gray-400 text-sm md:text-base">Организация мыслей и информации</p>
</div>
<div className="flex flex-col md:flex-row gap-3 w-full md:w-auto">
<div className="flex gap-2">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-primary-600 text-white' : 'bg-gray-700 text-gray-300'}`}
title="Сетка"
>
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded ${viewMode === 'list' ? 'bg-primary-600 text-white' : 'bg-gray-700 text-gray-300'}`}
title="Список"
>
</button>
</div>
<div className="relative w-full md:w-64">
<input
type="text"
placeholder="Поиск заметок..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-3 pr-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
/>
</div>
<button
onClick={() => setShowFolderForm(true)}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors w-full md:w-auto text-sm"
>
+ Папка
</button>
<button
onClick={() => setShowAddForm(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors w-full md:w-auto text-sm"
>
+ Заметка
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Боковая панель с папками */}
<div className="lg:col-span-1">
<div className="bg-gray-800 rounded-lg p-4">
<h3 className="font-semibold text-white mb-4">Папки</h3>
<div className="space-y-2">
<button
onClick={() => setActiveFolder('all')}
className={`w-full text-left p-3 rounded-lg transition-colors flex justify-between items-center ${
activeFolder === 'all'
? 'bg-primary-600 text-white'
: 'text-gray-300 hover:bg-gray-700'
}`}
>
<span>Все заметки</span>
<span className="text-xs bg-gray-600 px-2 py-1 rounded-full">
{notes.length}
</span>
</button>
{folders.map(folder => (
<div key={folder.id} className="flex justify-between items-center group">
<button
onClick={() => setActiveFolder(folder.id)}
className={`flex-1 text-left p-3 rounded-lg transition-colors flex justify-between items-center ${
activeFolder === folder.id
? 'bg-primary-600 text-white'
: 'text-gray-300 hover:bg-gray-700'
}`}
>
<span>{folder.name}</span>
<span className="text-xs bg-gray-600 px-2 py-1 rounded-full">
{getFolderNotesCount(folder.id)}
</span>
</button>
<button
onClick={() => deleteFolder(folder.id)}
className="text-gray-400 hover:text-red-500 transition-colors opacity-0 group-hover:opacity-100 ml-2"
title="Удалить папку"
>
🗑
</button>
</div>
))}
</div>
</div>
{/* Быстрые шаблоны */}
<div className="bg-gray-800 rounded-lg p-4 mt-4">
<h3 className="font-semibold text-white mb-3">Шаблоны</h3>
<div className="space-y-2">
<button
onClick={() => {
setNewNote({
title: 'Команда для сервера',
content: 'Команда: \nОписание: \nПримечания:',
folder: '',
tags: ['команда'],
color: 'blue',
isRichText: false
});
setShowAddForm(true);
}}
className="w-full text-left p-2 text-sm bg-gray-700 rounded hover:bg-gray-600 text-gray-300"
>
📝 Шаблон команды
</button>
<button
onClick={() => {
setNewNote({
title: 'Конфигурация сервера',
content: 'Сервер: \nIP: \nПорты: \nНастройки:',
folder: '',
tags: ['сервер'],
color: 'green',
isRichText: false
});
setShowAddForm(true);
}}
className="w-full text-left p-2 text-sm bg-gray-700 rounded hover:bg-gray-600 text-gray-300"
>
🖥 Конфиг сервера
</button>
</div>
</div>
</div>
{/* Основная область с заметками */}
<div className="lg:col-span-3">
{/* Форма добавления/редактирования заметки */}
{(showAddForm || editingNote) && (
<div className="bg-gray-800 rounded-lg p-4 md:p-6 mb-6 border border-gray-700">
<h3 className="text-lg font-semibold mb-4 text-white">
{editingNote ? 'Редактировать заметку' : 'Новая заметка'}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Заголовок *
</label>
<input
type="text"
value={editingNote ? editingNote.title : newNote.title}
onChange={(e) => editingNote
? setEditingNote({...editingNote, title: e.target.value})
: setNewNote({...newNote, title: e.target.value})
}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Заголовок заметки"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Папка
</label>
<select
value={editingNote ? editingNote.folder : newNote.folder}
onChange={(e) => editingNote
? setEditingNote({...editingNote, folder: e.target.value})
: setNewNote({...newNote, folder: e.target.value})
}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="">Без папки</option>
{folders.map(folder => (
<option key={folder.id} value={folder.id}>{folder.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Цвет
</label>
<select
value={editingNote ? editingNote.color : newNote.color}
onChange={(e) => editingNote
? setEditingNote({...editingNote, color: e.target.value})
: setNewNote({...newNote, color: e.target.value})
}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="blue">Синий</option>
<option value="green">Зеленый</option>
<option value="red">Красный</option>
<option value="yellow">Желтый</option>
<option value="purple">Фиолетовый</option>
<option value="pink">Розовый</option>
<option value="gray">Серый</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Теги (через запятую)
</label>
<input
type="text"
value={editingNote ? editingNote.tags.join(', ') : newNote.tags.join(', ')}
onChange={(e) => {
const tags = e.target.value.split(',').map(tag => tag.trim()).filter(Boolean);
editingNote
? setEditingNote({...editingNote, tags})
: setNewNote({...newNote, tags})
}}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="сервер, команда, настройка"
/>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<label className="block text-sm font-medium text-gray-300">
Содержание
</label>
<div className="flex space-x-1">
<button
onClick={() => formatText('bold')}
className="px-2 py-1 bg-gray-700 rounded text-sm text-white hover:bg-gray-600"
title="Жирный"
>
<strong>B</strong>
</button>
<button
onClick={() => formatText('italic')}
className="px-2 py-1 bg-gray-700 rounded text-sm text-white hover:bg-gray-600"
title="Курсив"
>
<em>I</em>
</button>
<button
onClick={() => formatText('underline')}
className="px-2 py-1 bg-gray-700 rounded text-sm text-white hover:bg-gray-600"
title="Подчеркивание"
>
<u>U</u>
</button>
<button
onClick={() => insertList('unordered')}
className="px-2 py-1 bg-gray-700 rounded text-sm text-white hover:bg-gray-600"
title="Маркированный список"
>
List
</button>
<button
onClick={() => insertList('ordered')}
className="px-2 py-1 bg-gray-700 rounded text-sm text-white hover:bg-gray-600"
title="Нумерованный список"
>
1. List
</button>
</div>
</div>
<div
ref={editorRef}
contentEditable
dangerouslySetInnerHTML={{
__html: editingNote ? editingNote.content : newNote.content
}}
onInput={(e) => {
const content = e.target.innerHTML;
editingNote
? setEditingNote({...editingNote, content})
: setNewNote({...newNote, content})
}}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent min-h-[200px] overflow-y-auto"
style={{
whiteSpace: 'pre-wrap',
wordWrap: 'break-word'
}}
/>
</div>
<div className="flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-3">
<button
onClick={() => {
setShowAddForm(false);
setEditingNote(null);
}}
className="px-4 py-2 border border-gray-600 text-gray-300 rounded-lg hover:bg-gray-700 transition-colors w-full md:w-auto"
>
Отмена
</button>
<button
onClick={editingNote ? updateNote : addNote}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors w-full md:w-auto"
>
{editingNote ? 'Обновить' : 'Сохранить'}
</button>
</div>
</div>
</div>
)}
{/* Форма добавления папки */}
{showFolderForm && (
<div className="bg-gray-800 rounded-lg p-4 md:p-6 mb-6 border border-gray-700">
<h3 className="text-lg font-semibold mb-4 text-white">Новая папка</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Название папки *
</label>
<input
type="text"
value={newFolder}
onChange={(e) => setNewFolder(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Название папки"
/>
</div>
<div className="flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-3">
<button
onClick={() => setShowFolderForm(false)}
className="px-4 py-2 border border-gray-600 text-gray-300 rounded-lg hover:bg-gray-700 transition-colors w-full md:w-auto"
>
Отмена
</button>
<button
onClick={addFolder}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors w-full md:w-auto"
>
Создать папку
</button>
</div>
</div>
</div>
)}
{/* Список заметок */}
{filteredNotes.length === 0 ? (
<div className="text-center py-8 md:py-12">
<div className="text-gray-400 text-4xl md:text-6xl mb-4">📝</div>
<h3 className="text-lg font-medium text-white">Нет заметок</h3>
<p className="text-gray-400 mt-1">
{searchTerm ? 'Ничего не найдено' : 'Создайте вашу первую заметку'}
</p>
</div>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
{filteredNotes.map(note => (
<div
key={note.id}
className="bg-gray-800 p-4 md:p-6 rounded-lg shadow-lg border border-gray-700 hover:border-primary-500 transition-colors"
>
<div className="flex justify-between items-start mb-3">
<div className="flex items-center space-x-2">
<div className={`w-3 h-3 rounded-full ${getColorClass(note.color)}`}></div>
<h3 className="font-semibold text-white text-base md:text-lg">{note.title}</h3>
</div>
<div className="flex space-x-2">
<button
onClick={() => setEditingNote(note)}
className="text-gray-400 hover:text-primary-500 transition-colors"
title="Редактировать"
>
</button>
<button
onClick={() => deleteNote(note.id)}
className="text-gray-400 hover:text-red-500 transition-colors"
title="Удалить"
>
🗑
</button>
</div>
</div>
<div className="mb-3">
<div
className="text-gray-300 text-sm line-clamp-4 rich-text-content"
dangerouslySetInnerHTML={{ __html: note.content }}
/>
</div>
{note.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{note.tags.map(tag => (
<span key={tag} className="px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs">
#{tag}
</span>
))}
</div>
)}
<div className="flex justify-between items-center pt-3 border-t border-gray-700">
<span className="text-xs text-gray-500">
{new Date(note.updatedAt).toLocaleDateString()}
</span>
{note.folder && (
<span className="text-xs px-2 py-1 bg-primary-500 text-white rounded">
{folders.find(f => f.id === note.folder)?.name}
</span>
)}
</div>
</div>
))}
</div>
) : (
<div className="space-y-4">
{filteredNotes.map(note => (
<div
key={note.id}
className="bg-gray-800 p-4 md:p-6 rounded-lg shadow-lg border border-gray-700 hover:border-primary-500 transition-colors"
>
<div className="flex justify-between items-start mb-3">
<div className="flex items-center space-x-3 flex-1">
<div className={`w-3 h-3 rounded-full ${getColorClass(note.color)}`}></div>
<h3 className="font-semibold text-white text-base md:text-lg flex-1">{note.title}</h3>
<div className="flex space-x-2">
<button
onClick={() => setEditingNote(note)}
className="text-gray-400 hover:text-primary-500 transition-colors"
title="Редактировать"
>
</button>
<button
onClick={() => deleteNote(note.id)}
className="text-gray-400 hover:text-red-500 transition-colors"
title="Удалить"
>
🗑
</button>
</div>
</div>
</div>
<div className="mb-3">
<div
className="text-gray-300 text-sm rich-text-content"
dangerouslySetInnerHTML={{ __html: note.content }}
/>
</div>
<div className="flex justify-between items-center pt-3 border-t border-gray-700">
<div className="flex flex-wrap gap-1">
{note.tags.map(tag => (
<span key={tag} className="px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs">
#{tag}
</span>
))}
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500">
{new Date(note.updatedAt).toLocaleDateString()}
</span>
{note.folder && (
<span className="text-xs px-2 py-1 bg-primary-500 text-white rounded">
{folders.find(f => f.id === note.folder)?.name}
</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default Notes;
+300
View File
@@ -0,0 +1,300 @@
import React, { useState, useEffect } from 'react';
const PasswordManager = () => {
const [passwords, setPasswords] = useState([]);
const [showAddForm, setShowAddForm] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [newPassword, setNewPassword] = useState({
title: '',
server: '',
username: '',
password: '',
category: 'server',
notes: ''
});
useEffect(() => {
loadPasswords();
}, []);
const loadPasswords = async () => {
if (window.electronAPI) {
const result = await window.electronAPI.loadPasswords();
if (result.success) {
setPasswords(result.passwords || []);
}
}
};
const savePasswords = async (passwordsList) => {
if (window.electronAPI) {
await window.electronAPI.savePasswords(passwordsList);
}
};
const addPassword = async () => {
if (!newPassword.title) {
alert('Заполните название');
return;
}
const password = {
...newPassword,
id: Date.now().toString(),
createdAt: new Date().toISOString()
};
const updatedPasswords = [...passwords, password];
setPasswords(updatedPasswords);
await savePasswords(updatedPasswords);
setShowAddForm(false);
setNewPassword({
title: '',
server: '',
username: '',
password: '',
category: 'server',
notes: ''
});
};
const deletePassword = async (passwordId) => {
if (confirm('Удалить эту запись?')) {
const updatedPasswords = passwords.filter(p => p.id !== passwordId);
setPasswords(updatedPasswords);
await savePasswords(updatedPasswords);
}
};
const copyToClipboard = async (text) => {
if (window.electronAPI) {
await window.electronAPI.copyToClipboard(text);
alert('Скопировано в буфер обмена');
}
};
const generatePassword = () => {
const length = 16;
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
let password = "";
for (let i = 0; i < length; i++) {
password += charset.charAt(Math.floor(Math.random() * charset.length));
}
setNewPassword({...newPassword, password});
};
const filteredPasswords = passwords.filter(password =>
password.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
password.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
password.category.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-4 md:p-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 md:mb-6 gap-4">
<div>
<h1 className="text-xl md:text-2xl font-bold text-white">Менеджер паролей</h1>
<p className="text-gray-400 text-sm md:text-base">Безопасное хранение учетных данных</p>
</div>
<div className="flex flex-col md:flex-row gap-3 w-full md:w-auto">
<div className="relative w-full md:w-64">
<input
type="text"
placeholder="Поиск паролей..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-3 pr-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
/>
</div>
<button
onClick={() => setShowAddForm(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors w-full md:w-auto text-sm"
>
+ Добавить запись
</button>
</div>
</div>
{showAddForm && (
<div className="bg-gray-800 p-4 md:p-6 rounded-lg shadow-lg mb-4 md:mb-6 border border-gray-700">
<h3 className="text-lg font-semibold mb-4 text-white">Добавить новую запись</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-300 mb-1">
Название *
</label>
<input
type="text"
value={newPassword.title}
onChange={(e) => setNewPassword({...newPassword, title: e.target.value})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
placeholder="Root доступ к серверу"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Сервер
</label>
<input
type="text"
value={newPassword.server}
onChange={(e) => setNewPassword({...newPassword, server: e.target.value})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
placeholder="server.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Категория
</label>
<select
value={newPassword.category}
onChange={(e) => setNewPassword({...newPassword, category: e.target.value})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
>
<option value="server">Сервер</option>
<option value="database">База данных</option>
<option value="web">Веб-сайт</option>
<option value="other">Другое</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Имя пользователя
</label>
<input
type="text"
value={newPassword.username}
onChange={(e) => setNewPassword({...newPassword, username: e.target.value})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
placeholder="admin"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Пароль
</label>
<div className="flex gap-2">
<input
type="text"
value={newPassword.password}
onChange={(e) => setNewPassword({...newPassword, password: e.target.value})}
className="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
placeholder="Пароль"
/>
<button
onClick={generatePassword}
className="px-3 bg-primary-600 text-white rounded-lg hover:bg-primary-500 text-sm"
>
🎲
</button>
</div>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-300 mb-1">
Заметки
</label>
<textarea
value={newPassword.notes}
onChange={(e) => setNewPassword({...newPassword, notes: e.target.value})}
rows="3"
className="w-full bg-gray-700 border border-gray-600 rounded-lg p-3 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
placeholder="Дополнительная информация..."
/>
</div>
</div>
<div className="flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-3 mt-4">
<button
onClick={() => setShowAddForm(false)}
className="px-4 py-2 border border-gray-600 text-gray-300 rounded-lg hover:bg-gray-700 transition-colors w-full md:w-auto text-sm"
>
Отмена
</button>
<button
onClick={addPassword}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors w-full md:w-auto text-sm"
>
Сохранить
</button>
</div>
</div>
)}
{filteredPasswords.length === 0 ? (
<div className="text-center py-8 md:py-12">
<div className="text-gray-400 text-4xl md:text-6xl mb-4">🔐</div>
<h3 className="text-lg font-medium text-white">Нет сохраненных паролей</h3>
<p className="text-gray-400 mt-1">
{searchTerm ? 'Ничего не найдено' : 'Добавьте вашу первую запись'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
{filteredPasswords.map(password => (
<div key={password.id} className="bg-gray-800 p-4 md:p-6 rounded-lg shadow-lg border border-gray-700">
<div className="flex justify-between items-start mb-3 md:mb-4">
<div>
<h3 className="font-semibold text-white text-base md:text-lg">{password.title}</h3>
{password.server && (
<p className="text-gray-400 text-sm mt-1">{password.server}</p>
)}
</div>
<button
onClick={() => deletePassword(password.id)}
className="text-gray-400 hover:text-red-500 transition-colors"
>
🗑
</button>
</div>
<div className="space-y-3 md:space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Пользователь:</span>
<div className="flex items-center space-x-2">
<span className="text-sm text-white font-mono">{password.username}</span>
<button
onClick={() => copyToClipboard(password.username)}
className="text-gray-400 hover:text-primary-500 transition-colors"
>
📋
</button>
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Пароль:</span>
<div className="flex items-center space-x-2">
<span className="text-sm text-white font-mono"></span>
<button
onClick={() => copyToClipboard(password.password)}
className="text-gray-400 hover:text-primary-500 transition-colors"
>
📋
</button>
</div>
</div>
{password.notes && (
<div className="pt-3 border-t border-gray-700">
<p className="text-sm text-gray-400 italic">{password.notes}</p>
</div>
)}
<div className="flex justify-between items-center pt-3 border-t border-gray-700">
<span className="text-xs text-gray-500">
{new Date(password.createdAt).toLocaleDateString()}
</span>
<span className="text-xs px-2 py-1 bg-primary-500 text-white rounded">
{password.category}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default PasswordManager;
+210
View File
@@ -0,0 +1,210 @@
import React, { useState, useEffect } from 'react';
const ServerHealth = () => {
const [servers, setServers] = useState([]);
const [healthData, setHealthData] = useState({});
const [autoRefresh, setAutoRefresh] = useState(true);
const [loading, setLoading] = useState(false);
useEffect(() => {
loadServers();
}, []);
useEffect(() => {
let interval;
if (autoRefresh) {
interval = setInterval(() => {
refreshAllHealth();
}, 30000);
}
return () => clearInterval(interval);
}, [autoRefresh, servers]);
const loadServers = async () => {
if (window.electronAPI) {
const result = await window.electronAPI.loadServers();
if (result.success) {
setServers(result.servers);
refreshAllHealth();
}
}
};
const refreshAllHealth = async () => {
setLoading(true);
const newHealthData = {};
for (const server of servers) {
if (window.electronAPI) {
const result = await window.electronAPI.getServerHealth(server);
if (result.success) {
newHealthData[server.id] = result.health;
} else {
newHealthData[server.id] = {
cpu: 0,
memory: 0,
disk: 0,
uptime: 'N/A',
load: 'N/A',
online: false,
error: result.error
};
}
}
}
setHealthData(newHealthData);
setLoading(false);
};
const getStatusColor = (value, thresholds = [70, 90]) => {
if (value < thresholds[0]) return 'bg-green-500';
if (value < thresholds[1]) return 'bg-yellow-500';
return 'bg-red-500';
};
const getStatusText = (value, thresholds = [70, 90]) => {
if (value < thresholds[0]) return 'Норма';
if (value < thresholds[1]) return 'Нагрузка';
return 'Критично';
};
return (
<div className="p-4 md:p-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Health Dashboard</h1>
<p className="text-gray-400">Мониторинг состояния серверов в реальном времени</p>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded border-gray-600 bg-gray-700"
/>
<span className="text-gray-300 text-sm">Автообновление (30с)</span>
</label>
<button
onClick={refreshAllHealth}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
{loading ? '🔄' : '📊'}
{loading ? 'Обновление...' : 'Обновить'}
</button>
</div>
</div>
{servers.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 text-6xl mb-4">🏥</div>
<h3 className="text-lg font-medium text-white">Нет серверов для мониторинга</h3>
<p className="text-gray-400 mt-1">Добавьте серверы в разделе "Серверы"</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{servers.map(server => {
const health = healthData[server.id] || {};
return (
<div key={server.id} className="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-blue-500 transition-colors">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-semibold text-lg text-white">{server.name}</h3>
<p className="text-gray-400 text-sm">{server.ip}</p>
</div>
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${health.online ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className={`text-sm font-medium ${health.online ? 'text-green-400' : 'text-red-400'}`}>
{health.online ? 'Online' : 'Offline'}
</span>
</div>
</div>
{health.online ? (
<div className="space-y-4">
{/* CPU */}
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">Процессор</span>
<span className="text-white font-medium">
{health.cpu?.toFixed(1)}% - {getStatusText(health.cpu)}
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full ${getStatusColor(health.cpu)}`}
style={{ width: `${Math.min(100, health.cpu)}%` }}
></div>
</div>
</div>
{/* Memory */}
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">Память</span>
<span className="text-white font-medium">
{health.memory?.toFixed(1)}% - {getStatusText(health.memory)}
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full ${getStatusColor(health.memory)}`}
style={{ width: `${Math.min(100, health.memory)}%` }}
></div>
</div>
</div>
{/* Disk */}
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">Диск</span>
<span className="text-white font-medium">
{health.disk?.toFixed(1)}% - {getStatusText(health.disk)}
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full ${getStatusColor(health.disk)}`}
style={{ width: `${Math.min(100, health.disk)}%` }}
></div>
</div>
</div>
{/* System Info */}
<div className="grid grid-cols-2 gap-4 text-sm pt-2 border-t border-gray-700">
<div>
<span className="text-gray-400">Uptime:</span>
<div className="text-white font-medium">{health.uptime}</div>
</div>
<div>
<span className="text-gray-400">Load:</span>
<div className="text-white font-medium">{health.load}</div>
</div>
</div>
</div>
) : (
<div className="text-center py-4 text-gray-400">
<div className="text-2xl mb-2"></div>
<p>Сервер недоступен</p>
{health.error && (
<p className="text-xs mt-1 text-red-400">{health.error}</p>
)}
</div>
)}
<div className="text-xs text-gray-500 mt-4 text-center">
Обновлено: {health.timestamp ? new Date(health.timestamp).toLocaleTimeString() : 'N/A'}
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default ServerHealth;
+384
View File
@@ -0,0 +1,384 @@
import React, { useState, useEffect } from 'react';
const ServerList = () => {
const [servers, setServers] = useState([]);
const [showAddForm, setShowAddForm] = useState(false);
const [filter, setFilter] = useState('all');
const [newServer, setNewServer] = useState({
name: '',
ip: '',
port: '22',
username: 'root',
password: '',
webPort: '51821',
sshKey: '',
tags: [],
status: 'unknown'
});
useEffect(() => {
loadServers();
}, []);
const loadServers = async () => {
if (window.electronAPI) {
const result = await window.electronAPI.loadServers();
if (result.success) {
// Добавляем статус для каждого сервера
const serversWithStatus = result.servers.map(server => ({
...server,
status: server.status || 'unknown'
}));
setServers(serversWithStatus);
}
}
};
const saveServers = async (serversList) => {
if (window.electronAPI) {
await window.electronAPI.saveServers(serversList);
}
};
const addServer = async () => {
if (!newServer.name || !newServer.ip) {
alert('Заполните название и IP адрес');
return;
}
const server = {
...newServer,
id: Date.now().toString(),
createdAt: new Date().toISOString()
};
const updatedServers = [...servers, server];
setServers(updatedServers);
await saveServers(updatedServers);
setShowAddForm(false);
setNewServer({
name: '',
ip: '',
port: '22',
username: 'root',
password: '',
webPort: '51821',
sshKey: '',
tags: [],
status: 'unknown'
});
};
const deleteServer = async (serverId) => {
if (confirm('Удалить сервер?')) {
const updatedServers = servers.filter(s => s.id !== serverId);
setServers(updatedServers);
await saveServers(updatedServers);
}
};
const connectSSH = async (server) => {
if (window.electronAPI) {
// Логируем действие
await window.electronAPI.logAction(`SSH подключение к серверу ${server.name} (${server.ip})`);
await window.electronAPI.connectSSH(server);
}
};
const openWebInterface = async (server) => {
if (window.electronAPI) {
await window.electronAPI.logAction(`Открытие веб-интерфейса сервера ${server.name}`);
await window.electronAPI.openWebInterface(server);
}
};
const checkServerStatus = async (server) => {
// Простая проверка доступности
const updatedServers = servers.map(s =>
s.id === server.id ? { ...s, status: 'checking' } : s
);
setServers(updatedServers);
// Имитация проверки статуса
setTimeout(() => {
const randomStatus = Math.random() > 0.5 ? 'online' : 'offline';
const finalServers = servers.map(s =>
s.id === server.id ? { ...s, status: randomStatus } : s
);
setServers(finalServers);
saveServers(finalServers);
// Уведомление если сервер недоступен
if (randomStatus === 'offline' && window.electronAPI) {
window.electronAPI.showNotification(
'Сервер недоступен',
`Сервер ${server.name} (${server.ip}) не отвечает`
);
}
}, 2000);
};
const getStatusColor = (status) => {
switch (status) {
case 'online': return 'bg-green-500';
case 'offline': return 'bg-red-500';
case 'checking': return 'bg-yellow-500';
default: return 'bg-gray-500';
}
};
const getStatusText = (status) => {
switch (status) {
case 'online': return 'Доступен';
case 'offline': return 'Недоступен';
case 'checking': return 'Проверка...';
default: return 'Неизвестно';
}
};
const filteredServers = filter === 'all'
? servers
: servers.filter(server => server.tags.includes(filter));
const allTags = [...new Set(servers.flatMap(server => server.tags))];
return (
<div className="p-4 md:p-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 md:mb-6 gap-4">
<div>
<h1 className="text-xl md:text-2xl font-bold text-white">Управление серверами</h1>
<p className="text-gray-400 text-sm md:text-base">Всего серверов: {servers.length}</p>
</div>
<button
onClick={() => setShowAddForm(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors w-full md:w-auto"
>
+ Добавить сервер
</button>
</div>
{/* Фильтр по тегам */}
{allTags.length > 0 && (
<div className="mb-4 md:mb-6">
<div className="flex flex-wrap gap-2">
<button
onClick={() => setFilter('all')}
className={`px-3 py-1 rounded-full text-sm ${
filter === 'all'
? 'bg-primary-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
Все
</button>
{allTags.map(tag => (
<button
key={tag}
onClick={() => setFilter(tag)}
className={`px-3 py-1 rounded-full text-sm ${
filter === tag
? 'bg-primary-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
{tag}
</button>
))}
</div>
</div>
)}
{showAddForm && (
<div className="bg-gray-800 p-4 md:p-6 rounded-lg shadow-lg mb-4 md:mb-6 border border-gray-700">
<h3 className="text-lg font-semibold mb-4 text-white">Добавить сервер</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-300 mb-1">
Название *
</label>
<input
type="text"
value={newServer.name}
onChange={(e) => setNewServer({...newServer, name: e.target.value})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Мой сервер"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
IP адрес *
</label>
<input
type="text"
value={newServer.ip}
onChange={(e) => setNewServer({...newServer, ip: e.target.value})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="192.168.1.100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Теги (через запятую)
</label>
<input
type="text"
value={newServer.tags.join(', ')}
onChange={(e) => setNewServer({...newServer, tags: e.target.value.split(',').map(tag => tag.trim()).filter(Boolean)})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="production, web, database"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
SSH порт
</label>
<input
type="number"
value={newServer.port}
onChange={(e) => setNewServer({...newServer, port: e.target.value})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="22"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Веб-порт
</label>
<input
type="number"
value={newServer.webPort}
onChange={(e) => setNewServer({...newServer, webPort: e.target.value})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="51821"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Имя пользователя
</label>
<input
type="text"
value={newServer.username}
onChange={(e) => setNewServer({...newServer, username: e.target.value})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="root"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Пароль
</label>
<input
type="password"
value={newServer.password}
onChange={(e) => setNewServer({...newServer, password: e.target.value})}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Пароль"
/>
</div>
</div>
<div className="flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-3 mt-4">
<button
onClick={() => setShowAddForm(false)}
className="px-4 py-2 border border-gray-600 text-gray-300 rounded-lg hover:bg-gray-700 transition-colors w-full md:w-auto"
>
Отмена
</button>
<button
onClick={addServer}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors w-full md:w-auto"
>
Сохранить
</button>
</div>
</div>
)}
{servers.length === 0 ? (
<div className="text-center py-8 md:py-12">
<div className="text-gray-400 text-4xl md:text-6xl mb-4">🖥</div>
<h3 className="text-lg font-medium text-white">Нет серверов</h3>
<p className="text-gray-400 mt-1">Добавьте ваш первый сервер</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
{filteredServers.map(server => (
<div key={server.id} className="bg-gray-800 p-4 md:p-6 rounded-lg shadow-lg border border-gray-700">
<div className="flex justify-between items-start mb-3 md:mb-4">
<div>
<h3 className="font-semibold text-white text-base md:text-lg">{server.name}</h3>
<p className="text-gray-400 text-sm">{server.ip}</p>
</div>
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${getStatusColor(server.status)}`}></div>
<span className="text-xs text-gray-400">{getStatusText(server.status)}</span>
</div>
</div>
{/* Теги сервера */}
{server.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3 md:mb-4">
{server.tags.map(tag => (
<span key={tag} className="px-2 py-1 bg-primary-500 text-white rounded text-xs">
{tag}
</span>
))}
</div>
)}
<div className="space-y-2 text-sm text-gray-300 mb-4">
<div className="flex justify-between">
<span>SSH порт:</span>
<span className="text-white">{server.port}</span>
</div>
<div className="flex justify-between">
<span>Веб-порт:</span>
<span className="text-white">{server.webPort}</span>
</div>
<div className="flex justify-between">
<span>Пользователь:</span>
<span className="text-white">{server.username}</span>
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => checkServerStatus(server)}
className="flex-1 px-2 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors text-sm"
>
Проверить
</button>
<button
onClick={() => connectSSH(server)}
className="flex-1 px-2 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm"
>
SSH
</button>
<button
onClick={() => openWebInterface(server)}
className="flex-1 px-2 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors text-sm"
>
Веб
</button>
</div>
<div className="flex justify-between items-center mt-3 pt-3 border-t border-gray-700">
<button
onClick={() => deleteServer(server.id)}
className="text-gray-400 hover:text-red-500 transition-colors text-sm"
>
Удалить
</button>
<span className="text-xs text-gray-500">
{new Date(server.createdAt).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default ServerList;
+207
View File
@@ -0,0 +1,207 @@
import React, { useState, useEffect } from 'react';
const ServerMonitoring = () => {
const [servers, setServers] = useState([]);
const [monitoringData, setMonitoringData] = useState({});
const [autoRefresh, setAutoRefresh] = useState(true);
useEffect(() => {
loadServers();
}, []);
useEffect(() => {
if (autoRefresh) {
const interval = setInterval(() => {
servers.forEach(server => {
checkServerStatus(server);
});
}, 30000);
return () => clearInterval(interval);
}
}, [autoRefresh, servers]);
const loadServers = async () => {
if (window.electronAPI) {
const result = await window.electronAPI.loadServers();
if (result.success) {
setServers(result.servers);
result.servers.forEach(server => checkServerStatus(server));
}
}
};
const checkServerStatus = async (server) => {
if (!window.electronAPI) return;
// Ping проверка
const pingResult = await window.electronAPI.pingServer(server);
// SNMP мониторинг (если доступен)
let snmpData = null;
try {
const snmpResult = await window.electronAPI.getSnmpData(server, 'public');
if (snmpResult.success) snmpData = snmpResult.data;
} catch (error) {}
// SSH мониторинг (если есть учетные данные)
let sshStats = null;
if (server.username && server.password) {
try {
const sshResult = await window.electronAPI.getSshStats(server);
if (sshResult.success) sshStats = sshResult.stats;
} catch (error) {}
}
setMonitoringData(prev => ({
...prev,
[server.id]: {
ping: pingResult,
snmp: snmpData,
ssh: sshStats,
lastUpdate: new Date()
}
}));
};
const getStatusColor = (online) => {
return online ? 'bg-green-500' : 'bg-red-500';
};
const getUsageColor = (percent) => {
if (percent < 70) return 'bg-green-500';
if (percent < 90) return 'bg-yellow-500';
return 'bg-red-500';
};
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Мониторинг серверов</h1>
<p className="text-gray-600 dark:text-gray-400">Реальное время состояние серверов</p>
</div>
<div className="flex items-center space-x-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Автообновление (30с)</span>
</label>
<button
onClick={loadServers}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Обновить
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{servers.map(server => {
const data = monitoringData[server.id];
const isOnline = data?.ping?.online;
return (
<div key={server.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 border border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-semibold text-lg text-gray-800 dark:text-white">{server.name}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">{server.ip}</p>
</div>
<div className="flex items-center space-x-2">
<div className={`w-3 h-3 rounded-full ${getStatusColor(isOnline)}`}></div>
<span className={`text-sm font-medium ${isOnline ? 'text-green-600' : 'text-red-600'}`}>
{isOnline ? 'Онлайн' : 'Офлайн'}
</span>
</div>
</div>
{data && (
<div className="space-y-4">
{/* Ping информация */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600 dark:text-gray-400">Ping:</span>
<div className="font-semibold text-gray-800 dark:text-white">
{data.ping?.responseTime ? `${data.ping.responseTime}ms` : 'N/A'}
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">Потери:</span>
<div className="font-semibold text-gray-800 dark:text-white">
{data.ping?.packetLoss ? `${data.ping.packetLoss}%` : 'N/A'}
</div>
</div>
</div>
{/* CPU Usage */}
{(data.snmp?.cpuLoad || data.ssh?.cpuLoad) && (
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600 dark:text-gray-400">Загрузка CPU</span>
<span className="font-semibold text-gray-800 dark:text-white">
{Math.round(data.ssh?.cpuLoad || data.snmp?.cpuLoad)}%
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full ${getUsageColor(data.ssh?.cpuLoad || data.snmp?.cpuLoad)}`}
style={{ width: `${Math.min(100, data.ssh?.cpuLoad || data.snmp?.cpuLoad)}%` }}
></div>
</div>
</div>
)}
{/* Memory Usage */}
{(data.ssh?.memoryUsed && data.ssh?.memoryTotal) && (
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600 dark:text-gray-400">Память</span>
<span className="font-semibold text-gray-800 dark:text-white">
{Math.round((data.ssh.memoryUsed / data.ssh.memoryTotal) * 100)}%
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full ${getUsageColor((data.ssh.memoryUsed / data.ssh.memoryTotal) * 100)}`}
style={{ width: `${Math.min(100, (data.ssh.memoryUsed / data.ssh.memoryTotal) * 100)}%` }}
></div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{data.ssh.memoryUsed}MB / {data.ssh.memoryTotal}MB
</div>
</div>
)}
{/* Last update */}
<div className="text-xs text-gray-500 dark:text-gray-400 border-t pt-2">
Обновлено: {data.lastUpdate?.toLocaleTimeString()}
</div>
</div>
)}
{!data && (
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
Загрузка данных...
</div>
)}
</div>
);
})}
</div>
{servers.length === 0 && (
<div className="text-center py-12">
<div className="text-gray-400 text-6xl mb-4">📊</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Нет серверов для мониторинга</h3>
<p className="text-gray-500 dark:text-gray-400 mt-1">Добавьте серверы в разделе "Серверы"</p>
</div>
)}
</div>
);
};
export default ServerMonitoring;
+101
View File
@@ -0,0 +1,101 @@
import React from 'react';
const Settings = () => {
const handleExport = async () => {
if (window.electronAPI) {
const servers = await window.electronAPI.loadServers();
const passwords = await window.electronAPI.loadPasswords();
const templates = await window.electronAPI.loadCommandTemplates();
const data = {
servers: servers.success ? servers.servers : [],
passwords: passwords.success ? passwords.passwords : [],
templates: templates.success ? templates.templates : [],
exportDate: new Date().toISOString()
};
const result = await window.electronAPI.exportData(data);
if (result.success) {
alert('Данные успешно экспортированы!');
} else {
alert(`Ошибка экспорта: ${result.error}`);
}
}
};
const handleImport = async () => {
if (window.electronAPI) {
const result = await window.electronAPI.importData();
if (result.success) {
const data = result.data;
if (data.servers) {
await window.electronAPI.saveServers(data.servers);
}
if (data.passwords) {
await window.electronAPI.savePasswords(data.passwords);
}
if (data.templates) {
await window.electronAPI.saveCommandTemplates(data.templates);
}
alert('Данные успешно импортированы! Перезагрузите приложение.');
} else {
alert(`Ошибка импорта: ${result.error}`);
}
}
};
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Настройки</h1>
<p className="text-gray-400">Управление данными приложения</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-semibold text-white mb-4">Экспорт данных</h3>
<p className="text-gray-400 mb-4">Сохраните все данные в файл для резервного копирования</p>
<button
onClick={handleExport}
className="w-full bg-primary-600 text-white py-2 px-4 rounded-lg hover:bg-primary-700 transition-colors"
>
Экспортировать данные
</button>
</div>
<div className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-semibold text-white mb-4">Импорт данных</h3>
<p className="text-gray-400 mb-4">Восстановите данные из ранее созданного файла</p>
<button
onClick={handleImport}
className="w-full bg-primary-600 text-white py-2 px-4 rounded-lg hover:bg-primary-700 transition-colors"
>
Импортировать данные
</button>
</div>
</div>
<div className="mt-6 bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-semibold text-white mb-4">Информация о приложении</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<span className="text-gray-400">Версия:</span>
<span className="text-white ml-2">2.0</span>
</div>
<div>
<span className="text-gray-400">Платформа:</span>
<span className="text-white ml-2">{navigator.platform}</span>
</div>
<div>
<span className="text-gray-400">Electron:</span>
<span className="text-white ml-2"></span>
</div>
</div>
</div>
</div>
);
};
export default Settings;
+55
View File
@@ -0,0 +1,55 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
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;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Убираем стандартные стили кнопок */
button {
border: none;
background: none;
cursor: pointer;
}
/* Стили для скроллбара */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Темная тема для скроллбара */
.dark ::-webkit-scrollbar-track {
background: #374151;
}
.dark ::-webkit-scrollbar-thumb {
background: #6b7280;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+5
View File
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
+29
View File
@@ -0,0 +1,29 @@
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
"./public/index.html"
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
}
},
},
plugins: [],
}