feat: Удаление устаревших файлов документации и оптимизация структуры проекта. Упрощение кода и улучшение читаемости за счет удаления ненужных компонентов и отчетов, что способствует более эффективному управлению проектом.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s
This commit is contained in:
@@ -1,321 +0,0 @@
|
||||
# Улучшения Router Lists UI - 2025
|
||||
|
||||
## 📋 Обзор
|
||||
|
||||
Данный документ описывает улучшения, внесенные в проект Router Lists UI. Все изменения внедрены постепенно и аккуратно, чтобы не нарушить работу существующего кода.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Реализованные улучшения
|
||||
|
||||
### 1. 🔐 Валидация и Безопасность входных данных
|
||||
|
||||
#### Backend (`backend/lib/validators.js`)
|
||||
Создан новый модуль валидации с функциями:
|
||||
|
||||
- **`isValidIPv4(ip)`** - валидация IPv4 адресов
|
||||
- **`isValidIPv6(ip)`** - валидация IPv6 адресов
|
||||
- **`isValidCIDRv4(cidr)`** - валидация CIDR блоков IPv4
|
||||
- **`isValidCIDRv6(cidr)`** - валидация CIDR блоков IPv6
|
||||
- **`isValidDomain(domain)`** - валидация доменных имен (поддержка IDN)
|
||||
- **`isValidWildcardDomain(domain)`** - валидация wildcard доменов (*.example.com)
|
||||
- **`isValidASN(asn)`** - валидация номеров ASN
|
||||
- **`isValidCommunity(community)`** - валидация BGP Community (форматы N и N:N)
|
||||
- **`isValidGateway(gateway)`** - валидация имен gateway для MikroTik
|
||||
- **`sanitizeString(str)`** - санитизация строк от опасных символов
|
||||
- **`isSafeSQLString(str)`** - проверка на SQL injection паттерны
|
||||
- **`isSafeXSSString(str)`** - проверка на XSS паттерны
|
||||
- **`validateData(data, schema)`** - комплексная валидация по схеме
|
||||
|
||||
#### Улучшенный Rate Limiting (`backend/server.js`)
|
||||
- **Общий лимитер**: 1000 запросов за 15 минут
|
||||
- **Лимитер для записи**: 100 запросов за 5 минут (применен к POST endpoints)
|
||||
- **Лимитер для BGP**: 5 запросов за 1 минуту (применен к `/api/update-bgp/background`)
|
||||
|
||||
#### Интеграция валидации
|
||||
Добавлена серверная валидация для всех POST endpoints:
|
||||
- `/api/domains-new` - валидация доменов и communities
|
||||
- `/api/ip-ranges` - валидация IP/CIDR и communities
|
||||
- `/api/asns` - валидация ASN и communities
|
||||
- `/api/communities` - валидация community значений
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Защита от невалидных данных на уровне сервера
|
||||
- ✅ Предотвращение XSS и injection атак
|
||||
- ✅ Контроль частоты запросов (защита от DDoS)
|
||||
- ✅ Детальные сообщения об ошибках валидации
|
||||
|
||||
---
|
||||
|
||||
### 2. 🏷️ Расширенная работа с Community
|
||||
|
||||
#### Backend
|
||||
|
||||
**Новый endpoint** (`/api/communities/stats`):
|
||||
- Подсчет использования каждого community
|
||||
- Статистика по всем типам данных: домены, IP ranges, ASNs, фильтры
|
||||
- Сортировка по частоте использования
|
||||
|
||||
**Расширенные поля communities**:
|
||||
```javascript
|
||||
{
|
||||
value: '65000:100',
|
||||
name: 'Social Media',
|
||||
description: 'Traffic for social networks',
|
||||
tags: ['video', 'social'],
|
||||
gatewayDefault: 'SWE-HIPHOST',
|
||||
color: 'blue',
|
||||
// Новые поля:
|
||||
category: 'Media',
|
||||
priority: 10,
|
||||
enabled: true
|
||||
}
|
||||
```
|
||||
|
||||
#### Frontend
|
||||
|
||||
**Новый компонент** (`frontend/src/components/CommunityStats.jsx`):
|
||||
- Отображение общей статистики использования
|
||||
- Top-10 самых используемых communities
|
||||
- Группировка по категориям
|
||||
- Визуализация через progress bars
|
||||
- Интерактивные графики
|
||||
|
||||
**Улучшения CommunitiesManager**:
|
||||
- Вкладки "Список" и "Статистика"
|
||||
- Переключение между режимами просмотра
|
||||
- Поддержка новых полей (category, priority, enabled)
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Понимание какие communities используются чаще всего
|
||||
- ✅ Группировка и категоризация для лучшей организации
|
||||
- ✅ Визуальное представление статистики
|
||||
|
||||
---
|
||||
|
||||
### 3. 📊 Улучшенная визуализация данных
|
||||
|
||||
#### Новый компонент (`frontend/src/components/TopNStats.jsx`)
|
||||
|
||||
**Top-5 стран**:
|
||||
- Подсчет серверов по странам
|
||||
- Флаги стран (эмодзи)
|
||||
- Progress bars для визуализации
|
||||
- Выделение лидера (🏆)
|
||||
|
||||
**Top-5 провайдеров**:
|
||||
- Подсчет серверов по провайдерам
|
||||
- Цветовые индикаторы
|
||||
- Progress bars
|
||||
- Выделение лидера
|
||||
|
||||
#### Интеграция в Dashboard
|
||||
- Добавлена секция "Топ статистика"
|
||||
- Отображение после основных метрик
|
||||
- Responsive дизайн
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Быстрый обзор распределения инфраструктуры
|
||||
- ✅ Наглядная визуализация
|
||||
- ✅ Помощь в принятии решений о расширении
|
||||
|
||||
---
|
||||
|
||||
### 4. ⚙️ Улучшенный Filter Manager
|
||||
|
||||
#### Backend (`backend/lib/mikrotik-validator.js`)
|
||||
|
||||
**Валидация синтаксиса MikroTik**:
|
||||
- Проверка сбалансированности фигурных скобок
|
||||
- Валидация if/else конструкций
|
||||
- Проверка команд set gw
|
||||
- Валидация bgp-communities includes
|
||||
- Проверка корректности community форматов
|
||||
- Проверка gateway имен
|
||||
|
||||
**Новый endpoint** (`/api/mikrotik/validate`):
|
||||
```javascript
|
||||
POST /api/mikrotik/validate
|
||||
{
|
||||
"config": "// MikroTik config..."
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"valid": true|false,
|
||||
"errors": [
|
||||
{ "line": 42, "message": "..." }
|
||||
],
|
||||
"warnings": [
|
||||
{ "line": 15, "message": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Проверка синтаксиса перед применением конфигурации
|
||||
- ✅ Раннее обнаружение ошибок
|
||||
- ✅ Детальные сообщения с номерами строк
|
||||
- ✅ Предупреждения о потенциальных проблемах
|
||||
|
||||
---
|
||||
|
||||
## 📁 Структура новых файлов
|
||||
|
||||
```
|
||||
backend/
|
||||
├── lib/
|
||||
│ ├── validators.js ✨ НОВЫЙ: Модуль валидации данных
|
||||
│ └── mikrotik-validator.js ✨ НОВЫЙ: Валидация MikroTik конфигураций
|
||||
|
||||
frontend/
|
||||
└── src/
|
||||
└── components/
|
||||
├── CommunityStats.jsx ✨ НОВЫЙ: Статистика communities
|
||||
└── TopNStats.jsx ✨ НОВЫЙ: Top-N статистика для Dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Измененные файлы
|
||||
|
||||
### Backend
|
||||
- `backend/server.js` - интеграция валидаторов, новые endpoints, улучшенный rate limiting
|
||||
|
||||
### Frontend
|
||||
- `frontend/src/CommunitiesManager.jsx` - добавлены вкладки и интеграция статистики
|
||||
- `frontend/src/Dashboard.jsx` - добавлена Top-N статистика
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Как использовать новые возможности
|
||||
|
||||
### 1. Валидация данных
|
||||
Все POST запросы автоматически валидируются на сервере. В случае ошибок валидации API вернет:
|
||||
```json
|
||||
{
|
||||
"code": "E_VALIDATION",
|
||||
"message": "Ошибки валидации",
|
||||
"details": {
|
||||
"errors": [
|
||||
"Элемент 0: неверный домен \"invalid..domain\"",
|
||||
"Элемент 5: неверный community \"abc\""
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Статистика Communities
|
||||
1. Перейдите в раздел **Данные → Communities**
|
||||
2. Нажмите вкладку **"Статистика использования"**
|
||||
3. Просмотрите:
|
||||
- Общую статистику
|
||||
- Top-10 самых используемых
|
||||
- Распределение по категориям
|
||||
|
||||
### 3. Top-N статистика на Dashboard
|
||||
Откройте главную страницу (Dashboard) - внизу появится секция "Топ статистика" с:
|
||||
- Top-5 стран
|
||||
- Top-5 провайдеров
|
||||
|
||||
### 4. Валидация MikroTik конфигурации
|
||||
Используйте API endpoint для проверки конфигурации перед применением:
|
||||
```javascript
|
||||
const response = await fetch('/api/mikrotik/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: yourConfigString })
|
||||
});
|
||||
const result = await response.json();
|
||||
// result.valid, result.errors, result.warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Достигнутые цели
|
||||
|
||||
### Безопасность
|
||||
- ✅ Серверная валидация всех входных данных
|
||||
- ✅ Защита от XSS и injection атак
|
||||
- ✅ Улучшенный rate limiting с разными лимитами для разных операций
|
||||
|
||||
### Функциональность
|
||||
- ✅ Статистика использования communities
|
||||
- ✅ Расширенные поля для communities (категории, приоритет)
|
||||
- ✅ Top-N статистика на Dashboard
|
||||
- ✅ Валидация синтаксиса MikroTik конфигураций
|
||||
|
||||
### UX/UI
|
||||
- ✅ Вкладки в Communities Manager
|
||||
- ✅ Визуализация статистики через progress bars
|
||||
- ✅ Эмодзи флаги для стран
|
||||
- ✅ Детальные сообщения об ошибках
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Исправления
|
||||
|
||||
### Docker сборка
|
||||
**Проблема**: При первой сборке модули `lib/validators.js` и `lib/mikrotik-validator.js` не копировались в Docker контейнер.
|
||||
|
||||
**Решение**: Обновлен `Dockerfile.fast` - добавлена строка:
|
||||
```dockerfile
|
||||
COPY backend/lib ./lib
|
||||
```
|
||||
|
||||
Теперь папка `lib` с модулями валидации корректно копируется в контейнер.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Обратная совместимость
|
||||
|
||||
Все изменения **обратно совместимы**:
|
||||
- ✅ Существующий код продолжает работать без изменений
|
||||
- ✅ Новые поля в communities опциональны
|
||||
- ✅ Валидация не блокирует существующие данные
|
||||
- ✅ Rate limiting имеет достаточно высокие лимиты для нормальной работы
|
||||
- ✅ Dockerfile обновлен для корректной работы в Docker
|
||||
|
||||
---
|
||||
|
||||
## 📝 Рекомендации по дальнейшему использованию
|
||||
|
||||
1. **Мониторинг rate limiting** - если пользователи сталкиваются с 429 ошибками, увеличьте лимиты
|
||||
2. **Наполнение категорий** - добавьте категории к существующим communities для лучшей организации
|
||||
3. **Использование валидации** - интегрируйте `/api/mikrotik/validate` в Filter Manager UI
|
||||
4. **Анализ статистики** - регулярно проверяйте статистику communities для оптимизации
|
||||
|
||||
---
|
||||
|
||||
## 🔜 Идеи для будущих улучшений
|
||||
|
||||
1. **Шаблоны фильтров** - сохранение и быстрое применение готовых наборов фильтров
|
||||
2. **Автоматическое тестирование** - unit и integration тесты
|
||||
3. **Графики динамики** - отображение изменений данных во времени
|
||||
4. **Экспорт отчетов** - генерация PDF/Excel отчетов со статистикой
|
||||
5. **WebSocket real-time** - обновление данных в реальном времени
|
||||
|
||||
---
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
При возникновении проблем проверьте:
|
||||
1. Логи backend (`console.log` и Pino логи)
|
||||
2. Network вкладку в DevTools браузера
|
||||
3. Prometheus метрики (`/metrics`)
|
||||
|
||||
Все новые endpoints логируются с помощью Pino и имеют requestId для трассировки.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Итого
|
||||
|
||||
**Добавлено файлов**: 4
|
||||
**Изменено файлов**: 4
|
||||
**Новых API endpoints**: 2
|
||||
**Новых компонентов**: 2
|
||||
**Улучшений безопасности**: ✅ Существенные
|
||||
**Совместимость**: ✅ Полная
|
||||
|
||||
Все изменения протестированы и готовы к использованию! 🎉
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
# Отчет об оптимизации кода проекта Router Lists UI
|
||||
|
||||
Дата: 2 октября 2025
|
||||
Статус: Завершено
|
||||
|
||||
## 🎯 Цель оптимизации
|
||||
|
||||
Упростить и оптимизировать кодовую базу проекта без нарушения функциональности.
|
||||
|
||||
## 📊 Что было сделано
|
||||
|
||||
### 1. ✅ Frontend - Исправление дублирования QueryClient
|
||||
|
||||
**Проблема:** QueryClient создавался дважды (в `main.jsx` и `App.jsx`), что приводило к избыточности и потенциальным проблемам с кэшированием.
|
||||
|
||||
**Решение:**
|
||||
- Удален дублирующий экземпляр из `main.jsx`
|
||||
- Оставлен единственный экземпляр в `App.jsx` с правильной конфигурацией
|
||||
- Упрощены импорты
|
||||
|
||||
**Файлы:**
|
||||
- `frontend/src/main.jsx` - убран QueryClient и QueryClientProvider
|
||||
- `frontend/src/App.jsx` - объединены импорты, создан единый QueryClient
|
||||
|
||||
**Результат:** Уменьшено дублирование кода, улучшена производительность
|
||||
|
||||
---
|
||||
|
||||
### 2. ✅ Backend - Объединение дублирующихся валидаторов
|
||||
|
||||
**Проблема:** Функции `isValidCommunity` и `isValidGateway` были продублированы в двух файлах:
|
||||
- `backend/lib/validators.js`
|
||||
- `backend/lib/mikrotik-validator.js`
|
||||
|
||||
**Решение:**
|
||||
- Удалены дублирующие функции из `mikrotik-validator.js`
|
||||
- Добавлен импорт из `validators.js` для переиспользования
|
||||
|
||||
**Файлы:**
|
||||
- `backend/lib/mikrotik-validator.js` - убраны дублирующие валидаторы
|
||||
- `backend/lib/validators.js` - основной источник валидаторов
|
||||
|
||||
**Результат:** Устранено дублирование ~50 строк кода
|
||||
|
||||
---
|
||||
|
||||
### 3. ✅ Backend - Создание общего S3 сервиса
|
||||
|
||||
**Проблема:** Логика работы с S3 была размазана по всему `server.js` (~2486 строк), множество повторяющихся операций.
|
||||
|
||||
**Решение:**
|
||||
Создан централизованный сервис `backend/services/s3Service.js` со следующими функциями:
|
||||
|
||||
```javascript
|
||||
// Основные операции
|
||||
- readS3TextObject() // Чтение текстовых файлов с кэшированием
|
||||
- writeS3TextObject() // Запись текстовых файлов
|
||||
- writeS3JsonObject() // Запись JSON файлов
|
||||
- deleteS3Object() // Удаление объектов
|
||||
- headS3ObjectEtag() // Получение ETag
|
||||
- headMeta() // Получение метаданных
|
||||
- streamPaginatedText() // Потоковое чтение с пагинацией
|
||||
- invalidateCacheForKey() // Инвалидация кэша
|
||||
|
||||
// Вспомогательные
|
||||
- streamToString() // Конвертация stream в string
|
||||
- getCache() / setCache() // Работа с кэшем
|
||||
```
|
||||
|
||||
**Файлы:**
|
||||
- `backend/services/s3Service.js` - новый централизованный сервис
|
||||
|
||||
**Результат:**
|
||||
- Переиспользуемая логика S3
|
||||
- Централизованное кэширование
|
||||
- Упрощение основного кода
|
||||
- ~300 строк вынесено в отдельный модуль
|
||||
|
||||
---
|
||||
|
||||
### 4. ✅ Backend - Вынос middleware в отдельные файлы
|
||||
|
||||
**Проблема:** Вся логика middleware, обработки ошибок и блокировок была в `server.js`.
|
||||
|
||||
**Решение:**
|
||||
Созданы отдельные модули:
|
||||
|
||||
#### `backend/middleware/errorHandler.js`
|
||||
```javascript
|
||||
- sendOk() // Отправка успешного ответа
|
||||
- sendError() // Отправка ошибки
|
||||
- checkIfNoneMatch() // Проверка ETag
|
||||
- errorHandler() // Центральный обработчик ошибок
|
||||
```
|
||||
|
||||
#### `backend/middleware/lockManager.js`
|
||||
```javascript
|
||||
- getLockStatus() // Получить статус блокировки
|
||||
- acquireLock() // Получить/обновить блокировку
|
||||
- releaseLock() // Освободить блокировку
|
||||
- cleanupExpiredLocks() // Очистка истекших блокировок
|
||||
```
|
||||
|
||||
#### `backend/utils/helpers.js`
|
||||
```javascript
|
||||
- toIso() // Конвертация даты
|
||||
- splitWhitespace() // Разбиение строки
|
||||
- sha256OfString() // SHA256 хэш
|
||||
- mapAjvErrors() // Форматирование ошибок AJV
|
||||
- resourceToKey() // Маппинг ресурсов на S3 ключи
|
||||
- buildNestedGatewayBlocks() // Генерация MikroTik конфигурации
|
||||
```
|
||||
|
||||
**Файлы:**
|
||||
- `backend/middleware/errorHandler.js`
|
||||
- `backend/middleware/lockManager.js`
|
||||
- `backend/utils/helpers.js`
|
||||
|
||||
**Результат:** ~200 строк вынесено в переиспользуемые модули
|
||||
|
||||
---
|
||||
|
||||
### 5. ✅ Backend - Создание общих роутов для однотипных эндпоинтов
|
||||
|
||||
**Проблема:** Дублирование логики для похожих эндпоинтов (domains, asns, ip-ranges, servers, filters, billing).
|
||||
|
||||
**Решение:**
|
||||
Созданы фабрики роутов:
|
||||
|
||||
#### `backend/routes/textDataRoutes.js`
|
||||
Для текстовых данных (domains, asns, ip-ranges):
|
||||
```javascript
|
||||
- createTextDataGET() // Общий GET эндпоинт
|
||||
- createTextDataPOST() // Общий POST эндпоинт
|
||||
- createTextDataRoutes() // Фабрика роутов
|
||||
```
|
||||
|
||||
Поддерживает:
|
||||
- Пагинацию (`offset`, `limit`)
|
||||
- Поиск (`q=`)
|
||||
- Подсчет (`countOnly=true`)
|
||||
- Кэширование
|
||||
- Валидацию ETag
|
||||
- Потоковое чтение больших файлов
|
||||
|
||||
#### `backend/routes/jsonDataRoutes.js`
|
||||
Для JSON данных (servers, filters, billing):
|
||||
```javascript
|
||||
- createJsonDataGET() // Общий GET эндпоинт
|
||||
- createJsonDataPOST() // Общий POST эндпоинт
|
||||
- createJsonDataRoutes() // Фабрика роутов
|
||||
```
|
||||
|
||||
#### `backend/routes/communitiesRoutes.js`
|
||||
Специализированные роуты для communities:
|
||||
```javascript
|
||||
- getCommunities() // GET /api/communities
|
||||
- postCommunities() // POST /api/communities
|
||||
- getCommunityStats() // GET /api/communities/stats
|
||||
```
|
||||
|
||||
**Использование:**
|
||||
```javascript
|
||||
const domainsRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/domains_community.txt',
|
||||
mapLine: (line) => { /* парсинг */ },
|
||||
formatLine: (obj) => { /* форматирование */ },
|
||||
validate: validateSchema,
|
||||
validateItem: validateItemFunc,
|
||||
cachePrefix: 'domains-new'
|
||||
});
|
||||
|
||||
app.get('/api/domains-new', domainsRoutes.get);
|
||||
app.post('/api/domains-new', domainsRoutes.post);
|
||||
```
|
||||
|
||||
**Файлы:**
|
||||
- `backend/routes/textDataRoutes.js`
|
||||
- `backend/routes/jsonDataRoutes.js`
|
||||
- `backend/routes/communitiesRoutes.js`
|
||||
|
||||
**Результат:**
|
||||
- ~400 строк дублирующего кода заменено на переиспользуемые фабрики
|
||||
- Упрощение добавления новых эндпоинтов
|
||||
- Единообразие в обработке данных
|
||||
|
||||
---
|
||||
|
||||
## 📈 Итоговые улучшения
|
||||
|
||||
### Метрики
|
||||
|
||||
| Метрика | До | После | Улучшение |
|
||||
|---------|----|----|-----------|
|
||||
| Размер server.js | 2486 строк | ~1500 строк (после полного рефакторинга) | -40% |
|
||||
| Дублирование кода | Высокое | Минимальное | -70% |
|
||||
| Модульность | Низкая (1 файл) | Высокая (10+ модулей) | +900% |
|
||||
| Переиспользуемость | 10% | 80% | +700% |
|
||||
| Валидаторы | 2 копии | 1 источник истины | -50% |
|
||||
| QueryClient (frontend) | 2 экземпляра | 1 экземпляр | -50% |
|
||||
|
||||
### Качественные улучшения
|
||||
|
||||
✅ **Читаемость кода**
|
||||
- Каждый модуль имеет четкую ответственность
|
||||
- Логика разделена по слоям (routes/services/middleware)
|
||||
- Комментарии и JSDoc документация
|
||||
|
||||
✅ **Поддерживаемость**
|
||||
- Легко найти нужную логику
|
||||
- Изменения в одном месте вместо нескольких
|
||||
- Упрощенное тестирование
|
||||
|
||||
✅ **Масштабируемость**
|
||||
- Простое добавление новых эндпоинтов через фабрики
|
||||
- Централизованная логика S3 и кэширования
|
||||
- Модульная структура
|
||||
|
||||
✅ **Производительность**
|
||||
- Единый QueryClient на фронтенде
|
||||
- Централизованное кэширование S3 операций
|
||||
- Оптимизированное потоковое чтение
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Новая структура проекта
|
||||
|
||||
```
|
||||
backend/
|
||||
├── lib/
|
||||
│ ├── validators.js ✨ Оптимизировано (убраны дубликаты)
|
||||
│ └── mikrotik-validator.js ✨ Оптимизировано (использует validators.js)
|
||||
├── middleware/
|
||||
│ ├── errorHandler.js 🆕 Новый модуль
|
||||
│ └── lockManager.js 🆕 Новый модуль
|
||||
├── routes/
|
||||
│ ├── textDataRoutes.js 🆕 Фабрика роутов для текстовых данных
|
||||
│ ├── jsonDataRoutes.js 🆕 Фабрика роутов для JSON данных
|
||||
│ └── communitiesRoutes.js 🆕 Специализированные роуты
|
||||
├── services/
|
||||
│ └── s3Service.js 🆕 Централизованный S3 сервис
|
||||
├── utils/
|
||||
│ └── helpers.js 🆕 Вспомогательные утилиты
|
||||
└── server.js ⏳ Готов к рефакторингу (использует новые модули)
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── main.jsx ✨ Оптимизировано (убран дублирующий QueryClient)
|
||||
│ └── App.jsx ✨ Оптимизировано (единый QueryClient, упрощены импорты)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Дальнейшие рекомендации
|
||||
|
||||
### Краткосрочные (можно сделать сразу)
|
||||
1. **Переписать server.js** - использовать созданные модули для всех эндпоинтов
|
||||
2. **Добавить unit-тесты** для новых модулей
|
||||
3. **Создать роуты для остальных эндпоинтов** (filters, auto-urls, billing)
|
||||
|
||||
### Среднесрочные
|
||||
1. **Добавить TypeScript** для лучшей типизации
|
||||
2. **Настроить ESLint** с правилами для обнаружения дублирования
|
||||
3. **Создать документацию API** на базе новых модулей
|
||||
|
||||
### Долгосрочные
|
||||
1. **Добавить интеграционные тесты**
|
||||
2. **Настроить CI/CD** с проверкой качества кода
|
||||
3. **Рассмотреть переход на более современную архитектуру** (например, NestJS)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Проверка работоспособности
|
||||
|
||||
Все изменения **обратно совместимы**. API остался без изменений:
|
||||
|
||||
- ✅ Frontend работает как раньше
|
||||
- ✅ Все эндпоинты доступны
|
||||
- ✅ Валидация работает
|
||||
- ✅ S3 операции работают
|
||||
- ✅ Кэширование работает
|
||||
- ✅ Блокировки работают
|
||||
|
||||
---
|
||||
|
||||
## 📝 Заключение
|
||||
|
||||
Выполнена успешная оптимизация кодовой базы с **нулевым breaking change**. Код стал:
|
||||
- 🎯 **Проще** - меньше дублирования
|
||||
- 🔧 **Удобнее** - модульная структура
|
||||
- 🚀 **Быстрее** - оптимизированное кэширование
|
||||
- 📚 **Понятнее** - четкое разделение ответственности
|
||||
|
||||
Все изменения готовы к продакшену и могут быть развернуты немедленно.
|
||||
|
||||
-2309
File diff suppressed because it is too large
Load Diff
@@ -1,531 +0,0 @@
|
||||
# 🎨 UX/UI Улучшения Router Lists UI - 2025
|
||||
|
||||
## 📋 Обзор
|
||||
|
||||
Данный документ описывает комплексные UX/UI улучшения, внесенные в проект Router Lists UI. Все компоненты разработаны **исключительно на стилях Tabler UI** без использования сторонних UI библиотек.
|
||||
|
||||
---
|
||||
|
||||
## ✅ РЕАЛИЗОВАННЫЕ УЛУЧШЕНИЯ
|
||||
|
||||
### 🎯 ВЫСОКИЙ ПРИОРИТЕТ
|
||||
|
||||
#### 1. Оптимизация навигации и информационной архитектуры
|
||||
|
||||
##### 📍 Улучшенный компонент Breadcrumbs
|
||||
**Файл**: `frontend/src/components/Breadcrumbs.jsx`
|
||||
|
||||
**Новые возможности**:
|
||||
- ✅ Кнопка "Назад" с сохранением состояния навигации
|
||||
- ✅ Динамические счетчики результатов в breadcrumbs
|
||||
- ✅ Отображение информации об активных фильтрах
|
||||
- ✅ Автоматический подсчет записей с правильными склонениями
|
||||
|
||||
**Использование**:
|
||||
```jsx
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Главная', to: '/dashboard' },
|
||||
{ label: 'Домены', count: 1250 }
|
||||
]}
|
||||
showBack={true}
|
||||
filterInfo="Community: Cloudflare"
|
||||
count={1250}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. Улучшения таблиц и списков данных
|
||||
|
||||
##### 🔖 SavedFilters - Сохраненные фильтры
|
||||
**Файл**: `frontend/src/components/SavedFilters.jsx`
|
||||
|
||||
**Возможности**:
|
||||
- ✅ Сохранение комбинаций фильтров в localStorage
|
||||
- ✅ Быстрое применение сохраненных фильтров
|
||||
- ✅ Удаление неактуальных фильтров
|
||||
- ✅ Отображение даты создания фильтра
|
||||
|
||||
**Использование**:
|
||||
```jsx
|
||||
<SavedFilters
|
||||
pageKey="domains" // уникальный ключ страницы
|
||||
currentFilters={{ searchTerm, filterCommunity, sortField }}
|
||||
onApplyFilter={(filters) => {
|
||||
setSearchTerm(filters.searchTerm)
|
||||
setFilterCommunity(filters.filterCommunity)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
##### ☑️ BulkActionsBar - Массовые операции
|
||||
**Файл**: `frontend/src/components/BulkActionsBar.jsx`
|
||||
|
||||
**Возможности**:
|
||||
- ✅ Выбор множества элементов через чекбоксы
|
||||
- ✅ Кнопка "Выбрать все" / "Снять выбор"
|
||||
- ✅ Массовое удаление, редактирование, экспорт
|
||||
- ✅ Sticky позиционирование при скролле
|
||||
- ✅ Кастомные действия через props
|
||||
|
||||
**Использование**:
|
||||
```jsx
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedItems.size}
|
||||
totalCount={totalItems}
|
||||
onSelectAll={() => selectAll()}
|
||||
onDeselectAll={() => clearSelection()}
|
||||
onDelete={() => handleBulkDelete()}
|
||||
onExport={() => handleBulkExport()}
|
||||
customActions={[
|
||||
{
|
||||
icon: IconEdit,
|
||||
label: 'Изменить community',
|
||||
onClick: handleBulkEditCommunity
|
||||
}
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. Формы и валидация
|
||||
|
||||
##### ✓ ValidatedInput - Input с inline валидацией
|
||||
**Файл**: `frontend/src/components/ValidatedInput.jsx`
|
||||
|
||||
**Возможности**:
|
||||
- ✅ Real-time валидация с debounce
|
||||
- ✅ Визуальные индикаторы (галочка/крестик)
|
||||
- ✅ Подсказки при ошибках
|
||||
- ✅ Поддержка required полей
|
||||
- ✅ Автофокус
|
||||
|
||||
**Использование**:
|
||||
```jsx
|
||||
<ValidatedInput
|
||||
label="IP адрес"
|
||||
value={ip}
|
||||
onChange={setIp}
|
||||
onValidate={(value) => {
|
||||
const valid = /^(\d{1,3}\.){3}\d{1,3}$/.test(value)
|
||||
return {
|
||||
valid,
|
||||
message: valid
|
||||
? 'IP адрес корректен'
|
||||
: 'Введите IPv4 (например: 1.1.1.1)'
|
||||
}
|
||||
}}
|
||||
helpText="Введите IPv4 адрес"
|
||||
required
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 4. Feedback и уведомления
|
||||
|
||||
##### 🔔 ToastContainer - Система toast уведомлений
|
||||
**Файлы**:
|
||||
- `frontend/src/components/ToastContainer.jsx`
|
||||
- Интеграция в `frontend/src/App.jsx`
|
||||
|
||||
**Возможности**:
|
||||
- ✅ 4 типа уведомлений: success, error, warning, info
|
||||
- ✅ Автозакрытие с настраиваемой длительностью
|
||||
- ✅ Действия в уведомлениях (кнопки "Отменить", "Повторить")
|
||||
- ✅ Анимированное появление/исчезновение
|
||||
- ✅ Глобальный доступ через `window.toast`
|
||||
|
||||
**Использование**:
|
||||
```jsx
|
||||
// Через hook
|
||||
const toast = useToast()
|
||||
toast.success('Данные сохранены')
|
||||
toast.error('Ошибка соединения', { duration: 0 }) // не закроется автоматически
|
||||
toast.warning('Проверьте данные', { duration: 5000 })
|
||||
toast.info('Обновление доступно', {
|
||||
actions: [
|
||||
{ label: 'Обновить', onClick: () => window.location.reload() }
|
||||
]
|
||||
})
|
||||
|
||||
// Через window (обратная совместимость)
|
||||
window.toast.success('Успех!')
|
||||
```
|
||||
|
||||
##### ⏳ ProgressIndicator - Индикаторы прогресса
|
||||
**Файл**: `frontend/src/components/ProgressIndicator.jsx`
|
||||
|
||||
**Компоненты**:
|
||||
1. **ProgressIndicator** - полный индикатор с кнопкой отмены
|
||||
2. **LinearProgress** - компактный линейный
|
||||
3. **CircularProgress** - кольцевой индикатор
|
||||
|
||||
**Использование**:
|
||||
```jsx
|
||||
// Полный индикатор
|
||||
<ProgressIndicator
|
||||
current={45}
|
||||
total={100}
|
||||
message="Обработка доменов..."
|
||||
variant="blue"
|
||||
onCancel={() => cancelOperation()}
|
||||
/>
|
||||
|
||||
// Линейный
|
||||
<LinearProgress
|
||||
progress={75}
|
||||
message="Загрузка..."
|
||||
variant="green"
|
||||
/>
|
||||
|
||||
// Кольцевой
|
||||
<CircularProgress
|
||||
progress={60}
|
||||
size={64}
|
||||
variant="purple"
|
||||
/>
|
||||
```
|
||||
|
||||
##### 💀 Skeleton Loaders - Улучшенные загрузчики
|
||||
**Файл**: `frontend/src/components/TableSkeleton.jsx`
|
||||
|
||||
**Компоненты**:
|
||||
1. **TableSkeleton** - для таблиц (с поддержкой чекбоксов)
|
||||
2. **CardSkeleton** - для карточек статистики
|
||||
3. **ListSkeleton** - для списков
|
||||
4. **FormSkeleton** - для форм
|
||||
|
||||
**Использование**:
|
||||
```jsx
|
||||
import TableSkeleton, { CardSkeleton, ListSkeleton, FormSkeleton } from './components/TableSkeleton'
|
||||
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={5} hasCheckbox={true} />
|
||||
) : (
|
||||
<table>...</table>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5. Keyboard Shortcuts и Command Palette
|
||||
|
||||
##### ⌨️ CommandPalette - Глобальный поиск (Ctrl+K)
|
||||
**Файлы**:
|
||||
- `frontend/src/components/CommandPalette.jsx`
|
||||
- Интеграция в `frontend/src/App.jsx`
|
||||
|
||||
**Возможности**:
|
||||
- ✅ Открытие по Ctrl+K / Cmd+K
|
||||
- ✅ Навигация стрелками ↑↓
|
||||
- ✅ Фильтрация команд по keywords
|
||||
- ✅ Быстрый переход по страницам
|
||||
- ✅ Подсветка активной команды
|
||||
|
||||
**Горячие клавиши**:
|
||||
- `Ctrl+K` - Открыть command palette
|
||||
- `↑` `↓` - Навигация по списку
|
||||
- `Enter` - Выполнить команду
|
||||
- `Esc` - Закрыть
|
||||
|
||||
##### 📋 KeyboardShortcutsButton - Справка по горячим клавишам
|
||||
**Использование**: Добавлена кнопка в навбар
|
||||
|
||||
**Список горячих клавиш**:
|
||||
- `Ctrl+K` - Поиск команд
|
||||
- `Ctrl+N` - Добавить новую запись
|
||||
- `Ctrl+S` - Сохранить
|
||||
- `Ctrl+F` - Поиск по таблице
|
||||
- `Esc` - Закрыть / Отменить
|
||||
- `↑` `↓` - Навигация
|
||||
- `Enter` - Подтвердить
|
||||
|
||||
---
|
||||
|
||||
### 🎨 СРЕДНИЙ ПРИОРИТЕТ
|
||||
|
||||
#### 6. Визуальная иерархия
|
||||
|
||||
##### 📊 Sparkline - Мини-графики
|
||||
**Файл**: `frontend/src/components/Sparkline.jsx`
|
||||
|
||||
**Компоненты**:
|
||||
1. **Sparkline** - линейный график
|
||||
2. **SparklineWithTrend** - с процентом изменения
|
||||
3. **SparklineBar** - столбчатый график
|
||||
|
||||
**Возможности**:
|
||||
- ✅ Чистый SVG без библиотек
|
||||
- ✅ Настраиваемые цвета и размеры
|
||||
- ✅ Опциональная заливка под графиком
|
||||
- ✅ Отображение точек на графике
|
||||
|
||||
**Использование**:
|
||||
```jsx
|
||||
// Простой sparkline
|
||||
<Sparkline
|
||||
data={[10, 20, 15, 30, 25, 40]}
|
||||
width={100}
|
||||
height={30}
|
||||
strokeColor="#206bc4"
|
||||
fillColor="#206bc4"
|
||||
/>
|
||||
|
||||
// С трендом
|
||||
<SparklineWithTrend
|
||||
data={[100, 105, 110, 120]}
|
||||
label="за 7 дней"
|
||||
showPercentage={true}
|
||||
/>
|
||||
|
||||
// Столбчатый
|
||||
<SparklineBar
|
||||
data={[5, 8, 3, 12, 7]}
|
||||
width={100}
|
||||
height={30}
|
||||
barColor="#2fb344"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 КАК ИСПОЛЬЗОВАТЬ
|
||||
|
||||
### Интеграция в существующие страницы
|
||||
|
||||
#### Пример: DomainsNewManager с новыми компонентами
|
||||
|
||||
```jsx
|
||||
import { useState } from 'react'
|
||||
import Breadcrumbs from './components/Breadcrumbs'
|
||||
import SavedFilters from './components/SavedFilters'
|
||||
import BulkActionsBar from './components/BulkActionsBar'
|
||||
import ValidatedInput from './components/ValidatedInput'
|
||||
import TableSkeleton from './components/TableSkeleton'
|
||||
import { useToast } from './components/ToastContainer'
|
||||
|
||||
function DomainsNewManager() {
|
||||
const [selectedItems, setSelectedItems] = useState(new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filters, setFilters] = useState({ searchTerm: '', community: '' })
|
||||
const toast = useToast()
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.delete('/domains-new/bulk', { ids: Array.from(selectedItems) })
|
||||
toast.success(`Удалено ${selectedItems.size} доменов`)
|
||||
setSelectedItems(new Set())
|
||||
} catch (e) {
|
||||
toast.error('Ошибка удаления', { duration: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumbs с кнопкой назад */}
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Главная', to: '/dashboard' },
|
||||
{ label: 'Данные' },
|
||||
{ label: 'Домены', count: items.length }
|
||||
]}
|
||||
showBack={true}
|
||||
filterInfo={filters.community ? `Community: ${filters.community}` : null}
|
||||
/>
|
||||
|
||||
{/* Сохраненные фильтры */}
|
||||
<div className="mb-3">
|
||||
<SavedFilters
|
||||
pageKey="domains"
|
||||
currentFilters={filters}
|
||||
onApplyFilter={setFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions */}
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedItems.size}
|
||||
totalCount={items.length}
|
||||
onSelectAll={() => setSelectedItems(new Set(items.map(i => i.id)))}
|
||||
onDeselectAll={() => setSelectedItems(new Set())}
|
||||
onDelete={handleBulkDelete}
|
||||
onExport={handleBulkExport}
|
||||
/>
|
||||
|
||||
{/* Таблица с skeleton loader */}
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={3} hasCheckbox={true} />
|
||||
) : (
|
||||
<table className="table">
|
||||
{/* таблица */}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 ДИЗАЙН-СИСТЕМА
|
||||
|
||||
### Цветовая палитра Tabler
|
||||
|
||||
```scss
|
||||
// Основные цвета
|
||||
$blue: #206bc4; // primary
|
||||
$azure: #4299e1; // info
|
||||
$indigo: #4263eb;
|
||||
$purple: #ae3ec9;
|
||||
$pink: #d6336c;
|
||||
$red: #d63939; // danger
|
||||
$orange: #f76707; // warning
|
||||
$yellow: #f59f00;
|
||||
$lime: #74b816;
|
||||
$green: #2fb344; // success
|
||||
$teal: #0ca678;
|
||||
$cyan: #17a2b8;
|
||||
|
||||
// Semantic colors
|
||||
$primary: $blue;
|
||||
$success: $green;
|
||||
$warning: $orange;
|
||||
$danger: $red;
|
||||
$info: $azure;
|
||||
```
|
||||
|
||||
### Использование badges
|
||||
|
||||
```jsx
|
||||
// Успех
|
||||
<span className="badge bg-green-lt text-green">Активен</span>
|
||||
|
||||
// Ошибка
|
||||
<span className="badge bg-red-lt text-red">Офлайн</span>
|
||||
|
||||
// Предупреждение
|
||||
<span className="badge bg-yellow-lt text-yellow">Внимание</span>
|
||||
|
||||
// Информация
|
||||
<span className="badge bg-blue-lt text-blue">123</span>
|
||||
```
|
||||
|
||||
### Иконки (Tabler Icons)
|
||||
|
||||
Все иконки из `@tabler/icons-react`:
|
||||
|
||||
```jsx
|
||||
import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconAlertTriangle,
|
||||
IconWorld,
|
||||
IconServer,
|
||||
IconFilter
|
||||
} from '@tabler/icons-react'
|
||||
|
||||
<IconCheck size={20} className="text-success" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 СТАТИСТИКА УЛУЧШЕНИЙ
|
||||
|
||||
### Созданные компоненты
|
||||
|
||||
| Компонент | Файл | Строк кода | Статус |
|
||||
|-----------|------|------------|--------|
|
||||
| Breadcrumbs | `Breadcrumbs.jsx` | 82 | ✅ |
|
||||
| SavedFilters | `SavedFilters.jsx` | 170 | ✅ |
|
||||
| BulkActionsBar | `BulkActionsBar.jsx` | 110 | ✅ |
|
||||
| ToastContainer | `ToastContainer.jsx` | 160 | ✅ |
|
||||
| ValidatedInput | `ValidatedInput.jsx` | 120 | ✅ |
|
||||
| ProgressIndicator | `ProgressIndicator.jsx` | 180 | ✅ |
|
||||
| Sparkline | `Sparkline.jsx` | 150 | ✅ |
|
||||
| CommandPalette | `CommandPalette.jsx` | 250 | ✅ |
|
||||
| **Итого** | **8 компонентов** | **~1222 строки** | **100%** |
|
||||
|
||||
### Улучшенные компоненты
|
||||
|
||||
- `TableSkeleton.jsx` - добавлены CardSkeleton, ListSkeleton, FormSkeleton (+70 строк)
|
||||
- `App.jsx` - интеграция ToastContainer и CommandPalette
|
||||
|
||||
---
|
||||
|
||||
## 🎯 СЛЕДУЮЩИЕ ШАГИ (pending)
|
||||
|
||||
### 7. Графики и визуализация данных
|
||||
- [ ] Timeline chart для Dashboard (динамика за месяц)
|
||||
- [ ] Pie chart для распределения по community
|
||||
- [ ] Heatmap активности
|
||||
- [ ] Улучшенный GraphView с zoom controls
|
||||
|
||||
### 8. Мобильная адаптивность
|
||||
- [ ] Card view для таблиц на мобильных
|
||||
- [ ] Swipe actions (свайп для удаления/редактирования)
|
||||
- [ ] Bottom navigation для мобилок
|
||||
- [ ] Адаптивные модалки (bottom sheet)
|
||||
|
||||
---
|
||||
|
||||
## 💡 РЕКОМЕНДАЦИИ ПО ИСПОЛЬЗОВАНИЮ
|
||||
|
||||
### 1. Замена старых alert'ов на Toast
|
||||
```jsx
|
||||
// Было
|
||||
setSuccess('Данные сохранены')
|
||||
setError('Ошибка')
|
||||
|
||||
// Стало
|
||||
toast.success('Данные сохранены')
|
||||
toast.error('Ошибка')
|
||||
```
|
||||
|
||||
### 2. Добавление bulk operations в таблицы
|
||||
1. Добавить состояние `const [selected, setSelected] = useState(new Set())`
|
||||
2. Добавить чекбоксы в `<th>` и `<td>`
|
||||
3. Добавить `<BulkActionsBar />` над таблицей
|
||||
|
||||
### 3. Использование ValidatedInput вместо обычного input
|
||||
Заменить все критичные input'ы (IP, email, домены) на `<ValidatedInput />`
|
||||
|
||||
### 4. Skeleton loaders вместо спиннеров
|
||||
```jsx
|
||||
// Вместо
|
||||
{loading && <div className="spinner-border" />}
|
||||
|
||||
// Использовать
|
||||
{loading ? <TableSkeleton /> : <Table />}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 ИЗВЕСТНЫЕ ОГРАНИЧЕНИЯ
|
||||
|
||||
1. **SavedFilters** хранит данные в localStorage (ограничение 5-10MB)
|
||||
2. **BulkActionsBar** sticky позиция может конфликтовать с другими sticky элементами
|
||||
3. **CommandPalette** не поддерживает вложенные команды
|
||||
4. **Sparkline** не имеет интерактивности (нет hover tooltips)
|
||||
|
||||
---
|
||||
|
||||
## 📝 ЗАКЛЮЧЕНИЕ
|
||||
|
||||
Все компоненты разработаны с использованием **чистого Tabler UI** без сторонних библиотек. Компоненты:
|
||||
- ✅ Полностью адаптивны
|
||||
- ✅ Поддерживают темную тему
|
||||
- ✅ Доступны (ARIA labels, keyboard navigation)
|
||||
- ✅ Легковесны (нет зависимостей)
|
||||
- ✅ Расширяемы через props
|
||||
|
||||
**Итого внедрено**: 8 новых компонентов, улучшено 3 существующих, добавлено ~1300 строк кода.
|
||||
|
||||
---
|
||||
|
||||
**Версия документа**: 1.0
|
||||
**Дата**: 2 октября 2025
|
||||
**Автор**: AI Assistant (Claude Sonnet 4.5)
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
// Оптимизированная версия server.js с модульной структурой
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const compression = require('compression');
|
||||
const Ajv = require('ajv');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const pino = require('pino');
|
||||
const pinoHttp = require('pino-http');
|
||||
const promClient = require('prom-client');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Импорт модулей
|
||||
const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler');
|
||||
const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager');
|
||||
const { createTextDataRoutes } = require('./routes/textDataRoutes');
|
||||
const { createJsonDataRoutes } = require('./routes/jsonDataRoutes');
|
||||
const { splitWhitespace, resourceToKey, toIso, buildNestedGatewayBlocks } = require('./utils/helpers');
|
||||
const validators = require('./lib/validators');
|
||||
const mikrotikValidator = require('./lib/mikrotik-validator');
|
||||
const s3Service = require('./services/s3Service');
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT) || 3001;
|
||||
|
||||
// === Logger ===
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||
app.use(pinoHttp({
|
||||
logger,
|
||||
genReqId: (req) => req.headers['x-request-id'] || crypto.randomBytes(8).toString('hex'),
|
||||
serializers: {
|
||||
req(req) { return { id: req.id, method: req.method, url: req.url }; },
|
||||
res(res) { return { statusCode: res.statusCode }; },
|
||||
},
|
||||
}));
|
||||
|
||||
// === CORS ===
|
||||
const allowed = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (allowed.length > 0) {
|
||||
app.use(cors({
|
||||
origin: (origin, cb) => {
|
||||
if (!origin || allowed.includes(origin)) return cb(null, true);
|
||||
return cb(new Error('CORS blocked'));
|
||||
},
|
||||
credentials: true,
|
||||
}));
|
||||
} else {
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
}
|
||||
|
||||
app.options('*', cors());
|
||||
|
||||
// === Security ===
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' },
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}));
|
||||
app.set('trust proxy', 1);
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// === Rate Limiting ===
|
||||
const createRateLimiter = (windowMs, max, message) => rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { code: 'E_RATE_LIMIT', message: message || 'Слишком много запросов' },
|
||||
});
|
||||
|
||||
const generalLimiter = createRateLimiter(15 * 60 * 1000, 1000);
|
||||
const writeLimiter = createRateLimiter(5 * 60 * 1000, 100, 'Слишком много операций записи');
|
||||
const bgpUpdateLimiter = createRateLimiter(1 * 60 * 1000, 5, 'Слишком частые BGP обновления');
|
||||
|
||||
app.use(generalLimiter);
|
||||
app.use(express.json({ limit: process.env.JSON_LIMIT || '1mb' }));
|
||||
app.use(compression());
|
||||
app.set('etag', false);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Expose-Headers', 'ETag, Last-Modified, Content-Length-Source');
|
||||
next();
|
||||
});
|
||||
|
||||
// === Metrics ===
|
||||
promClient.collectDefaultMetrics();
|
||||
const httpDuration = new promClient.Histogram({
|
||||
name: 'http_request_duration_seconds',
|
||||
help: 'HTTP request duration',
|
||||
labelNames: ['method', 'route', 'code'],
|
||||
buckets: [0.05,0.1,0.2,0.5,1,2,5]
|
||||
});
|
||||
const httpErrors = new promClient.Counter({
|
||||
name: 'http_errors_total',
|
||||
help: 'HTTP error count',
|
||||
labelNames: ['route','code']
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const start = process.hrtime.bigint();
|
||||
res.on('finish', () => {
|
||||
try {
|
||||
const dur = Number(process.hrtime.bigint() - start) / 1e9;
|
||||
httpDuration.labels(req.method, req.route?.path || req.path, String(res.statusCode)).observe(dur);
|
||||
if (res.statusCode >= 400) {
|
||||
httpErrors.labels(req.route?.path || req.path, String(res.statusCode)).inc();
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// === Health & Metrics ===
|
||||
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||
app.get('/ready', (req, res) => res.json({ ok: true }));
|
||||
app.get('/metrics', async (req, res) => {
|
||||
try {
|
||||
res.set('Content-Type', promClient.register.contentType);
|
||||
res.end(await promClient.register.metrics());
|
||||
} catch (e) {
|
||||
res.status(500).end(String(e?.message || e));
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/version', (req, res) => {
|
||||
res.json({
|
||||
version: process.env.APP_VERSION || null,
|
||||
gitSha: process.env.GIT_SHA || null,
|
||||
buildAt: process.env.BUILD_AT || null
|
||||
});
|
||||
});
|
||||
|
||||
// === Cache TTL ===
|
||||
const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30));
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === 'GET') {
|
||||
res.set('Cache-Control', `private, max-age=${DEFAULT_CACHE_TTL}`);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// === AJV Schemas ===
|
||||
const ajv = new Ajv({ allErrors: true, removeAdditional: 'failing' });
|
||||
|
||||
const schemaDomainsNew = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['domain', 'community'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
domain: { type: 'string' },
|
||||
community: { type: 'string' }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const schemaAsns = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['domain', 'type'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
domain: { type: 'string' },
|
||||
type: { type: 'string' }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const schemaIpRanges = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['ipRange', 'community'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
ipRange: { type: 'string' },
|
||||
community: { type: 'string' }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validateDomainsNew = ajv.compile(schemaDomainsNew);
|
||||
const validateAsns = ajv.compile(schemaAsns);
|
||||
const validateIpRanges = ajv.compile(schemaIpRanges);
|
||||
|
||||
// === Domains (старая версия) ===
|
||||
const domainsOldRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/domains.txt',
|
||||
mapLine: (line) => {
|
||||
const parts = splitWhitespace(line);
|
||||
return { domain: parts[0] || '', type: parts[1] || '' };
|
||||
},
|
||||
formatLine: (d) => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim(),
|
||||
validate: validateAsns,
|
||||
cachePrefix: 'domains'
|
||||
});
|
||||
|
||||
app.get('/api/domains', domainsOldRoutes.get);
|
||||
app.post('/api/domains', domainsOldRoutes.post);
|
||||
|
||||
// === ASNs ===
|
||||
const asnsRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/asns.txt',
|
||||
mapLine: (line) => {
|
||||
const parts = splitWhitespace(line);
|
||||
return { domain: parts[0] || '', type: parts[1] || '' };
|
||||
},
|
||||
formatLine: (a) => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim(),
|
||||
validate: validateAsns,
|
||||
validateItem: (asn, i) => {
|
||||
const errors = [];
|
||||
if (!validators.isValidASN(asn.domain)) {
|
||||
errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(asn.type)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${asn.type}"`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
cachePrefix: 'asns'
|
||||
});
|
||||
|
||||
app.get('/api/asns', asnsRoutes.get);
|
||||
app.post('/api/asns', writeLimiter, asnsRoutes.post);
|
||||
|
||||
// === Domains New ===
|
||||
const domainsNewRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/domains_community.txt',
|
||||
mapLine: (line) => {
|
||||
const parts = splitWhitespace(line);
|
||||
return { domain: parts[0] || '', community: parts[1] || '' };
|
||||
},
|
||||
formatLine: (d) => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim(),
|
||||
validate: validateDomainsNew,
|
||||
validateItem: (d, i) => {
|
||||
const errors = [];
|
||||
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
|
||||
errors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(d.community)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${d.community}"`);
|
||||
}
|
||||
if (!validators.isSafeXSSString(d.domain)) {
|
||||
errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
cachePrefix: 'domains-new'
|
||||
});
|
||||
|
||||
app.get('/api/domains-new', domainsNewRoutes.get);
|
||||
app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post);
|
||||
|
||||
// === IP Ranges ===
|
||||
const ipRangesRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/ips.txt',
|
||||
mapLine: (line) => {
|
||||
const parts = splitWhitespace(line);
|
||||
return { ipRange: parts[0] || '', community: parts[1] || '' };
|
||||
},
|
||||
formatLine: (ip) => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim(),
|
||||
validate: validateIpRanges,
|
||||
validateItem: (ip, i) => {
|
||||
const errors = [];
|
||||
const ipStr = String(ip.ipRange || '').trim();
|
||||
const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr);
|
||||
const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr);
|
||||
|
||||
if (!isValidCIDR && !isValidIP) {
|
||||
errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(ip.community)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${ip.community}"`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
cachePrefix: 'ip-ranges'
|
||||
});
|
||||
|
||||
app.get('/api/ip-ranges', ipRangesRoutes.get);
|
||||
app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post);
|
||||
|
||||
// === Остальные роуты из старого server.js ===
|
||||
// Для экономии места и времени, остальные роуты могут быть вынесены позже
|
||||
// Но основная логика уже оптимизирована
|
||||
|
||||
// Locks
|
||||
app.get('/api/locks/:resource', (req, res) => {
|
||||
const status = getLockStatus(req.params.resource);
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
app.post('/api/locks/:resource', (req, res) => {
|
||||
const { owner = 'anonymous', ttlSeconds = 120 } = req.body || {};
|
||||
const result = acquireLock(req.params.resource, owner, ttlSeconds);
|
||||
if (!result.success) {
|
||||
return sendError(res, 423, 'Resource is locked by another user', 'E_RESOURCE_LOCKED', {
|
||||
owner: result.owner,
|
||||
expiresAt: result.expiresAt
|
||||
});
|
||||
}
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.delete('/api/locks/:resource', (req, res) => {
|
||||
const result = releaseLock(req.params.resource);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// Fallback для остальных роутов - временно используем старую логику из server.js
|
||||
// В будущем можно вынести и оптимизировать остальные эндпоинты аналогично
|
||||
|
||||
// Catchall
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use(errorHandler);
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server is running on http://localhost:${port}`);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,9 @@
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"axios": "^1.10.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"html-to-image": "^1.11.11",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-sigma": "^1.2.35",
|
||||
"@xyflow/react": "^12.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -38,7 +38,7 @@ import QuickAddBar from './components/QuickAddBar.jsx';
|
||||
import AccordionCard from './components/AccordionCard.jsx';
|
||||
import SavedFilters from './components/SavedFilters.jsx';
|
||||
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||
import ValidatedInput from './components/ValidatedInput.jsx';
|
||||
import FormField from './components/FormField.jsx';
|
||||
import { useToast } from './components/ToastContainer.jsx';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
@@ -1,41 +1,67 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import Modal from './Modal';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* ConfirmDialog - упрощённая версия ConfirmModal для быстрых подтверждений
|
||||
* Рефакторинг: теперь использует базовый Modal компонент
|
||||
*/
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
title = 'Подтверждение',
|
||||
message,
|
||||
confirmText = 'Подтвердить',
|
||||
cancelText = 'Отмена',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
destructive = false,
|
||||
size = 'sm',
|
||||
loading = false
|
||||
}) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm?.();
|
||||
};
|
||||
|
||||
export default function ConfirmDialog({ open, title, message, confirmText = 'Подтвердить', cancelText = 'Отмена', onConfirm, onCancel, destructive = false, size = 'md' }) {
|
||||
const ref = useRef(null)
|
||||
useEffect(() => {
|
||||
if (open && ref.current) {
|
||||
try { ref.current.querySelector('button[data-primary]')?.focus() } catch {}
|
||||
}
|
||||
}, [open])
|
||||
if (!open) return null
|
||||
return (
|
||||
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onCancel?.() }}>
|
||||
<div className={`modal-dialog ${size === 'sm' ? 'modal-sm' : size === 'lg' ? 'modal-lg' : ''}`} role="document">
|
||||
<div className="modal-content" ref={ref} tabIndex={-1} onKeyDown={(e) => {
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
||||
if (!focusable || focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}}>
|
||||
<div className="modal-header">
|
||||
<h5 id="confirm-title" className="modal-title">{title || 'Подтверждение'}</h5>
|
||||
<button type="button" className="btn-close" aria-label="Close" onClick={onCancel}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p id="confirm-message" className="m-0">{message}</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel}>{cancelText}</button>
|
||||
<button type="button" className={`btn ${destructive ? 'btn-danger' : 'btn-primary'}`} data-primary onClick={onConfirm}>{confirmText}</button>
|
||||
<Modal
|
||||
show={open}
|
||||
onClose={onCancel}
|
||||
title={title}
|
||||
size={size}
|
||||
centered
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${destructive ? 'btn-danger' : 'btn-primary'}`}
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && (
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
|
||||
)}
|
||||
{confirmText}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
{destructive && (
|
||||
<div className="flex-shrink-0 me-3">
|
||||
<IconAlertTriangle className="text-danger" size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-grow-1">
|
||||
<p className="mb-0">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,49 +1,73 @@
|
||||
function ConfirmDiffModal({ show, diff, onConfirm, onClose }) {
|
||||
if (!show) return null;
|
||||
import Modal from './Modal';
|
||||
|
||||
/**
|
||||
* ConfirmDiffModal - модальное окно для подтверждения изменений с отображением статистики
|
||||
* Рефакторинг: теперь использует базовый Modal компонент
|
||||
*/
|
||||
function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
|
||||
const added = diff?.added?.length || 0;
|
||||
const removed = diff?.removed?.length || 0;
|
||||
const changed = diff?.changed?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-diff-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}>
|
||||
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||||
<div className="modal-content" tabIndex={-1} onKeyDown={(e) => {
|
||||
if (e.key === 'Tab') {
|
||||
const c = e.currentTarget
|
||||
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
||||
if (!focusable || focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}}>
|
||||
<div className="modal-header">
|
||||
<h5 id="confirm-diff-title" className="modal-title">Подтвердить сохранение</h5>
|
||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="row g-2 text-center">
|
||||
<div className="col">
|
||||
<div className="card"><div className="card-body p-2"><strong>Добавлено</strong><div className="text-muted">{added}</div></div></div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="card"><div className="card-body p-2"><strong>Удалено</strong><div className="text-muted">{removed}</div></div></div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="card"><div className="card-body p-2"><strong>Изменено</strong><div className="text-muted">{changed}</div></div></div>
|
||||
</div>
|
||||
<Modal
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
title="Подтвердить сохранение"
|
||||
size="sm"
|
||||
centered
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && (
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
|
||||
)}
|
||||
Сохранить
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="row g-2 text-center">
|
||||
<div className="col">
|
||||
<div className="card">
|
||||
<div className="card-body p-2">
|
||||
<strong>Добавлено</strong>
|
||||
<div className="text-muted">{added}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Отмена</button>
|
||||
<button type="button" className="btn btn-primary" onClick={onConfirm}>Сохранить</button>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="card">
|
||||
<div className="card-body p-2">
|
||||
<strong>Удалено</strong>
|
||||
<div className="text-muted">{removed}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="card">
|
||||
<div className="card-body p-2">
|
||||
<strong>Изменено</strong>
|
||||
<div className="text-muted">{changed}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmDiffModal;
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* EmptyState - компонент для отображения пустого состояния
|
||||
* Оптимизирован: убран неиспользуемый импорт React
|
||||
*/
|
||||
function EmptyState({
|
||||
icon: Icon,
|
||||
title = 'Нет данных',
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { IconAlertTriangle, IconX } from '@tabler/icons-react';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* ErrorAlert - компонент для отображения ошибок
|
||||
* Оптимизирован: убрано дублирование кода
|
||||
*/
|
||||
function ErrorAlert({ message, details, onClose }) {
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||
<div className="d-flex">
|
||||
@@ -10,24 +15,26 @@ function ErrorAlert({ message, details, onClose }) {
|
||||
</div>
|
||||
<div className="flex-grow-1">
|
||||
{String(message)}
|
||||
{details ? (
|
||||
{details && (
|
||||
<details className="small mt-1">
|
||||
<summary>Показать детали</summary>
|
||||
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{typeof details === 'string' ? details : JSON.stringify(details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть">
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorAlert;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* FormField - универсальное поле формы с валидацией
|
||||
* Поддерживает иконки, подсказки, ошибки и success состояния
|
||||
* Объединяет функциональность FormField и ValidatedInput
|
||||
* Поддерживает: иконки, подсказки, ошибки, success состояния, debounce валидацию
|
||||
*/
|
||||
function FormField({
|
||||
label,
|
||||
@@ -9,32 +13,76 @@ function FormField({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onValidate, // функция валидации (опционально): (value) => { valid: boolean, message: string }
|
||||
error,
|
||||
success,
|
||||
helpText,
|
||||
required,
|
||||
disabled,
|
||||
placeholder,
|
||||
autoFocus,
|
||||
icon: Icon,
|
||||
className = '',
|
||||
inputClassName = '',
|
||||
rows, // для textarea
|
||||
options, // для select
|
||||
debounceMs = 300, // debounce для валидации
|
||||
showValidationIcon = true,
|
||||
...inputProps
|
||||
}) {
|
||||
const inputId = `field-${name}`;
|
||||
const hasError = !!error;
|
||||
const hasSuccess = !!success && !error;
|
||||
const [localValue, setLocalValue] = useState(value || '');
|
||||
const [validation, setValidation] = useState({ valid: null, message: '' });
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const hasExternalError = !!error;
|
||||
const hasValidationError = isDirty && validation.valid === false;
|
||||
const hasError = hasExternalError || hasValidationError;
|
||||
|
||||
const hasExternalSuccess = !!success && !hasExternalError;
|
||||
const hasValidationSuccess = isDirty && validation.valid === true && !hasExternalError;
|
||||
const hasSuccess = hasExternalSuccess || hasValidationSuccess;
|
||||
|
||||
const isTextarea = type === 'textarea';
|
||||
const isSelect = type === 'select';
|
||||
|
||||
// Синхронизация с внешним value
|
||||
useEffect(() => {
|
||||
setLocalValue(value || '');
|
||||
}, [value]);
|
||||
|
||||
// Валидация с debounce
|
||||
useEffect(() => {
|
||||
if (!isDirty || !onValidate) return;
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
const result = onValidate(localValue);
|
||||
setValidation(result);
|
||||
}, debounceMs);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [localValue, isDirty, onValidate, debounceMs]);
|
||||
|
||||
const inputClasses = `form-control ${hasError ? 'is-invalid' : ''} ${hasSuccess ? 'is-valid' : ''} ${inputClassName}`;
|
||||
|
||||
const handleChange = (e) => {
|
||||
onChange?.(e.target.value, e);
|
||||
const newValue = e.target.value;
|
||||
setLocalValue(newValue);
|
||||
setIsDirty(true);
|
||||
onChange?.(newValue, e);
|
||||
};
|
||||
|
||||
const handleBlur = (e) => {
|
||||
setIsDirty(true);
|
||||
onBlur?.(e);
|
||||
};
|
||||
|
||||
@@ -45,13 +93,14 @@ function FormField({
|
||||
id={inputId}
|
||||
name={name}
|
||||
className={inputClasses}
|
||||
value={value}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
rows={rows || 3}
|
||||
autoFocus={autoFocus}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
||||
{...inputProps}
|
||||
@@ -65,11 +114,12 @@ function FormField({
|
||||
id={inputId}
|
||||
name={name}
|
||||
className={inputClasses.replace('form-control', 'form-select')}
|
||||
value={value}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
||||
{...inputProps}
|
||||
@@ -89,12 +139,13 @@ function FormField({
|
||||
name={name}
|
||||
type={type}
|
||||
className={inputClasses}
|
||||
value={value}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
||||
{...inputProps}
|
||||
@@ -102,6 +153,53 @@ function FormField({
|
||||
);
|
||||
};
|
||||
|
||||
// Рендер с иконкой (слева или справа для валидации)
|
||||
const renderInputWithIcon = () => {
|
||||
const hasValidationIcon = showValidationIcon && isDirty && validation.valid !== null;
|
||||
|
||||
if (Icon) {
|
||||
// Иконка слева (переданная через prop)
|
||||
return (
|
||||
<div className="input-icon">
|
||||
<span className="input-icon-addon">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
{renderInput()}
|
||||
{hasValidationIcon && (
|
||||
<span className="input-icon-addon" style={{ right: 0, left: 'auto' }}>
|
||||
{validation.valid === true ? (
|
||||
<IconCheck size={20} className="text-success" />
|
||||
) : (
|
||||
<IconX size={20} className="text-danger" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasValidationIcon) {
|
||||
// Только иконка валидации справа
|
||||
return (
|
||||
<div className="input-icon">
|
||||
{renderInput()}
|
||||
<span className="input-icon-addon">
|
||||
{validation.valid === true ? (
|
||||
<IconCheck size={20} className="text-success" />
|
||||
) : (
|
||||
<IconX size={20} className="text-danger" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderInput();
|
||||
};
|
||||
|
||||
const displayError = error || (hasValidationError ? validation.message : null);
|
||||
const displaySuccess = success || (hasValidationSuccess ? validation.message : null);
|
||||
|
||||
return (
|
||||
<div className={`mb-3 ${className}`}>
|
||||
{label && (
|
||||
@@ -111,31 +209,23 @@ function FormField({
|
||||
</label>
|
||||
)}
|
||||
|
||||
{Icon ? (
|
||||
<div className="input-icon">
|
||||
<span className="input-icon-addon">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
{renderInput()}
|
||||
</div>
|
||||
) : (
|
||||
renderInput()
|
||||
)}
|
||||
{renderInputWithIcon()}
|
||||
|
||||
{error && (
|
||||
{displayError && (
|
||||
<div id={`${inputId}-error`} className="invalid-feedback d-block" role="alert">
|
||||
{error}
|
||||
{displayError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSuccess && (
|
||||
{displaySuccess && !displayError && (
|
||||
<div className="valid-feedback d-block">
|
||||
{success}
|
||||
{displaySuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{helpText && !error && !success && (
|
||||
{helpText && !displayError && !displaySuccess && (
|
||||
<div id={`${inputId}-help`} className="form-text">
|
||||
<IconAlertCircle size={14} className="me-1" />
|
||||
{helpText}
|
||||
</div>
|
||||
)}
|
||||
@@ -144,4 +234,3 @@ function FormField({
|
||||
}
|
||||
|
||||
export default FormField;
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export default function LockBanner() { return null }
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Универсальный компонент пагинации
|
||||
* Устраняет дублирование кода пагинации во всех менеджерах
|
||||
* Оптимизирован: useMemo для вычисления страниц
|
||||
*/
|
||||
|
||||
function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChange }) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const renderPages = () => {
|
||||
const pages = [];
|
||||
const pages = useMemo(() => {
|
||||
const result = [];
|
||||
let start = Math.max(1, currentPage - 2);
|
||||
let end = Math.min(totalPages, currentPage + 2);
|
||||
|
||||
@@ -18,25 +19,14 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
|
||||
start = Math.max(1, totalPages - 4);
|
||||
}
|
||||
|
||||
if (start > 1) pages.push('start-ellipsis');
|
||||
for (let p = start; p <= end; p++) pages.push(p);
|
||||
if (end < totalPages) pages.push('end-ellipsis');
|
||||
if (start > 1) result.push({ type: 'ellipsis', key: 'start-ellipsis' });
|
||||
for (let p = start; p <= end; p++) {
|
||||
result.push({ type: 'page', page: p, key: p });
|
||||
}
|
||||
if (end < totalPages) result.push({ type: 'ellipsis', key: 'end-ellipsis' });
|
||||
|
||||
return pages.map((p) => {
|
||||
if (p === 'start-ellipsis' || p === 'end-ellipsis') {
|
||||
return (
|
||||
<li key={p} className="page-item disabled">
|
||||
<span className="page-link">…</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
||||
<button className="page-link" onClick={() => onPageChange(p)}>{p}</button>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
};
|
||||
return result;
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
const startItem = (currentPage - 1) * pageSize + 1;
|
||||
const endItem = Math.min(currentPage * pageSize, totalItems);
|
||||
@@ -52,6 +42,7 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
|
||||
className="page-link"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Первая страница"
|
||||
>
|
||||
Первая
|
||||
</button>
|
||||
@@ -61,16 +52,38 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
|
||||
className="page-link"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Предыдущая страница"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
</li>
|
||||
{renderPages()}
|
||||
{pages.map((item) => {
|
||||
if (item.type === 'ellipsis') {
|
||||
return (
|
||||
<li key={item.key} className="page-item disabled">
|
||||
<span className="page-link">…</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={item.key} className={`page-item${currentPage === item.page ? ' active' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => onPageChange(item.page)}
|
||||
aria-label={`Страница ${item.page}`}
|
||||
aria-current={currentPage === item.page ? 'page' : undefined}
|
||||
>
|
||||
{item.page}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
aria-label="Следующая страница"
|
||||
>
|
||||
Вперед
|
||||
</button>
|
||||
@@ -80,6 +93,7 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
|
||||
className="page-link"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
aria-label="Последняя страница"
|
||||
>
|
||||
Последняя
|
||||
</button>
|
||||
@@ -90,4 +104,3 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
|
||||
}
|
||||
|
||||
export default Pagination;
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react'
|
||||
|
||||
/**
|
||||
* Компонент input с inline валидацией и подсказками (Tabler UI стили)
|
||||
*/
|
||||
function ValidatedInput({
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
onValidate, // функция валидации: (value) => { valid: boolean, message: string }
|
||||
placeholder = '',
|
||||
label = '',
|
||||
required = false,
|
||||
disabled = false,
|
||||
className = '',
|
||||
helpText = '',
|
||||
debounceMs = 300,
|
||||
showSuccessIcon = true,
|
||||
autoFocus = false,
|
||||
...rest
|
||||
}) {
|
||||
const [localValue, setLocalValue] = useState(value || '')
|
||||
const [validation, setValidation] = useState({ valid: null, message: '' })
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const timerRef = useRef(null)
|
||||
|
||||
// Синхронизация с внешним value
|
||||
useEffect(() => {
|
||||
setLocalValue(value || '')
|
||||
}, [value])
|
||||
|
||||
// Валидация с debounce
|
||||
useEffect(() => {
|
||||
if (!isDirty || !onValidate) return
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
const result = onValidate(localValue)
|
||||
setValidation(result)
|
||||
}, debounceMs)
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [localValue, isDirty, onValidate, debounceMs])
|
||||
|
||||
const handleChange = (e) => {
|
||||
const newValue = e.target.value
|
||||
setLocalValue(newValue)
|
||||
setIsDirty(true)
|
||||
onChange?.(newValue)
|
||||
}
|
||||
|
||||
const getInputClass = () => {
|
||||
if (!isDirty) return ''
|
||||
if (validation.valid === true) return 'is-valid'
|
||||
if (validation.valid === false) return 'is-invalid'
|
||||
return ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mb-3 ${className}`}>
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<label className="form-label">
|
||||
{label}
|
||||
{required && <span className="text-danger ms-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Input с иконкой валидации */}
|
||||
<div className="input-icon">
|
||||
<input
|
||||
type={type}
|
||||
className={`form-control ${getInputClass()}`}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
autoFocus={autoFocus}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
{/* Иконка статуса валидации */}
|
||||
{isDirty && validation.valid !== null && (
|
||||
<span className="input-icon-addon">
|
||||
{validation.valid === true && showSuccessIcon ? (
|
||||
<IconCheck size={20} className="text-success" />
|
||||
) : validation.valid === false ? (
|
||||
<IconX size={20} className="text-danger" />
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text или сообщение валидации */}
|
||||
{isDirty && validation.message ? (
|
||||
<div className={`form-hint ${validation.valid === false ? 'text-danger' : 'text-success'}`}>
|
||||
{validation.message}
|
||||
</div>
|
||||
) : helpText ? (
|
||||
<div className="form-hint text-muted">
|
||||
<IconAlertCircle size={14} className="me-1" />
|
||||
{helpText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ValidatedInput
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Хук для управления состоянием модального окна
|
||||
* Упрощает работу с открытием/закрытием модалок
|
||||
*/
|
||||
export function useModal(initialState = false) {
|
||||
const [isOpen, setIsOpen] = useState(initialState);
|
||||
|
||||
const open = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setIsOpen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
setIsOpen
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для управления ESC и клавиатурной навигацией в модалках
|
||||
*/
|
||||
export function useModalKeyboard(isOpen, onClose) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для управления классом modal-open на body
|
||||
*/
|
||||
export function useModalBodyClass(isOpen) {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.classList.add('modal-open');
|
||||
} else {
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('modal-open');
|
||||
};
|
||||
}, [isOpen]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для управления focus trap внутри модалки
|
||||
*/
|
||||
export function useFocusTrap(isOpen, containerRef) {
|
||||
useEffect(() => {
|
||||
if (!isOpen || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const focusableElements = container.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
// Фокус на первый элемент при открытии
|
||||
firstElement?.focus();
|
||||
|
||||
const handleTabKey = (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift + Tab
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement?.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('keydown', handleTabKey);
|
||||
return () => container.removeEventListener('keydown', handleTabKey);
|
||||
}, [isOpen, containerRef]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Комплексный хук, объединяющий все хуки для модалок
|
||||
*/
|
||||
export function useModalManager(initialState = false) {
|
||||
const modal = useModal(initialState);
|
||||
|
||||
return {
|
||||
...modal,
|
||||
// Дополнительные утилиты
|
||||
openWithData: (data) => {
|
||||
modal.setData?.(data);
|
||||
modal.open();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default useModal;
|
||||
|
||||
@@ -12,8 +12,5 @@
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/core": "^1.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user