feat: Удаление устаревших файлов документации и оптимизация структуры проекта. Упрощение кода и улучшение читаемости за счет удаления ненужных компонентов и отчетов, что способствует более эффективному управлению проектом.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s

This commit is contained in:
2025-10-03 09:30:53 +07:00
parent a4bd1ab1d4
commit 2ae26801c4
18 changed files with 417 additions and 6528 deletions
-321
View File
@@ -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
**Улучшений безопасности**: ✅ Существенные
**Совместимость**: ✅ Полная
Все изменения протестированы и готовы к использованию! 🎉
-295
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-531
View File
@@ -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)
-331
View File
@@ -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
-2
View File
@@ -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": {
+1 -1
View File
@@ -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';
+61 -35
View File
@@ -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>
);
}
+60 -36
View File
@@ -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;
+4 -2
View File
@@ -1,5 +1,7 @@
import React from 'react'
/**
* EmptyState - компонент для отображения пустого состояния
* Оптимизирован: убран неиспользуемый импорт React
*/
function EmptyState({
icon: Icon,
title = 'Нет данных',
+16 -9
View File
@@ -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;
+112 -23
View File
@@ -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
View File
@@ -1 +0,0 @@
export default function LockBanner() { return null }
+37 -24
View File
@@ -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;
-119
View File
@@ -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
+126
View File
@@ -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;
-3
View File
@@ -12,8 +12,5 @@
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.0"
},
"dependencies": {
"@tabler/core": "^1.3.2"
}
}