Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdf692a2aa | ||
|
|
8952c4a897 | ||
|
|
cd5e169bc6 | ||
|
|
339144fb11 | ||
|
|
495063a510 | ||
|
|
cfbcedf9d7 | ||
|
|
c06945eefc | ||
|
|
730017405c | ||
|
|
ac0cbc8c31 | ||
|
|
10903400e7 | ||
|
|
960f68b8ec | ||
|
|
61f0db7b03 | ||
|
|
a8afef1191 | ||
|
|
46c80fdf32 | ||
|
|
d30a507f60 | ||
|
|
6a81ad9983 | ||
|
|
cd3f7a84e3 | ||
|
|
0f3ad56f87 | ||
|
|
434462f633 | ||
|
|
001b4d9d98 | ||
|
|
449f44b499 | ||
|
|
3358b8daed | ||
|
|
8d0c28de92 | ||
|
|
54377afba1 | ||
|
|
9e16a0966f | ||
|
|
82fb2404a2 | ||
|
|
298fb7c6f0 | ||
|
|
6388bea7b5 | ||
|
|
49d08dbd14 | ||
|
|
ce62dad844 | ||
|
|
ec50fcab72 | ||
|
|
b3eb991095 | ||
|
|
20ee4704a4 | ||
|
|
b0009fbb1f | ||
|
|
213dc42124 | ||
|
|
9edd32526b | ||
|
|
03d24e8746 | ||
|
|
f11374880c | ||
|
|
e6878a5b08 | ||
|
|
7348cc2c79 | ||
|
|
f4e3de4a52 | ||
|
|
855fc5d577 | ||
|
|
158af391b0 | ||
|
|
ae8141bee8 | ||
|
|
bdfbdcfa1f | ||
|
|
30fbc1107d | ||
|
|
3dbb8db338 | ||
|
|
a9029acea2 | ||
|
|
358bfb04b6 | ||
|
|
5b80e5faa4 | ||
|
|
8982c9ae10 | ||
|
|
9c0fe54402 | ||
|
|
64b9254174 | ||
|
|
10a86232fc | ||
|
|
459b02f756 | ||
|
|
79038a3981 | ||
|
|
cdf42aa589 | ||
|
|
f06a433d77 | ||
|
|
026d2a9379 | ||
|
|
3b1a59b03f | ||
|
|
f44acbff98 | ||
|
|
af9ef12ba8 | ||
|
|
c49bb26f6d | ||
|
|
5287089fb7 | ||
|
|
c1afd0a9d6 | ||
|
|
6afd43f9e8 | ||
|
|
087e0eb491 | ||
|
|
6f45015bec | ||
|
|
460c06dfba | ||
|
|
7321e21871 | ||
|
|
f1b84a761e | ||
|
|
415f05f6aa | ||
|
|
3340571a6e | ||
|
|
d1a06866c9 | ||
|
|
8f31a31827 | ||
|
|
7419762681 | ||
|
|
eafd0c0957 | ||
|
|
21f6f46bd3 | ||
|
|
6f10e63ad8 | ||
|
|
cc8d11cfb1 | ||
|
|
d25e8fc810 | ||
|
|
1fb53c3467 | ||
|
|
ca56b52672 | ||
|
|
16f14d3350 | ||
|
|
4635c05f8c | ||
|
|
817a8981f4 | ||
|
|
d3490c438b | ||
|
|
92474cc49b | ||
|
|
f8a80f44e4 | ||
|
|
42c54a32c1 | ||
|
|
f74586c4ff | ||
|
|
e5f8544c2e | ||
|
|
76dee39843 | ||
|
|
bbad45dfad | ||
|
|
9c3332fe36 | ||
|
|
522112cc3c | ||
|
|
b1b043f7bc | ||
|
|
d36db114cd | ||
|
|
b23056d95a | ||
|
|
f5f71085b2 | ||
|
|
08cf7f806c | ||
|
|
8ea6f9526e | ||
|
|
eaa88e2897 | ||
|
|
efc561a2a1 | ||
|
|
16c75a40a8 | ||
|
|
896f2e668c | ||
|
|
5bdb4cfdd9 | ||
|
|
26152b2127 | ||
|
|
0e81f36200 | ||
|
|
68e9d5be7d | ||
|
|
1f1ee7161c | ||
|
|
a7549ff164 | ||
|
|
389725c023 | ||
|
|
ebfc5a9fab | ||
|
|
560120f5b8 | ||
|
|
fdad8ce5e1 | ||
|
|
6c7142d45d | ||
|
|
6d7ed80646 | ||
|
|
3c185509b2 | ||
|
|
64dbe621be | ||
|
|
82e15e708f | ||
|
|
f999c87335 | ||
|
|
c736268c87 | ||
|
|
b1b956f05b | ||
|
|
fd9f598f95 | ||
|
|
9d6cef6f9b | ||
|
|
a8c7d3e73e | ||
|
|
4e5025b86e | ||
|
|
c139a0c9c9 | ||
|
|
b0094e4658 | ||
|
|
d96f1e1c24 | ||
|
|
9b253e8963 | ||
|
|
2c67c2b23b | ||
|
|
859395028f | ||
|
|
31559eae3b | ||
|
|
6dee0fa0b4 | ||
|
|
7c6ceb66bc | ||
|
|
f5d86d9e1b | ||
|
|
89bb76d7cb | ||
|
|
6807d1d429 | ||
|
|
3168964cde | ||
|
|
4fbffad878 | ||
|
|
13767d7b3f | ||
|
|
c2f0324817 | ||
|
|
c6fbfee2b3 | ||
|
|
093d3e07db | ||
|
|
7642d86a82 | ||
|
|
9ca9d3f2df | ||
|
|
8a475b38c0 | ||
|
|
9df5f577d9 | ||
|
|
04766bd005 | ||
|
|
bbf143dee7 | ||
|
|
a104cc7a3f | ||
|
|
c5da9e357e | ||
|
|
2590b08262 | ||
|
|
1a31b37677 | ||
|
|
646e9be382 | ||
|
|
24d4e743b5 | ||
|
|
b65d459e5d | ||
|
|
2bee891548 | ||
|
|
a54c09ae15 | ||
|
|
2f33d2545a | ||
|
|
3b852464d6 | ||
|
|
c62666bd42 | ||
|
|
e6a9c17aa0 | ||
|
|
fd769dc81a | ||
|
|
6bcc8a8331 | ||
|
|
7d4e1d26b7 | ||
|
|
b4f3d78349 | ||
|
|
ecca26d486 | ||
|
|
a4e0b5c936 | ||
|
|
1869774f73 | ||
|
|
d059892d7e | ||
|
|
71ab7b24d3 | ||
|
|
022c8dddc5 | ||
|
|
077fdcd258 | ||
|
|
c25011e72b | ||
|
|
a42e103bba | ||
|
|
43a77bc3b1 | ||
|
|
8926f599c0 | ||
|
|
d174797ad0 | ||
|
|
9af7d1081a | ||
|
|
07ff184ef4 | ||
|
|
93ace3c619 | ||
|
|
a4c5b1dd98 | ||
|
|
3e6199a339 | ||
|
|
24d2198f72 | ||
|
|
15f3166758 | ||
|
|
85ed473cca | ||
|
|
9e4c6863b6 | ||
|
|
546d9aabac | ||
|
|
4efe958c2e | ||
|
|
0d53b11dc4 | ||
|
|
cdd7963331 | ||
|
|
0c7c717be8 | ||
|
|
9a109a0bf2 | ||
|
|
aaa4878229 | ||
|
|
a2f3f24c57 | ||
|
|
2aac641cd3 | ||
|
|
86f6304382 | ||
|
|
2738455c1a | ||
|
|
0390dfd148 | ||
|
|
e0fa98f21a | ||
|
|
9d31682745 | ||
|
|
7e29a3daed | ||
|
|
c96245ae67 | ||
|
|
3e8d43f1bd | ||
|
|
b4f1a2858d | ||
|
|
cff0494884 | ||
|
|
82f2d62bf8 | ||
|
|
97711f26cb | ||
|
|
68d7d55c8b | ||
|
|
1eb3e9d3f6 | ||
|
|
0c1c156bb7 |
@@ -0,0 +1,35 @@
|
||||
# VCS
|
||||
.git
|
||||
.gitattributes
|
||||
.gitignore
|
||||
|
||||
# CI config (excluded from build context)
|
||||
.gitea/
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
backend/node_modules/
|
||||
|
||||
# IDE/OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Frontend build output
|
||||
frontend/dist/
|
||||
|
||||
# Large example data not needed for image build
|
||||
example/
|
||||
example-servers*.json
|
||||
|
||||
# Misc docs
|
||||
Dockerfile*
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
CI_TROUBLESHOOTING.md
|
||||
DOCKER.md
|
||||
SHX_NETWORK.md
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Publish Fast Tabler Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- tabler
|
||||
|
||||
jobs:
|
||||
build-and-push-fast:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: |
|
||||
image=moby/buildkit:v0.13.2
|
||||
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shts.su
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
- name: Build and push Fast Tabler Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.fast
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
cache-from: |
|
||||
type=registry,ref=git.shts.su/${{ gitea.repository }}:tabler-fast
|
||||
cache-to: type=inline
|
||||
tags: |
|
||||
git.shts.su/${{ gitea.repository }}:tabler-fast
|
||||
git.shts.su/${{ gitea.repository }}:tabler-fast-${{ gitea.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=S3 Lists Manager with Tabler UI (Fast Build)
|
||||
org.opencontainers.image.description=Modern S3 Lists Manager with Tabler UI design - Fast Alpine build
|
||||
org.opencontainers.image.version=tabler-fast
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
||||
provenance: false
|
||||
|
||||
- name: Cleanup old images
|
||||
run: |
|
||||
echo "Skipping container registry cleanup (inline cache in image). Keep 3 latest image tags logic can be added with regctl if needed."
|
||||
@@ -12,6 +12,12 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: |
|
||||
image=moby/buildkit:v0.12.0
|
||||
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -26,6 +32,16 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: |
|
||||
git.shts.su/${{ gitea.repository }}:latest
|
||||
git.shts.su/${{ gitea.repository }}:${{ gitea.sha }}
|
||||
git.shts.su/${{ gitea.repository }}:${{ gitea.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=S3 Lists Manager
|
||||
org.opencontainers.image.description=S3 Lists Manager for domains and AS management
|
||||
org.opencontainers.image.version=latest
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
@@ -0,0 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
## [2025-10-02] - Major UX/UI Improvements
|
||||
|
||||
### Added - Новые компоненты (100% Tabler UI)
|
||||
- **Breadcrumbs** - улучшенный с кнопкой "Назад", счетчиками и фильтрами
|
||||
- **SavedFilters** - сохранение комбинаций фильтров в localStorage
|
||||
- **BulkActionsBar** - панель массовых операций для таблиц
|
||||
- **ToastContainer** - система toast уведомлений (success/error/warning/info)
|
||||
- **ValidatedInput** - input с inline валидацией и подсказками
|
||||
- **ProgressIndicator** - индикаторы прогресса (линейный, кольцевой)
|
||||
- **Sparkline** - мини-графики без внешних библиотек (SVG)
|
||||
- **CommandPalette** - глобальный поиск команд (Ctrl+K)
|
||||
- **KeyboardShortcutsButton** - справка по горячим клавишам
|
||||
|
||||
### Enhanced - Улучшенные компоненты
|
||||
- **TableSkeleton** - добавлены CardSkeleton, ListSkeleton, FormSkeleton
|
||||
- **App.jsx** - интеграция ToastContainer и CommandPalette
|
||||
|
||||
### Improved - Улучшения UX
|
||||
- ⌨️ Keyboard shortcuts (Ctrl+K, Ctrl+N, Ctrl+S, Ctrl+F, Esc)
|
||||
- 🎨 Анимированные skeleton loaders с pulse эффектом
|
||||
- 🔔 Toast уведомления с действиями и автозакрытием
|
||||
- ✓ Real-time валидация форм с debounce
|
||||
- ☑️ Bulk operations с чекбоксами
|
||||
- 📊 Sparkline графики для визуализации трендов
|
||||
- 🔍 Command palette для быстрого доступа к функциям
|
||||
|
||||
### Technical Details
|
||||
- ~1300 строк нового кода
|
||||
- 8 новых компонентов
|
||||
- 3 улучшенных компонента
|
||||
- 100% Tabler UI стили (без сторонних библиотек)
|
||||
- Полная поддержка темной темы
|
||||
- ARIA labels для accessibility
|
||||
- TypeScript ready
|
||||
|
||||
### Documentation
|
||||
- Создан `UX_UI_IMPROVEMENTS.md` с полной документацией
|
||||
- Примеры использования всех компонентов
|
||||
- Дизайн-система и цветовая палитра
|
||||
|
||||
---
|
||||
|
||||
## [2024-07-08] - Tabler Core Integration
|
||||
|
||||
### Added
|
||||
- Integrated `@tabler/core` package for 100% authentic Tabler UI
|
||||
- Official Tabler CSS and components
|
||||
- Better performance and smaller bundle size
|
||||
|
||||
### Changed
|
||||
- Updated Node.js requirement from 18 to 20 (required by @tabler/core)
|
||||
- Simplified CSS by removing custom Tabler styles
|
||||
- Replaced React-Bootstrap components with native Tabler classes
|
||||
- Updated Dockerfile to use Node.js 20
|
||||
|
||||
### Removed
|
||||
- Custom Tabler CSS variables and styles
|
||||
- React-Bootstrap dependency (still available but not used)
|
||||
- Duplicate styling code
|
||||
|
||||
### Technical Details
|
||||
- `@tabler/core` requires Node.js >= 20
|
||||
- Vite 7.0+ requires Node.js >= 20
|
||||
- All existing functionality preserved
|
||||
- Better TypeScript support
|
||||
- Improved accessibility
|
||||
|
||||
### Migration Notes
|
||||
- No breaking changes for users
|
||||
- Backend remains unchanged
|
||||
- Docker images now use Node.js 20
|
||||
- CI/CD pipelines updated automatically
|
||||
@@ -0,0 +1,100 @@
|
||||
# CI/CD Troubleshooting Guide
|
||||
|
||||
## Проблемы со сборкой Docker
|
||||
|
||||
### 1. Сборка зависает на npm install
|
||||
|
||||
**Симптомы:**
|
||||
- CI зависает на этапе `RUN npm install`
|
||||
- Время сборки превышает 20 минут
|
||||
- Ошибки сетевого подключения
|
||||
|
||||
**Решения:**
|
||||
|
||||
#### A. Использовать оптимизированный Dockerfile
|
||||
```bash
|
||||
# Основной оптимизированный
|
||||
docker build -f Dockerfile .
|
||||
|
||||
# Быстрый с Alpine
|
||||
docker build -f Dockerfile.fast .
|
||||
|
||||
# Простой тестовый
|
||||
docker build -f Dockerfile.test .
|
||||
```
|
||||
|
||||
#### B. Проверить сетевые настройки
|
||||
```bash
|
||||
# В CI добавить таймауты
|
||||
npm config set fetch-timeout 300000
|
||||
npm config set fetch-retry-mintimeout 20000
|
||||
npm config set fetch-retry-maxtimeout 120000
|
||||
```
|
||||
|
||||
#### C. Использовать кэширование
|
||||
```yaml
|
||||
# В CI/CD файле
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
```
|
||||
|
||||
### 2. Проблемы с Node.js версиями
|
||||
|
||||
**Симптомы:**
|
||||
- `EBADENGINE Unsupported engine`
|
||||
- Ошибки совместимости пакетов
|
||||
|
||||
**Решения:**
|
||||
- Обновить Dockerfile до Node.js 20
|
||||
- Проверить совместимость всех зависимостей
|
||||
|
||||
### 3. Проблемы с размером образа
|
||||
|
||||
**Симптомы:**
|
||||
- Слишком большой размер Docker образа
|
||||
- Медленная загрузка
|
||||
|
||||
**Решения:**
|
||||
- Использовать Alpine Linux (`node:20-alpine`)
|
||||
- Добавить `.dockerignore`
|
||||
- Использовать multi-stage builds
|
||||
|
||||
## Быстрые решения
|
||||
|
||||
### Вариант 1: Основной оптимизированный
|
||||
```bash
|
||||
# Использует обновленный Dockerfile с кэшированием
|
||||
git push origin tabler
|
||||
```
|
||||
|
||||
### Вариант 2: Быстрый с Alpine
|
||||
```bash
|
||||
# Использовать Dockerfile.fast (уже исправлен)
|
||||
git push origin tabler
|
||||
```
|
||||
|
||||
### Вариант 3: Локальная сборка для тестирования
|
||||
```bash
|
||||
# Собрать локально для проверки
|
||||
docker build -t test-image .
|
||||
docker run -p 3001:3001 test-image
|
||||
```
|
||||
|
||||
## Мониторинг CI/CD
|
||||
|
||||
### Проверить статус сборки
|
||||
1. Зайти в Gitea → Repository → Actions
|
||||
2. Найти последний workflow
|
||||
3. Проверить логи на каждом этапе
|
||||
|
||||
### Типичные времена сборки
|
||||
- **Оптимизированный Dockerfile**: 5-10 минут
|
||||
- **Быстрый Dockerfile**: 3-7 минут
|
||||
- **С кэшированием**: 2-5 минут (повторные сборки)
|
||||
|
||||
## Контакты для поддержки
|
||||
|
||||
Если проблемы продолжаются:
|
||||
1. Проверить логи CI/CD
|
||||
2. Попробовать локальную сборку
|
||||
3. Создать issue с логами ошибок
|
||||
@@ -0,0 +1,208 @@
|
||||
# Docker Images
|
||||
|
||||
Этот проект предоставляет Docker образы для S3 Lists Manager с двумя различными версиями интерфейса.
|
||||
|
||||
## 🏷️ Доступные теги
|
||||
|
||||
### Основная версия (main branch)
|
||||
- `git.shts.su/[repository]:latest` - Стабильная версия с базовым интерфейсом
|
||||
- `git.shts.su/[repository]:[commit-sha]` - Версия с конкретным коммитом
|
||||
|
||||
### Tabler версия (tabler branch)
|
||||
- `git.shts.su/[repository]:tabler` - Версия с современным Tabler UI
|
||||
- `git.shts.su/[repository]:tabler-[commit-sha]` - Tabler версия с конкретным коммитом
|
||||
|
||||
## 🚀 Запуск
|
||||
|
||||
### Основная версия
|
||||
```bash
|
||||
docker run -d \
|
||||
--name s3-lists-manager \
|
||||
-p 3001:3001 \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
### Tabler версия
|
||||
```bash
|
||||
docker run -d \
|
||||
--name s3-lists-manager-tabler \
|
||||
-p 3001:3001 \
|
||||
git.shts.su/[repository]:tabler
|
||||
```
|
||||
|
||||
## 🔄 Обновление
|
||||
|
||||
### Основная версия
|
||||
```bash
|
||||
docker pull git.shts.su/[repository]:latest
|
||||
docker stop s3-lists-manager
|
||||
docker rm s3-lists-manager
|
||||
docker run -d \
|
||||
--name s3-lists-manager \
|
||||
-p 3001:3001 \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
### Tabler версия
|
||||
```bash
|
||||
docker pull git.shts.su/[repository]:tabler
|
||||
docker stop s3-lists-manager-tabler
|
||||
docker rm s3-lists-manager-tabler
|
||||
docker run -d \
|
||||
--name s3-lists-manager-tabler \
|
||||
-p 3001:3001 \
|
||||
git.shts.su/[repository]:tabler
|
||||
```
|
||||
|
||||
## 🐳 Docker Compose
|
||||
|
||||
Создайте файл `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
s3-lists-manager:
|
||||
image: git.shts.su/[repository]:latest
|
||||
container_name: s3-lists-manager
|
||||
ports:
|
||||
- "3001:3001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
|
||||
s3-lists-manager-tabler:
|
||||
image: git.shts.su/[repository]:tabler
|
||||
container_name: s3-lists-manager-tabler
|
||||
ports:
|
||||
- "3002:3001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
```
|
||||
|
||||
Запуск:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 🔧 Переменные окружения
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|------------|----------|--------------|
|
||||
| `NODE_ENV` | Окружение Node.js | `production` |
|
||||
| `PORT` | Порт для запуска сервера | `3001` |
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
### Просмотр логов
|
||||
```bash
|
||||
# Основная версия
|
||||
docker logs s3-lists-manager
|
||||
|
||||
# Tabler версия
|
||||
docker logs s3-lists-manager-tabler
|
||||
```
|
||||
|
||||
### Статистика контейнеров
|
||||
```bash
|
||||
docker stats s3-lists-manager s3-lists-manager-tabler
|
||||
```
|
||||
|
||||
## 🔍 Отладка
|
||||
|
||||
### Вход в контейнер
|
||||
```bash
|
||||
# Основная версия
|
||||
docker exec -it s3-lists-manager /bin/bash
|
||||
|
||||
# Tabler версия
|
||||
docker exec -it s3-lists-manager-tabler /bin/bash
|
||||
```
|
||||
|
||||
### Проверка процессов
|
||||
```bash
|
||||
docker exec s3-lists-manager ps aux
|
||||
```
|
||||
|
||||
## 🏗️ Сборка локально
|
||||
|
||||
### Основная версия
|
||||
```bash
|
||||
git checkout main
|
||||
docker build -t s3-lists-manager:local .
|
||||
```
|
||||
|
||||
### Tabler версия
|
||||
```bash
|
||||
git checkout tabler
|
||||
docker build -t s3-lists-manager-tabler:local .
|
||||
```
|
||||
|
||||
## 📝 CI/CD
|
||||
|
||||
### Автоматическая сборка
|
||||
|
||||
- **Ветка `main`**: Автоматически создает образ с тегом `latest`
|
||||
- **Ветка `tabler`**: Автоматически создает образ с тегом `tabler`
|
||||
|
||||
### Workflow файлы
|
||||
|
||||
- `.gitea/workflows/docker-publish.yml` - Сборка основной версии
|
||||
- `.gitea/workflows/docker-publish-tabler.yml` - Сборка Tabler версии
|
||||
|
||||
## 🔒 Безопасность
|
||||
|
||||
### Проверка образа
|
||||
```bash
|
||||
docker scan git.shts.su/[repository]:latest
|
||||
docker scan git.shts.su/[repository]:tabler
|
||||
```
|
||||
|
||||
### Запуск в изолированной сети
|
||||
```bash
|
||||
docker network create s3-lists-network
|
||||
docker run -d \
|
||||
--name s3-lists-manager \
|
||||
--network s3-lists-network \
|
||||
-p 3001:3001 \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
## 📈 Производительность
|
||||
|
||||
### Ограничение ресурсов
|
||||
```bash
|
||||
docker run -d \
|
||||
--name s3-lists-manager \
|
||||
--memory=512m \
|
||||
--cpus=0.5 \
|
||||
-p 3001:3001 \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
### Мониторинг ресурсов
|
||||
```bash
|
||||
docker stats --no-stream s3-lists-manager
|
||||
```
|
||||
|
||||
## 🆘 Устранение неполадок
|
||||
|
||||
### Проверка статуса контейнера
|
||||
```bash
|
||||
docker ps -a | grep s3-lists-manager
|
||||
```
|
||||
|
||||
### Перезапуск контейнера
|
||||
```bash
|
||||
docker restart s3-lists-manager
|
||||
```
|
||||
|
||||
### Очистка неиспользуемых образов
|
||||
```bash
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Примечание**: Замените `[repository]` на актуальное имя вашего репозитория в Gitea.
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
# Stage 1: Build React frontend
|
||||
FROM node:18-slim AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Copy package.json ONLY and install dependencies from scratch
|
||||
# This avoids issues with lock files generated on different OS (e.g. Windows -> Linux)
|
||||
COPY frontend/package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy rest of the frontend code and build
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Setup Node.js backend and serve everything
|
||||
FROM node:18-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json ONLY and install production dependencies from scratch
|
||||
COPY backend/package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copy rest of the backend code
|
||||
COPY backend/ .
|
||||
|
||||
# Copy built frontend assets from the previous stage
|
||||
COPY --from=frontend-builder /app/frontend/dist ./public
|
||||
|
||||
# The port the backend runs on
|
||||
EXPOSE 3001
|
||||
|
||||
# Start the server
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,48 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
# Stage 1: Build React frontend on glibc
|
||||
FROM node:20-bookworm-slim AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
|
||||
ENV NODE_ENV=development
|
||||
RUN npm config set registry https://registry.npmjs.org/ \
|
||||
&& npm config set fetch-timeout 300000 \
|
||||
&& npm config set fetch-retry-mintimeout 20000 \
|
||||
&& npm config set fetch-retry-maxtimeout 120000
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm install --no-audit --no-fund
|
||||
|
||||
COPY frontend/src/ ./src/
|
||||
COPY frontend/public/ ./public/
|
||||
COPY frontend/index.html ./
|
||||
COPY frontend/vite.config.js ./
|
||||
COPY frontend/eslint.config.js ./
|
||||
|
||||
ENV ROLLUP_SKIP_NODEJS_NATIVE=1
|
||||
ENV ROLLUP_NO_NATIVE=1
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Install backend deps on glibc
|
||||
FROM node:20-bookworm-slim AS backend-builder
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN npm config set registry https://registry.npmjs.org/ \
|
||||
&& npm config set fetch-timeout 300000
|
||||
|
||||
COPY backend/package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --only=production --no-audit --no-fund
|
||||
|
||||
# Stage 3: Minimal runtime (distroless)
|
||||
FROM gcr.io/distroless/nodejs20-debian12:nonroot
|
||||
WORKDIR /app
|
||||
|
||||
# Copy runtime files
|
||||
COPY --from=backend-builder /app/node_modules ./node_modules
|
||||
COPY backend/server.js ./server.js
|
||||
COPY backend/lib ./lib
|
||||
COPY --from=frontend-builder /app/frontend/dist ./public
|
||||
|
||||
EXPOSE 3001
|
||||
USER nonroot
|
||||
CMD ["server.js"]
|
||||
@@ -0,0 +1,321 @@
|
||||
# Улучшения 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,24 +1,164 @@
|
||||
# Router Lists UI
|
||||
|
||||
Полноценный UI/Backend для управления списками BGP (домены, IP-диапазоны, ASNs), фильтрами и конфигурациями MikroTik на базе S3 (Yandex Object Storage). Интерфейс построен на Tabler, frontend — Vite + React, backend — Express.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Требования
|
||||
- Node.js 18+
|
||||
- S3-совместимое хранилище (Yandex Object Storage)
|
||||
- Доступы AWS: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET_NAME`, `AWS_REGION`
|
||||
|
||||
### Запуск backend
|
||||
```powershell
|
||||
cd backend
|
||||
npm i
|
||||
# создайте .env и заполните (пример ниже)
|
||||
npm start
|
||||
```
|
||||
Сервис поднимется на `http://localhost:3001`.
|
||||
|
||||
### Запуск frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm i
|
||||
npm run dev
|
||||
```
|
||||
Frontend доступен на `http://localhost:5173` (по умолчанию). Production-сборка: `npm run build`.
|
||||
|
||||
## Главные особенности
|
||||
- Единая UX/UI библиотека Tabler, адаптивные панели действий и навигация.
|
||||
- Онлайн-обновление (WebSocket) BGP c потоковым логом и фоновое обновление (HTTP POST) из UI.
|
||||
- Блокировки (soft-lock) ресурсов с TTL, чтобы избежать гонок при одновременном редактировании.
|
||||
- Версионирование данных (история/откат), если включено версии в бакете.
|
||||
- Генерация конфигурации MikroTik из фильтров (`/api/filters/generate-config`) и экспорт в S3.
|
||||
- Метрики Prometheus: `/metrics`, health/ready: `/health`, `/ready`.
|
||||
|
||||
### Важные переменные окружения
|
||||
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET_NAME` — доступ к Object Storage
|
||||
- `CORS_ORIGINS` — список разрешённых Origin через запятую (если пусто — разрешены все)
|
||||
- `LOG_LEVEL` — уровень логов pino (`info` по умолчанию)
|
||||
- `PORT` — порт backend (по умолчанию 3001)
|
||||
- `BGP_BACKGROUND_URL` — адрес фонового обновления BGP, например:
|
||||
- `http://77.232.38.173:8080/api/update_bgp/background?api_key=...`
|
||||
- Используется эндпоинтом прокси `POST /api/update-bgp/background` для обхода CORS
|
||||
|
||||
Пример `.env`:
|
||||
```dotenv
|
||||
PORT=3001
|
||||
LOG_LEVEL=info
|
||||
AWS_ACCESS_KEY_ID=...
|
||||
AWS_SECRET_ACCESS_KEY=...
|
||||
AWS_REGION=ru-central1
|
||||
S3_BUCKET_NAME=...
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=denozord2502
|
||||
```
|
||||
|
||||
## API (backend)
|
||||
|
||||
Все ответы об ошибке имеют единый формат:
|
||||
```json
|
||||
{ "code": "E_*", "message": "...", "details": {}, "requestId": "..." }
|
||||
```
|
||||
Успешные POST/PUT возвращают:
|
||||
```json
|
||||
{ "ok": true, "etag": "...", "lastModified": "ISO", "contentLength": 123 }
|
||||
```
|
||||
И всегда выставляют заголовки `ETag`, `Last-Modified`, `Content-Length-Source` (если есть). GET поддерживают `countOnly=true` там, где это логично.
|
||||
|
||||
### Данные
|
||||
- GET `/api/domains-new` — список доменов `{ domain, community }`
|
||||
- `?q=`, `?offset=`, `?limit=`, `?countOnly=true`, `?format=std`
|
||||
- POST `/api/domains-new` — `{ domains: [{domain, community}], etag }`
|
||||
|
||||
- GET `/api/ip-ranges` — список `[{ ipRange, community }]`
|
||||
- POST `/api/ip-ranges` — `{ ipRanges: [{ipRange, community}], etag }`
|
||||
|
||||
- GET `/api/asns` — список `[{ domain, type }]` (domain = AS, type = community)
|
||||
- POST `/api/asns` — `{ domains: [{domain, type}], etag }`
|
||||
|
||||
- GET `/api/communities` — справочник community (JSON)
|
||||
- POST `/api/communities` — `{ communities: [...] }` (валидация уникальности value)
|
||||
|
||||
### Фильтры и конфигурации
|
||||
- GET `/api/filters` / POST `/api/filters` — фильтры для всех серверов.
|
||||
- GET `/api/filters/generate-config` — сгенерировать конфиг MikroTik из `filters.json`.
|
||||
- POST `/api/filters/export-config` — сохранить сгенерированный конфиг в S3 (`mikrotik-frouting-config.txt`).
|
||||
|
||||
- GET `/api/server-configs` / POST `/api/server-configs` — список серверов (id, name, ...).
|
||||
- GET `/api/server-configs/:serverId` / POST `/api/server-configs/:serverId` — конфиг конкретного сервера.
|
||||
- DELETE `/api/server-configs/:serverId` — удалить конфиг.
|
||||
- DELETE `/api/server-configs/:serverId/complete` — удалить конфиг и фильтры.
|
||||
|
||||
- GET `/api/server-filters/:serverId` — фильтры сервера.
|
||||
- POST `/api/server-filters/:serverId` — сохранить фильтры сервера.
|
||||
- POST `/api/server-filters/generate-config` — сгенерировать конфиг MikroTik на лету из переданных `{ filters }`.
|
||||
|
||||
### Прочее
|
||||
- GET `/api/servers` / POST `/api/servers` — список серверов.
|
||||
- GET `/api/billing` / POST `/api/billing` — биллинг (ноды, статусы и т.п.).
|
||||
- GET `/api/auto-urls` / POST `/api/auto-urls` — список авто-URL.
|
||||
- POST `/api/auto-urls/process` — обработать авто-URL и добавить IP в `bgp_data/ips.txt`.
|
||||
- GET `/api/servers/availability?ttlSeconds=60` — быстрый TCP‑чек доступности нод.
|
||||
- GET `/api/s3/last-modified` — метаданные S3 (etag/lastModified/contentLength) по ключевым файлам.
|
||||
- POST `/api/update-bgp/background` — прокси к фоновой задаче обновления BGP. Требует `BGP_BACKGROUND_URL` в `.env`.
|
||||
- Locks: GET `/api/locks/:resource`, POST `/api/locks/:resource`, DELETE `/api/locks/:resource`.
|
||||
- History: GET `/api/history/:resource`, POST `/api/history/:resource/rollback`.
|
||||
|
||||
## Frontend
|
||||
- Vite + React, Tabler CSS/JS (`@tabler/core`).
|
||||
- Общий компонент `PageHeaderActions` — единый toolbar на страницах данных.
|
||||
- Модалка `WsUpdateModal` — поток логов online-обновления (ws).
|
||||
- Доступность: роли, подписи, фокус-кольца, hot-path без мыши.
|
||||
|
||||
### Скрипты
|
||||
```bash
|
||||
npm run dev # dev-сервер
|
||||
npm run build # продакшн сборка
|
||||
```
|
||||
|
||||
## Структура репозитория
|
||||
```
|
||||
backend/ # Express API
|
||||
frontend/ # Vite React UI
|
||||
```
|
||||
|
||||
## Безопасность и эксплуатация
|
||||
- Helmet, RateLimit, CORS, отключён слабый etag на JSON.
|
||||
- Prometheus метрики по умолчанию.
|
||||
- Для истории версий включите versioning в бакете S3.
|
||||
|
||||
## Лицензия
|
||||
MIT
|
||||
|
||||
# 📂 S3 Lists Manager
|
||||
|
||||
|
||||
Веб-интерфейс для удобного управления текстовыми файлами (`domains.txt`, `asns.txt`) в S3-совместимом хранилище Yandex Cloud. Приложение позволяет в реальном времени просматривать, добавлять, редактировать, удалять и массово изменять записи в этих файлах.
|
||||
Веб-интерфейс для удобного управления файлами в S3-совместимом хранилище Yandex Cloud. Приложение позволяет в реальном времени просматривать, добавлять, редактировать, удалять и массово изменять записи в следующих файлах:
|
||||
|
||||
- **domains.txt** - список доменов и их шлюзов
|
||||
- **asns.txt** - список AS номеров и их шлюзов
|
||||
- **servers.json** - список серверов с расширенной информацией (IP, DNS, страна, провайдер, тип туннеля)
|
||||
|
||||
## ✨ Возможности
|
||||
|
||||
- **Два режима работы:** Управление списками доменов и AS-номеров через вкладки.
|
||||
- **Три режима работы:** Управление списками доменов, AS-номеров и серверов через вкладки.
|
||||
- **CRUD операции:** Полный набор действий: создание, чтение, обновление и удаление записей.
|
||||
- **Поиск в реальном времени:** Мгновенная фильтрация списков по мере ввода.
|
||||
- **Массовая замена:** Быстрое обновление шлюзов для сотен записей в один клик.
|
||||
- **Сохранение в S3:** Все изменения сохраняются непосредственно в файлах в бакете Yandex Cloud.
|
||||
- **Docker-контейнеризация:** Готовый `Dockerfile` для сборки и запуска приложения в изолированном окружении.
|
||||
- **CI/CD с Gitea Actions:** Автоматическая сборка и публикация Docker-образа в Gitea Registry при пуше в `main`.
|
||||
- **Современный интерфейс:** Полностью интегрированный Tabler UI с официальными компонентами.
|
||||
|
||||
## 🛠️ Технологический стек
|
||||
|
||||
| Область | Технология |
|
||||
|--------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| **Фронтенд** | [**React**](https://reactjs.org/) + [**Vite**](https://vitejs.dev/) |
|
||||
| | [**React-Bootstrap**](https://react-bootstrap.github.io/) (UI-компоненты) |
|
||||
| | [**@tabler/core**](https://tabler.io/) (UI-компоненты для Tabler версии) |
|
||||
| | [**Tabler Icons**](https://tabler-icons.io/) (иконки) |
|
||||
| | [**Axios**](https://axios-http.com/) (HTTP-клиент) |
|
||||
| **Бэкенд** | [**Node.js**](https://nodejs.org/) + [**Express**](https://expressjs.com/) |
|
||||
| | [**AWS SDK for JS**](https://aws.amazon.com/sdk-for-javascript/) (для работы с Yandex Cloud S3) |
|
||||
@@ -42,6 +182,7 @@ graph TD
|
||||
C{S3 Bucket}
|
||||
D1[domains.txt]
|
||||
D2[asns.txt]
|
||||
D3[servers.json]
|
||||
end
|
||||
|
||||
A -- HTTP Requests --> B
|
||||
@@ -54,7 +195,7 @@ graph TD
|
||||
|
||||
### Предварительные требования
|
||||
|
||||
- [Node.js](https://nodejs.org/) (v18.x или выше)
|
||||
- [Node.js](https://nodejs.org/) (v20.x или выше)
|
||||
- [npm](https://www.npmjs.com/) или [yarn](https://yarnpkg.com/)
|
||||
- Доступ к бакету Yandex Cloud S3 и сервисный аккаунт с правами на чтение и запись.
|
||||
|
||||
@@ -103,9 +244,41 @@ graph TD
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
Приложение полностью готово к запуску в Docker.
|
||||
Приложение полностью готово к запуску в Docker с двумя вариантами интерфейса.
|
||||
|
||||
### Сборка образа
|
||||
### Доступные образы
|
||||
|
||||
#### Основная версия (main branch)
|
||||
```bash
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
#### Tabler версия (tabler branch)
|
||||
```bash
|
||||
git.shts.su/[repository]:tabler
|
||||
```
|
||||
|
||||
### Быстрый запуск
|
||||
|
||||
#### Основная версия
|
||||
```bash
|
||||
docker run -d \
|
||||
--name s3-lists-manager \
|
||||
-p 3001:3001 \
|
||||
--env-file ./backend/.env \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
#### Tabler версия
|
||||
```bash
|
||||
docker run -d \
|
||||
--name s3-lists-manager-tabler \
|
||||
-p 3002:3001 \
|
||||
--env-file ./backend/.env \
|
||||
git.shts.su/[repository]:tabler
|
||||
```
|
||||
|
||||
### Локальная сборка
|
||||
|
||||
Для сборки образа выполните команду в корневой директории проекта:
|
||||
```bash
|
||||
@@ -122,6 +295,40 @@ docker run --rm -p 3001:3001 --env-file ./backend/.env s3-lists-manager
|
||||
|
||||
После этого приложение будет доступно по адресу `http://localhost:3001`.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Создайте файл `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
s3-lists-manager:
|
||||
image: git.shts.su/[repository]:latest
|
||||
container_name: s3-lists-manager
|
||||
ports:
|
||||
- "3001:3001"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
|
||||
s3-lists-manager-tabler:
|
||||
image: git.shts.su/[repository]:tabler
|
||||
container_name: s3-lists-manager-tabler
|
||||
ports:
|
||||
- "3002:3001"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
```
|
||||
|
||||
Запуск:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Подробная документация по Docker образам доступна в файле [DOCKER.md](DOCKER.md).
|
||||
|
||||
## ⚙️ API Endpoints
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
@@ -129,4 +336,6 @@ docker run --rm -p 3001:3001 --env-file ./backend/.env s3-lists-manager
|
||||
| `GET` | `/api/domains`| Получить список всех доменов. |
|
||||
| `POST` | `/api/domains`| Сохранить изменения в `domains.txt`. |
|
||||
| `GET` | `/api/asns` | Получить список всех AS. |
|
||||
| `POST` | `/api/asns` | Сохранить изменения в `asns.txt`. |
|
||||
| `POST` | `/api/asns` | Сохранить изменения в `asns.txt`. |
|
||||
| `GET` | `/api/servers`| Получить список всех серверов. |
|
||||
| `POST` | `/api/servers`| Сохранить изменения в `servers.json`. |
|
||||
+2309
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,531 @@
|
||||
# 🎨 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)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Модуль валидации синтаксиса MikroTik RouterOS конфигурации
|
||||
*/
|
||||
|
||||
/**
|
||||
* Валидация MikroTik конфигурации
|
||||
* @param {string} config - Текст конфигурации
|
||||
* @returns {object} { valid: boolean, errors: array, warnings: array }
|
||||
*/
|
||||
function validateMikrotikConfig(config) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
if (!config || typeof config !== 'string') {
|
||||
errors.push({ line: 0, message: 'Конфигурация пуста или неверного типа' });
|
||||
return { valid: false, errors, warnings };
|
||||
}
|
||||
|
||||
const lines = config.split('\n');
|
||||
let inBlock = false;
|
||||
let blockName = '';
|
||||
let braceBalance = 0;
|
||||
let currentBlockLine = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNum = i + 1;
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Пропускаем комментарии и пустые строки
|
||||
if (line.startsWith('//') || line.startsWith('#') || line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверка начала блока (например, /routing filter bgp-in-tmp {)
|
||||
if (line.match(/^\/\w+(\s+\w+)*\s+\w+(-\w+)*\s*\{/)) {
|
||||
if (inBlock) {
|
||||
errors.push({ line: lineNum, message: `Вложенные блоки не поддерживаются в RouterOS` });
|
||||
}
|
||||
inBlock = true;
|
||||
blockName = line.match(/^\/\w+(\s+\w+)*/)?.[0] || '';
|
||||
currentBlockLine = lineNum;
|
||||
braceBalance++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Подсчет фигурных скобок
|
||||
const openBraces = (line.match(/\{/g) || []).length;
|
||||
const closeBraces = (line.match(/\}/g) || []).length;
|
||||
braceBalance += openBraces - closeBraces;
|
||||
|
||||
// Проверка закрытия блока
|
||||
if (line === '}') {
|
||||
if (braceBalance === 0) {
|
||||
inBlock = false;
|
||||
blockName = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверка синтаксиса внутри блока
|
||||
if (inBlock) {
|
||||
// Проверка if конструкций
|
||||
if (line.startsWith('if')) {
|
||||
// Проверка наличия условия в скобках
|
||||
if (!line.match(/if\s*\(/)) {
|
||||
errors.push({ line: lineNum, message: `Отсутствует открывающая скобка после if` });
|
||||
}
|
||||
|
||||
// Проверка сбалансированности скобок в условии
|
||||
const conditionPart = line.substring(line.indexOf('('));
|
||||
const openParens = (conditionPart.match(/\(/g) || []).length;
|
||||
const closeParens = (conditionPart.match(/\)/g) || []).length;
|
||||
if (openParens !== closeParens) {
|
||||
errors.push({ line: lineNum, message: `Несбалансированные скобки в условии if` });
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка команд set gw
|
||||
if (line.match(/set\s+gw\s+/)) {
|
||||
const match = line.match(/set\s+gw\s+([^;]+)/);
|
||||
if (match) {
|
||||
const gateway = match[1].trim();
|
||||
// Проверка валидности имени gateway
|
||||
if (!gateway || !/^[a-zA-Z0-9_-]+$/.test(gateway)) {
|
||||
warnings.push({ line: lineNum, message: `Возможно неверное имя gateway: "${gateway}"` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка команд accept/reject
|
||||
if (line.match(/;\s*(accept|reject)\s*;/)) {
|
||||
warnings.push({ line: lineNum, message: `Двойная точка с запятой перед ${line.includes('accept') ? 'accept' : 'reject'}` });
|
||||
}
|
||||
|
||||
// Проверка bgp-communities
|
||||
if (line.includes('bgp-communities includes')) {
|
||||
const match = line.match(/bgp-communities\s+includes\s+(\S+)/);
|
||||
if (match) {
|
||||
const community = match[1].replace(/[()]/g, '');
|
||||
// Базовая проверка формата community
|
||||
if (!community.match(/^\d+:\d+$/)) {
|
||||
warnings.push({ line: lineNum, message: `Community "${community}" может иметь неверный формат (ожидается N:N)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка else конструкций
|
||||
if (line.startsWith('else')) {
|
||||
// else должен следовать после закрывающей скобки if
|
||||
const prevNonEmpty = lines.slice(0, i).reverse().find(l => l.trim() !== '' && !l.trim().startsWith('//'));
|
||||
if (prevNonEmpty && !prevNonEmpty.trim().endsWith('}')) {
|
||||
warnings.push({ line: lineNum, message: `else должен следовать после закрывающей скобки блока if` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка команд вне блока
|
||||
if (!inBlock && line.startsWith('/')) {
|
||||
// Команды верхнего уровня должны начинаться с /
|
||||
const validTopLevel = ['/routing', '/ip', '/interface', '/system'];
|
||||
const isValidTopLevel = validTopLevel.some(cmd => line.startsWith(cmd));
|
||||
if (!isValidTopLevel) {
|
||||
warnings.push({ line: lineNum, message: `Неизвестная команда верхнего уровня: ${line.substring(0, 30)}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка сбалансированности фигурных скобок
|
||||
if (braceBalance !== 0) {
|
||||
errors.push({ line: 0, message: `Несбалансированные фигурные скобки (баланс: ${braceBalance})` });
|
||||
}
|
||||
|
||||
// Проверка незакрытых блоков
|
||||
if (inBlock) {
|
||||
errors.push({ line: currentBlockLine, message: `Блок "${blockName}" не закрыт` });
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка корректности community value
|
||||
* @param {string} community - Community value (например, 65000:100)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidCommunity(community) {
|
||||
if (!community || typeof community !== 'string') return false;
|
||||
// Формат: N:N где N - число от 0 до 65535
|
||||
const match = community.match(/^(\d+):(\d+)$/);
|
||||
if (!match) return false;
|
||||
const first = Number(match[1]);
|
||||
const second = Number(match[2]);
|
||||
return first >= 0 && first <= 65535 && second >= 0 && second <= 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка корректности имени gateway
|
||||
* @param {string} gateway - Gateway имя
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidGateway(gateway) {
|
||||
if (!gateway || typeof gateway !== 'string') return false;
|
||||
// Допустимы: буквы, цифры, дефис, подчеркивание
|
||||
// Длина: 1-64 символа
|
||||
return /^[a-zA-Z0-9_-]{1,64}$/.test(gateway);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateMikrotikConfig,
|
||||
isValidCommunity,
|
||||
isValidGateway,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Модуль валидации входных данных
|
||||
* Содержит валидаторы для IP, CIDR, доменов, ASN, Community
|
||||
*/
|
||||
|
||||
/**
|
||||
* Валидация IPv4 адреса
|
||||
* @param {string} ip - IP адрес для валидации
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidIPv4(ip) {
|
||||
if (!ip || typeof ip !== 'string') return false;
|
||||
const octets = ip.trim().split('.');
|
||||
if (octets.length !== 4) return false;
|
||||
return octets.every(octet => {
|
||||
if (!/^\d{1,3}$/.test(octet)) return false;
|
||||
const num = Number(octet);
|
||||
return num >= 0 && num <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация IPv6 адреса (базовая)
|
||||
* @param {string} ip - IPv6 адрес
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidIPv6(ip) {
|
||||
if (!ip || typeof ip !== 'string') return false;
|
||||
const segments = ip.trim().split(':');
|
||||
if (segments.length < 3 || segments.length > 8) return false;
|
||||
return segments.every(seg => {
|
||||
if (seg === '') return true; // :: notation
|
||||
return /^[0-9a-fA-F]{1,4}$/.test(seg);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация CIDR блока (IPv4)
|
||||
* @param {string} cidr - CIDR блок (например, 192.168.1.0/24)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidCIDRv4(cidr) {
|
||||
if (!cidr || typeof cidr !== 'string') return false;
|
||||
const parts = cidr.trim().split('/');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
const [ip, mask] = parts;
|
||||
if (!isValidIPv4(ip)) return false;
|
||||
|
||||
if (!/^\d{1,2}$/.test(mask)) return false;
|
||||
const maskNum = Number(mask);
|
||||
return maskNum >= 0 && maskNum <= 32;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация CIDR блока (IPv6)
|
||||
* @param {string} cidr - CIDR блок IPv6
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidCIDRv6(cidr) {
|
||||
if (!cidr || typeof cidr !== 'string') return false;
|
||||
const parts = cidr.trim().split('/');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
const [ip, mask] = parts;
|
||||
if (!isValidIPv6(ip)) return false;
|
||||
|
||||
if (!/^\d{1,3}$/.test(mask)) return false;
|
||||
const maskNum = Number(mask);
|
||||
return maskNum >= 0 && maskNum <= 128;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация доменного имени (FQDN)
|
||||
* Поддержка IDN (интернационализированных доменов)
|
||||
* @param {string} domain - доменное имя
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidDomain(domain) {
|
||||
if (!domain || typeof domain !== 'string') return false;
|
||||
const d = domain.trim().toLowerCase();
|
||||
|
||||
// Проверка на максимальную длину
|
||||
if (d.length > 253) return false;
|
||||
|
||||
// Проверка на IP адрес (не должен быть IP)
|
||||
if (isValidIPv4(d) || isValidIPv6(d)) return false;
|
||||
|
||||
// Базовая проверка структуры домена
|
||||
// Разрешаем буквы, цифры, дефис, точку, и IDN символы
|
||||
const domainRegex = /^([a-z0-9\u00a1-\uffff]([a-z0-9\u00a1-\uffff-]{0,61}[a-z0-9\u00a1-\uffff])?\.)+[a-z\u00a1-\uffff]{2,}$/i;
|
||||
return domainRegex.test(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация wildcard домена (*.example.com)
|
||||
* @param {string} domain - wildcard домен
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidWildcardDomain(domain) {
|
||||
if (!domain || typeof domain !== 'string') return false;
|
||||
const d = domain.trim().toLowerCase();
|
||||
|
||||
// Проверка на wildcard в начале
|
||||
if (d.startsWith('*.')) {
|
||||
const baseDomain = d.substring(2);
|
||||
return isValidDomain(baseDomain);
|
||||
}
|
||||
|
||||
return isValidDomain(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация номера ASN
|
||||
* @param {string|number} asn - номер ASN
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidASN(asn) {
|
||||
if (!asn) return false;
|
||||
const asnStr = String(asn).trim();
|
||||
|
||||
// Формат: AS12345 или просто 12345
|
||||
const asnRegex = /^(AS)?(\d{1,10})$/i;
|
||||
const match = asnStr.match(asnRegex);
|
||||
if (!match) return false;
|
||||
|
||||
const num = Number(match[2]);
|
||||
// ASN диапазон: 0-4294967295 (32-bit)
|
||||
return num >= 0 && num <= 4294967295;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация BGP Community
|
||||
* Поддержка форматов: 65000:100, 65000
|
||||
* @param {string} community - BGP community
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidCommunity(community) {
|
||||
if (!community || typeof community !== 'string') return false;
|
||||
const c = community.trim();
|
||||
|
||||
// Формат: N:N или просто N
|
||||
const communityRegex = /^(\d{1,10})(:(\d{1,10}))?$/;
|
||||
const match = c.match(communityRegex);
|
||||
if (!match) return false;
|
||||
|
||||
const first = Number(match[1]);
|
||||
if (first < 0 || first > 4294967295) return false;
|
||||
|
||||
if (match[3]) {
|
||||
const second = Number(match[3]);
|
||||
if (second < 0 || second > 65535) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация gateway имени (для MikroTik)
|
||||
* @param {string} gateway - имя gateway
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidGateway(gateway) {
|
||||
if (!gateway || typeof gateway !== 'string') return false;
|
||||
const g = gateway.trim();
|
||||
|
||||
// MikroTik gateway: буквы, цифры, дефис, подчеркивание
|
||||
// Длина: 1-64 символа
|
||||
if (g.length < 1 || g.length > 64) return false;
|
||||
|
||||
const gatewayRegex = /^[a-zA-Z0-9_-]+$/;
|
||||
return gatewayRegex.test(g);
|
||||
}
|
||||
|
||||
/**
|
||||
* Санитизация строки от опасных символов
|
||||
* @param {string} str - входная строка
|
||||
* @returns {string}
|
||||
*/
|
||||
function sanitizeString(str) {
|
||||
if (!str || typeof str !== 'string') return '';
|
||||
|
||||
// Удаляем управляющие символы
|
||||
let cleaned = str.replace(/[\x00-\x1F\x7F]/g, '');
|
||||
|
||||
// Экранируем HTML специальные символы
|
||||
cleaned = cleaned
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\//g, '/');
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка на SQL injection паттерны
|
||||
* @param {string} str - строка для проверки
|
||||
* @returns {boolean} true если безопасно
|
||||
*/
|
||||
function isSafeSQLString(str) {
|
||||
if (!str || typeof str !== 'string') return true;
|
||||
|
||||
const dangerousPatterns = [
|
||||
/(\bSELECT\b|\bINSERT\b|\bUPDATE\b|\bDELETE\b|\bDROP\b|\bCREATE\b)/i,
|
||||
/(\bUNION\b|\bJOIN\b)/i,
|
||||
/(--|;|\/\*|\*\/)/,
|
||||
/(\bOR\b|\bAND\b)\s+\d+\s*=\s*\d+/i,
|
||||
];
|
||||
|
||||
return !dangerousPatterns.some(pattern => pattern.test(str));
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка на XSS паттерны
|
||||
* @param {string} str - строка для проверки
|
||||
* @returns {boolean} true если безопасно
|
||||
*/
|
||||
function isSafeXSSString(str) {
|
||||
if (!str || typeof str !== 'string') return true;
|
||||
|
||||
const dangerousPatterns = [
|
||||
/<script/i,
|
||||
/javascript:/i,
|
||||
/on\w+\s*=/i, // onclick=, onerror=, etc
|
||||
/<iframe/i,
|
||||
/<embed/i,
|
||||
/<object/i,
|
||||
];
|
||||
|
||||
return !dangerousPatterns.some(pattern => pattern.test(str));
|
||||
}
|
||||
|
||||
/**
|
||||
* Комплексная валидация входных данных
|
||||
* @param {object} data - объект с данными для валидации
|
||||
* @param {object} schema - схема валидации
|
||||
* @returns {object} { valid: boolean, errors: array }
|
||||
*/
|
||||
function validateData(data, schema) {
|
||||
const errors = [];
|
||||
|
||||
for (const [field, rules] of Object.entries(schema)) {
|
||||
const value = data[field];
|
||||
|
||||
// Проверка required
|
||||
if (rules.required && (value === undefined || value === null || value === '')) {
|
||||
errors.push({ field, message: `Поле ${field} обязательно` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Если поле не обязательно и пустое - пропускаем остальные проверки
|
||||
if (!rules.required && (value === undefined || value === null || value === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверка типа
|
||||
if (rules.type) {
|
||||
switch (rules.type) {
|
||||
case 'ipv4':
|
||||
if (!isValidIPv4(value)) {
|
||||
errors.push({ field, message: `${field} должен быть валидным IPv4 адресом` });
|
||||
}
|
||||
break;
|
||||
case 'cidrv4':
|
||||
if (!isValidCIDRv4(value)) {
|
||||
errors.push({ field, message: `${field} должен быть валидным CIDR блоком` });
|
||||
}
|
||||
break;
|
||||
case 'domain':
|
||||
if (!isValidDomain(value)) {
|
||||
errors.push({ field, message: `${field} должен быть валидным доменом` });
|
||||
}
|
||||
break;
|
||||
case 'asn':
|
||||
if (!isValidASN(value)) {
|
||||
errors.push({ field, message: `${field} должен быть валидным ASN` });
|
||||
}
|
||||
break;
|
||||
case 'community':
|
||||
if (!isValidCommunity(value)) {
|
||||
errors.push({ field, message: `${field} должен быть валидным BGP Community` });
|
||||
}
|
||||
break;
|
||||
case 'gateway':
|
||||
if (!isValidGateway(value)) {
|
||||
errors.push({ field, message: `${field} должен быть валидным gateway именем` });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка минимальной длины
|
||||
if (rules.minLength && String(value).length < rules.minLength) {
|
||||
errors.push({ field, message: `${field} должен быть не короче ${rules.minLength} символов` });
|
||||
}
|
||||
|
||||
// Проверка максимальной длины
|
||||
if (rules.maxLength && String(value).length > rules.maxLength) {
|
||||
errors.push({ field, message: `${field} должен быть не длиннее ${rules.maxLength} символов` });
|
||||
}
|
||||
|
||||
// Проверка безопасности
|
||||
if (rules.checkXSS && !isSafeXSSString(value)) {
|
||||
errors.push({ field, message: `${field} содержит потенциально опасные символы` });
|
||||
}
|
||||
|
||||
if (rules.checkSQL && !isSafeSQLString(value)) {
|
||||
errors.push({ field, message: `${field} содержит недопустимые SQL конструкции` });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isValidIPv4,
|
||||
isValidIPv6,
|
||||
isValidCIDRv4,
|
||||
isValidCIDRv6,
|
||||
isValidDomain,
|
||||
isValidWildcardDomain,
|
||||
isValidASN,
|
||||
isValidCommunity,
|
||||
isValidGateway,
|
||||
sanitizeString,
|
||||
isSafeSQLString,
|
||||
isSafeXSSString,
|
||||
validateData,
|
||||
};
|
||||
|
||||
Generated
+2259
-187
File diff suppressed because it is too large
Load Diff
@@ -16,10 +16,17 @@
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.0.1",
|
||||
"express": "^4.19.2"
|
||||
"express": "^4.19.2",
|
||||
"compression": "^1.7.4",
|
||||
"ajv": "^8.17.1",
|
||||
"helmet": "^7.1.0",
|
||||
"express-rate-limit": "^6.11.2",
|
||||
"pino": "^9.4.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"@aws-sdk/client-s3": "^3.687.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.10"
|
||||
|
||||
+2405
-56
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"hostName": "VDSINA",
|
||||
"purpose": "relay",
|
||||
"country": "RU",
|
||||
"provider": "VDSINA",
|
||||
"loginUrl": "cp.vdsina.com",
|
||||
"monthlyCost": 12.00,
|
||||
"monthlyCostCurrency": "USD",
|
||||
"nextPaymentDate": "2026-04-25",
|
||||
"lastPaymentDate": "2025-05-05",
|
||||
"lastPaymentAmount": 12.00,
|
||||
"lastPaymentCurrency": "USD",
|
||||
"status": "active",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"hostName": "Macloud",
|
||||
"purpose": "bgp",
|
||||
"country": "RU",
|
||||
"provider": "Macloud",
|
||||
"loginUrl": "cp.macloud.ru",
|
||||
"monthlyCost": 7.00,
|
||||
"monthlyCostCurrency": "USD",
|
||||
"nextPaymentDate": "2025-09-02",
|
||||
"lastPaymentDate": "2025-02-03",
|
||||
"lastPaymentAmount": 7.00,
|
||||
"lastPaymentCurrency": "USD",
|
||||
"status": "active",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"hostName": "Hosting VDS",
|
||||
"purpose": "monitoring",
|
||||
"country": "RU",
|
||||
"provider": "Hosting VDS",
|
||||
"loginUrl": "my.hosting-vds.com/",
|
||||
"monthlyCost": 11.95,
|
||||
"monthlyCostCurrency": "USD",
|
||||
"nextPaymentDate": "2025-12-21",
|
||||
"lastPaymentDate": "2025-06-13",
|
||||
"lastPaymentAmount": 10.00,
|
||||
"lastPaymentCurrency": "USD",
|
||||
"status": "active",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"hostName": "Waicore",
|
||||
"purpose": "dns",
|
||||
"country": "DE",
|
||||
"provider": "Waicore",
|
||||
"loginUrl": "my.waicore.com/billmgr",
|
||||
"monthlyCost": 9.60,
|
||||
"monthlyCostCurrency": "USD",
|
||||
"nextPaymentDate": "2026-04-25",
|
||||
"lastPaymentDate": "2025-04-25",
|
||||
"lastPaymentAmount": 9.60,
|
||||
"lastPaymentCurrency": "USD",
|
||||
"status": "active",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"hostName": "IHOR",
|
||||
"purpose": "proxy",
|
||||
"country": "RU",
|
||||
"provider": "IHOR",
|
||||
"loginUrl": "billing.ihor-hosting.ru/billmgr",
|
||||
"monthlyCost": 10.00,
|
||||
"monthlyCostCurrency": "USD",
|
||||
"nextPaymentDate": "2025-09-10",
|
||||
"lastPaymentDate": "2025-06-25",
|
||||
"lastPaymentAmount": 10.00,
|
||||
"lastPaymentCurrency": "USD",
|
||||
"status": "active",
|
||||
"notes": ""
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"ip": "192.168.1.1",
|
||||
"dns": "srv1.shx.su",
|
||||
"country": "RU",
|
||||
"provider": "Yandex",
|
||||
"tunnel": "GRE"
|
||||
},
|
||||
{
|
||||
"ip": "10.0.0.5",
|
||||
"dns": "srv2.shx.su",
|
||||
"country": "US",
|
||||
"provider": "AWS",
|
||||
"tunnel": "IPSec"
|
||||
},
|
||||
{
|
||||
"ip": "172.16.0.10",
|
||||
"dns": "srv3.shx.su",
|
||||
"country": "DE",
|
||||
"provider": "Hetzner",
|
||||
"tunnel": "GRE"
|
||||
},
|
||||
{
|
||||
"ip": "8.8.8.8",
|
||||
"dns": "dns.google",
|
||||
"country": "US",
|
||||
"provider": "Google",
|
||||
"tunnel": "IPSec"
|
||||
},
|
||||
{
|
||||
"ip": "1.1.1.1",
|
||||
"dns": "cloudflare-dns.com",
|
||||
"country": "US",
|
||||
"provider": "Cloudflare",
|
||||
"tunnel": "GRE"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
# Примеры файлов S3 для Router Lists UI
|
||||
|
||||
Эта папка содержит примеры файлов, которые хранятся в S3 для работы с FilterManager.
|
||||
|
||||
## Структура папок
|
||||
|
||||
```
|
||||
s3/
|
||||
├── domains/ # Файлы доменов и IP
|
||||
│ ├── domains.txt # Основные домены
|
||||
│ ├── domains_comunity.txt # Домены с community
|
||||
│ ├── asns.txt # ASN списки
|
||||
│ └── ips.txt # IP-диапазоны с community
|
||||
├── filter-manager/ # Файлы FilterManager
|
||||
│ ├── simple-filters.json # Фильтры упрощённого режима
|
||||
│ ├── server-filters-*.json # Фильтры для каждого сервера
|
||||
│ └── config-*.txt # Сгенерированные конфигурации MikroTik
|
||||
└── server-configs.json # Список серверов (в корне S3)
|
||||
```
|
||||
|
||||
## Описание файлов
|
||||
|
||||
### `server-configs.json`
|
||||
Список всех серверов в расширенном режиме.
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "server-001",
|
||||
"name": "SWE-HIPHOST",
|
||||
"description": "Шведский сервер для стриминга и игр",
|
||||
"enabled": true,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `filter-manager/simple-filters.json`
|
||||
Фильтры для упрощённого режима (применяются ко всем серверам).
|
||||
```json
|
||||
[
|
||||
{
|
||||
"community": "65001:100",
|
||||
"gateway": "SWE-HIPHOST",
|
||||
"description": "Шведский сервер для стриминга"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `filter-manager/server-filters-{serverId}.json`
|
||||
Фильтры для конкретного сервера в расширенном режиме.
|
||||
```json
|
||||
[
|
||||
{
|
||||
"community": "65001:100",
|
||||
"gateway": "SWE-HIPHOST",
|
||||
"description": "Стриминг сервисы"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `domains/ips.txt`
|
||||
IP-диапазоны с community для маршрутизации.
|
||||
```
|
||||
192.168.1.0/24 65001:100
|
||||
10.0.0.0/8 65001:200
|
||||
172.16.0.0/12 65001:300
|
||||
8.8.8.0/24 65001:400
|
||||
```
|
||||
|
||||
### `filter-manager/config-{serverId}.txt`
|
||||
Сгенерированная конфигурация MikroTik для конкретного сервера.
|
||||
```
|
||||
// Frouting filter configuration for MikroTik 7.14+
|
||||
// Generated automatically
|
||||
// Date: 2024-01-20T12:00:00.000Z
|
||||
|
||||
if (
|
||||
(bgp-communities includes 65001:100)
|
||||
or
|
||||
(bgp-communities includes 65001:101)
|
||||
)
|
||||
{
|
||||
set gw SWE-HIPHOST; accept;
|
||||
}
|
||||
else
|
||||
{
|
||||
reject;
|
||||
}
|
||||
```
|
||||
|
||||
## Примеры серверов
|
||||
|
||||
1. **SWE-HIPHOST** (server-001) - Шведский сервер для стриминга
|
||||
2. **NL-AMSTERDAM** (server-002) - Голландский сервер с низкой задержкой
|
||||
3. **DE-FRANKFURT** (server-003) - Немецкий сервер для торрентов (отключен)
|
||||
4. **US-NEWYORK** (server-004) - Американский сервер для Netflix (с каскадной структурой)
|
||||
5. **JP-TOKYO** (server-005) - Японский сервер для аниме и игр
|
||||
|
||||
## Особенности
|
||||
|
||||
- **Каскадная структура**: Сервер US-NEWYORK демонстрирует конфигурацию с несколькими gateway
|
||||
- **Отключённые серверы**: DE-FRANKFURT показывает неактивный сервер
|
||||
- **Разные типы трафика**: Каждый сервер специализируется на определённом типе контента
|
||||
- **Реалистичные community**: Используются AS:community в формате 65001:XXX
|
||||
|
||||
## Использование
|
||||
|
||||
Эти файлы можно использовать для:
|
||||
- Тестирования FilterManager
|
||||
- Демонстрации функциональности
|
||||
- Обучения работе с системой
|
||||
- Восстановления данных после сбоя
|
||||
@@ -0,0 +1,46 @@
|
||||
# BGP Data Files
|
||||
|
||||
Эта папка содержит файлы данных для BGP-маршрутизации:
|
||||
|
||||
## Структура файлов
|
||||
|
||||
### domains.txt
|
||||
Файл с доменами и их типами (старый формат):
|
||||
```
|
||||
example.com 100
|
||||
google.com 101
|
||||
facebook.com 102
|
||||
```
|
||||
|
||||
### domains_community.txt
|
||||
Файл с доменами и их community (новый формат):
|
||||
```
|
||||
example.com 100
|
||||
google.com 101
|
||||
facebook.com 102
|
||||
```
|
||||
|
||||
### asns.txt
|
||||
Файл с номерами AS и их community:
|
||||
```
|
||||
12345 112
|
||||
4545 113
|
||||
6789 114
|
||||
```
|
||||
|
||||
### ips.txt
|
||||
Файл с IP-диапазонами и их community:
|
||||
```
|
||||
192.168.1.0/24 100
|
||||
10.0.0.0/8 101
|
||||
172.16.0.0/12 102
|
||||
```
|
||||
|
||||
## Формат файлов
|
||||
|
||||
Все файлы имеют текстовый формат с разделителем пробел между значениями.
|
||||
Каждая строка содержит два поля:
|
||||
- Первое поле: домен/AS/IP-диапазон
|
||||
- Второе поле: community/тип
|
||||
|
||||
Пустые строки игнорируются при обработке.
|
||||
@@ -0,0 +1,5 @@
|
||||
12345 112
|
||||
4545 113
|
||||
6789 114
|
||||
1011 115
|
||||
1213 116
|
||||
@@ -0,0 +1,5 @@
|
||||
example.com 100
|
||||
google.com 101
|
||||
facebook.com 102
|
||||
youtube.com 103
|
||||
github.com 104
|
||||
@@ -0,0 +1,5 @@
|
||||
example.com 100
|
||||
google.com 101
|
||||
facebook.com 102
|
||||
youtube.com 103
|
||||
github.com 104
|
||||
@@ -0,0 +1,5 @@
|
||||
192.168.1.0/24 100
|
||||
10.0.0.0/8 101
|
||||
172.16.0.0/12 102
|
||||
8.8.8.0/24 103
|
||||
1.1.1.0/24 104
|
||||
@@ -0,0 +1,8 @@
|
||||
192.168.1.0/24 65001:100
|
||||
10.0.0.0/8 65001:200
|
||||
172.16.0.0/12 65001:300
|
||||
8.8.8.0/24 65001:400
|
||||
1.1.1.0/24 65001:500
|
||||
208.67.222.0/24 65001:600
|
||||
9.9.9.0/24 65001:700
|
||||
149.112.112.0/24 65001:800
|
||||
@@ -0,0 +1,18 @@
|
||||
// Frouting filter configuration for MikroTik 7.14+
|
||||
// Generated automatically
|
||||
// Date: 2024-01-20T12:00:00.000Z
|
||||
|
||||
if (
|
||||
(bgp-communities includes 65001:100)
|
||||
or
|
||||
(bgp-communities includes 65001:101)
|
||||
or
|
||||
(bgp-communities includes 65001:102)
|
||||
)
|
||||
{
|
||||
set gw SWE-HIPHOST; accept;
|
||||
}
|
||||
else
|
||||
{
|
||||
reject;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Frouting filter configuration for MikroTik 7.14+
|
||||
// Generated automatically
|
||||
// Date: 2024-01-20T12:00:00.000Z
|
||||
|
||||
if (
|
||||
(bgp-communities includes 65001:200)
|
||||
or
|
||||
(bgp-communities includes 65001:201)
|
||||
or
|
||||
(bgp-communities includes 65001:202)
|
||||
or
|
||||
(bgp-communities includes 65001:203)
|
||||
)
|
||||
{
|
||||
set gw NL-AMSTERDAM; accept;
|
||||
}
|
||||
else
|
||||
{
|
||||
reject;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Frouting filter configuration for MikroTik 7.14+
|
||||
// Generated automatically
|
||||
// Date: 2024-01-20T12:00:00.000Z
|
||||
|
||||
if (
|
||||
(bgp-communities includes 65001:400)
|
||||
or
|
||||
(bgp-communities includes 65001:401)
|
||||
)
|
||||
{
|
||||
set gw US-NEWYORK; accept;
|
||||
}
|
||||
else if (
|
||||
(bgp-communities includes 65001:402)
|
||||
or
|
||||
(bgp-communities includes 65001:403)
|
||||
)
|
||||
{
|
||||
set gw US-LOSANGELES; accept;
|
||||
}
|
||||
else if (
|
||||
(bgp-communities includes 65001:404)
|
||||
)
|
||||
{
|
||||
set gw US-CHICAGO; accept;
|
||||
}
|
||||
else
|
||||
{
|
||||
reject;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Frouting filter configuration for MikroTik 7.14+
|
||||
// Generated automatically
|
||||
// Date: 2024-01-20T12:00:00.000Z
|
||||
|
||||
if (
|
||||
(bgp-communities includes 65001:500)
|
||||
or
|
||||
(bgp-communities includes 65001:501)
|
||||
or
|
||||
(bgp-communities includes 65001:502)
|
||||
or
|
||||
(bgp-communities includes 65001:503)
|
||||
)
|
||||
{
|
||||
set gw JP-TOKYO; accept;
|
||||
}
|
||||
else
|
||||
{
|
||||
reject;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"community": "65001:100",
|
||||
"gateway": "SWE-HIPHOST",
|
||||
"description": "Стриминг сервисы"
|
||||
},
|
||||
{
|
||||
"community": "65001:101",
|
||||
"gateway": "SWE-HIPHOST",
|
||||
"description": "Игровые серверы"
|
||||
},
|
||||
{
|
||||
"community": "65001:102",
|
||||
"gateway": "SWE-HIPHOST",
|
||||
"description": "Социальные сети"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"community": "65001:200",
|
||||
"gateway": "NL-AMSTERDAM",
|
||||
"description": "Игровые платформы"
|
||||
},
|
||||
{
|
||||
"community": "65001:201",
|
||||
"gateway": "NL-AMSTERDAM",
|
||||
"description": "VoIP сервисы"
|
||||
},
|
||||
{
|
||||
"community": "65001:202",
|
||||
"gateway": "NL-AMSTERDAM",
|
||||
"description": "Облачные сервисы"
|
||||
},
|
||||
{
|
||||
"community": "65001:203",
|
||||
"gateway": "NL-AMSTERDAM",
|
||||
"description": "CDN сети"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"community": "65001:300",
|
||||
"gateway": "DE-FRANKFURT",
|
||||
"description": "Торрент трекеры"
|
||||
},
|
||||
{
|
||||
"community": "65001:301",
|
||||
"gateway": "DE-FRANKFURT",
|
||||
"description": "Файлообменники"
|
||||
},
|
||||
{
|
||||
"community": "65001:302",
|
||||
"gateway": "DE-FRANKFURT",
|
||||
"description": "P2P сети"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"community": "65001:400",
|
||||
"gateway": "US-NEWYORK",
|
||||
"description": "Netflix US"
|
||||
},
|
||||
{
|
||||
"community": "65001:401",
|
||||
"gateway": "US-NEWYORK",
|
||||
"description": "Hulu US"
|
||||
},
|
||||
{
|
||||
"community": "65001:402",
|
||||
"gateway": "US-LOSANGELES",
|
||||
"description": "Disney+ US"
|
||||
},
|
||||
{
|
||||
"community": "65001:403",
|
||||
"gateway": "US-LOSANGELES",
|
||||
"description": "HBO Max US"
|
||||
},
|
||||
{
|
||||
"community": "65001:404",
|
||||
"gateway": "US-CHICAGO",
|
||||
"description": "Amazon Prime US"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"community": "65001:500",
|
||||
"gateway": "JP-TOKYO",
|
||||
"description": "Crunchyroll"
|
||||
},
|
||||
{
|
||||
"community": "65001:501",
|
||||
"gateway": "JP-TOKYO",
|
||||
"description": "Funimation"
|
||||
},
|
||||
{
|
||||
"community": "65001:502",
|
||||
"gateway": "JP-TOKYO",
|
||||
"description": "Steam JP"
|
||||
},
|
||||
{
|
||||
"community": "65001:503",
|
||||
"gateway": "JP-TOKYO",
|
||||
"description": "Nintendo Online"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"community": "65001:100",
|
||||
"gateway": "SWE-HIPHOST",
|
||||
"description": "Шведский сервер для стриминга"
|
||||
},
|
||||
{
|
||||
"community": "65001:200",
|
||||
"gateway": "NL-AMSTERDAM",
|
||||
"description": "Голландский сервер для игр"
|
||||
},
|
||||
{
|
||||
"community": "65001:300",
|
||||
"gateway": "DE-FRANKFURT",
|
||||
"description": "Немецкий сервер для торрентов"
|
||||
},
|
||||
{
|
||||
"community": "65001:400",
|
||||
"gateway": "US-NEWYORK",
|
||||
"description": "Американский сервер для Netflix"
|
||||
},
|
||||
{
|
||||
"community": "65001:500",
|
||||
"gateway": "JP-TOKYO",
|
||||
"description": "Японский сервер для аниме"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"id": "server-001",
|
||||
"name": "SWE-HIPHOST",
|
||||
"description": "Шведский сервер для стриминга и игр",
|
||||
"enabled": true,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "server-002",
|
||||
"name": "NL-AMSTERDAM",
|
||||
"description": "Голландский сервер с низкой задержкой",
|
||||
"enabled": true,
|
||||
"createdAt": "2024-01-16T14:20:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "server-003",
|
||||
"name": "DE-FRANKFURT",
|
||||
"description": "Немецкий сервер для торрентов",
|
||||
"enabled": false,
|
||||
"createdAt": "2024-01-17T09:15:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "server-004",
|
||||
"name": "US-NEWYORK",
|
||||
"description": "Американский сервер для Netflix",
|
||||
"enabled": true,
|
||||
"createdAt": "2024-01-18T16:45:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "server-005",
|
||||
"name": "JP-TOKYO",
|
||||
"description": "Японский сервер для аниме и игр",
|
||||
"enabled": true,
|
||||
"createdAt": "2024-01-19T11:30:00.000Z"
|
||||
}
|
||||
]
|
||||
+170
-7
@@ -1,12 +1,175 @@
|
||||
# React + Vite
|
||||
# S3 Lists Manager - Frontend
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
Современный веб-интерфейс для управления списками доменов и AS с использованием Tabler UI.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## 🎨 Особенности
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
- **Современный UI/UX** - Полностью переработанный интерфейс в стиле Tabler
|
||||
- **Адаптивный дизайн** - Отлично работает на всех устройствах
|
||||
- **Иконки Tabler** - Красивые иконки для улучшения пользовательского опыта
|
||||
- **Интерактивные элементы** - Анимации, hover-эффекты, модальные окна
|
||||
- **Статистика** - Отображение количества элементов в реальном времени
|
||||
- **Поиск** - Быстрый поиск по спискам
|
||||
- **Массовые операции** - Возможность массового обновления шлюзов
|
||||
- **Подтверждения** - Модальные окна для подтверждения важных действий
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
## 🚀 Технологии
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
- **React 19** - Современная библиотека для создания пользовательских интерфейсов
|
||||
- **Tabler Icons** - Красивые иконки в стиле Tabler
|
||||
- **CSS Variables** - Современные CSS переменные для легкой кастомизации
|
||||
- **Responsive Design** - Адаптивный дизайн для всех устройств
|
||||
- **Vite** - Быстрый сборщик проекта
|
||||
|
||||
## 📦 Установка
|
||||
|
||||
1. Убедитесь, что у вас установлен Node.js (версия 16 или выше)
|
||||
|
||||
2. Перейдите в папку frontend:
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
|
||||
3. Установите зависимости:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
4. Запустите проект в режиме разработки:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Откройте браузер и перейдите по адресу: `http://localhost:5173`
|
||||
|
||||
## 🏗️ Сборка для продакшена
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Собранные файлы будут находиться в папке `dist/`.
|
||||
|
||||
## 🎯 Использование
|
||||
|
||||
### Управление доменами
|
||||
|
||||
1. **Добавление домена**:
|
||||
- Введите имя домена (например: `example.com`)
|
||||
- Выберите шлюз (по умолчанию: `SharkFIN`)
|
||||
- Нажмите "Добавить"
|
||||
|
||||
2. **Редактирование**:
|
||||
- Нажмите на иконку редактирования рядом с доменом
|
||||
- Измените шлюз
|
||||
- Нажмите "Сохранить" или "Отмена"
|
||||
|
||||
3. **Удаление**:
|
||||
- Нажмите на иконку удаления
|
||||
- Подтвердите удаление в модальном окне
|
||||
|
||||
### Управление AS
|
||||
|
||||
Аналогично управлению доменами, но для AS номеров (например: `AS12345`).
|
||||
|
||||
### Массовые операции
|
||||
|
||||
1. Введите текущий шлюз в поле "Заменить с"
|
||||
2. Введите новый шлюз в поле "Заменить на"
|
||||
3. Нажмите "Выполнить замену"
|
||||
4. Подтвердите операцию
|
||||
|
||||
### Сохранение изменений
|
||||
|
||||
После внесения изменений обязательно нажмите "Сохранить в S3" для записи изменений в базу данных.
|
||||
|
||||
## 🎨 Кастомизация
|
||||
|
||||
### Цветовая схема
|
||||
|
||||
Основные цвета можно изменить в CSS переменных в файле `src/App.css`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--tblr-primary: #206bc4; /* Основной цвет */
|
||||
--tblr-success: #2fb344; /* Цвет успеха */
|
||||
--tblr-warning: #f59f00; /* Цвет предупреждения */
|
||||
--tblr-danger: #d63939; /* Цвет ошибки */
|
||||
}
|
||||
```
|
||||
|
||||
### Шрифты
|
||||
|
||||
Проект использует шрифт Inter. Для изменения шрифта обновите переменную:
|
||||
|
||||
```css
|
||||
--tblr-font-family-sans-serif: "Ваш шрифт", -apple-system, BlinkMacSystemFont, ...;
|
||||
```
|
||||
|
||||
## 📱 Адаптивность
|
||||
|
||||
Интерфейс полностью адаптивен и оптимизирован для:
|
||||
|
||||
- **Desktop** (1200px+) - Полный интерфейс с боковой панелью
|
||||
- **Tablet** (768px - 1199px) - Адаптированный интерфейс
|
||||
- **Mobile** (до 767px) - Мобильная версия с выпадающим меню
|
||||
|
||||
## 🔧 Разработка
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
src/
|
||||
├── App.jsx # Главный компонент приложения
|
||||
├── App.css # Стили Tabler
|
||||
├── DataManager.jsx # Компонент управления данными
|
||||
├── main.jsx # Точка входа
|
||||
└── index.css # Базовые стили
|
||||
```
|
||||
|
||||
### Добавление новых функций
|
||||
|
||||
1. Создайте новый компонент в папке `src/`
|
||||
2. Импортируйте необходимые иконки из `@tabler/icons-react`
|
||||
3. Используйте CSS классы Tabler для стилизации
|
||||
4. Добавьте адаптивность для мобильных устройств
|
||||
|
||||
### Иконки
|
||||
|
||||
Все иконки импортируются из `@tabler/icons-react`:
|
||||
|
||||
```jsx
|
||||
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
|
||||
// Использование
|
||||
<IconPlus className="icon" />
|
||||
```
|
||||
|
||||
## 🐛 Отладка
|
||||
|
||||
### Консоль разработчика
|
||||
|
||||
Откройте консоль разработчика (F12) для просмотра ошибок и логов.
|
||||
|
||||
### Проверка API
|
||||
|
||||
Убедитесь, что бэкенд запущен и доступен по адресу `/api`.
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
Этот проект использует лицензию MIT.
|
||||
|
||||
## 🤝 Вклад в проект
|
||||
|
||||
1. Форкните репозиторий
|
||||
2. Создайте ветку для новой функции
|
||||
3. Внесите изменения
|
||||
4. Создайте Pull Request
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
Если у вас есть вопросы или проблемы, создайте Issue в репозитории.
|
||||
|
||||
---
|
||||
|
||||
**S3 Lists Manager** - Современный интерфейс для управления списками с использованием Tabler UI.
|
||||
|
||||
+11
-2
@@ -1,13 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
<meta name="description" content="S3 Lists Manager - Управление списками доменов и AS" />
|
||||
<meta name="theme-color" content="#206bc4" />
|
||||
|
||||
<!-- Inter Font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<title>S3 Lists Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- Удалён импорт Tabler JS, теперь он импортируется в main.jsx -->
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+707
-323
File diff suppressed because it is too large
Load Diff
+14
-3
@@ -10,11 +10,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/core": "^1.3.2",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"axios": "^1.10.0",
|
||||
"bootstrap": "^5.3.7",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"html-to-image": "^1.11.11",
|
||||
"react": "^19.1.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^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": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
@@ -26,5 +31,11 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.2.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-musl": "4.40.0"
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": "4.40.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,806 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
|
||||
import CommunityBadge from './components/CommunityBadge.jsx';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDatabase,
|
||||
IconRefresh,
|
||||
IconUpload,
|
||||
IconDownload,
|
||||
IconDeviceFloppy,
|
||||
IconHash,
|
||||
IconClock,
|
||||
IconFileText
|
||||
} from '@tabler/icons-react';
|
||||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
import EmptyState from './components/EmptyState.jsx';
|
||||
import Breadcrumbs from './components/Breadcrumbs.jsx';
|
||||
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||
import WsUpdateModal from './components/WsUpdateModal.jsx';
|
||||
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
||||
import HistoryModal from './components/HistoryModal.jsx';
|
||||
import ErrorAlert from './components/ErrorAlert.jsx';
|
||||
import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
|
||||
import ImportModal from './components/ImportModal.jsx';
|
||||
import QuickAddBar from './components/QuickAddBar.jsx';
|
||||
import AccordionCard from './components/AccordionCard.jsx';
|
||||
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
function ASNsNewManager() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [originalItems, setOriginalItems] = useState([]);
|
||||
const [etag, setEtag] = useState('');
|
||||
const [lastModified, setLastModified] = useState('');
|
||||
const [contentLength, setContentLength] = useState(null);
|
||||
const [newItem, setNewItem] = useState({ asn: '', community: '' });
|
||||
const [newInvalid, setNewInvalid] = useState({ asn: false, community: false });
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const searchTimer = useRef(null);
|
||||
const didInit = useRef(false);
|
||||
const [editingAsn, setEditingAsn] = useState(null);
|
||||
const [editingValue, setEditingValue] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [sortField, setSortField] = useState('asn');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [filterCommunity, setFilterCommunity] = useState('');
|
||||
const editInputRef = useRef(null);
|
||||
const pageSize = 10;
|
||||
const [asnNameMap, setAsnNameMap] = useState({});
|
||||
|
||||
// Справочник community для подсказок
|
||||
const [communities, setCommunities] = useState([]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get(`/communities`);
|
||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
||||
} catch (e) {
|
||||
// тихо игнорируем
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Подгружаем имена ASN из кэша/внешних API (эффект будет размещен ниже после определения paginatedItems)
|
||||
|
||||
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
|
||||
|
||||
const isValidAsn = (value) => /^[0-9]+$/.test(String(value).trim());
|
||||
// Допускаем как числовые, так и строковые (AS:NNN) community
|
||||
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
|
||||
|
||||
const [wsOpen, setWsOpen] = useState(false);
|
||||
const [wsUrl, setWsUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Load WS URL once
|
||||
(async () => {
|
||||
try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' } });
|
||||
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||
const mapped = payload.map(item => ({ asn: String(item.domain), community: String(item.type) }));
|
||||
const total = mapped.length;
|
||||
setItems(mapped);
|
||||
setOriginalItems(mapped);
|
||||
setTotalItems(Number.isFinite(total) ? total : 0);
|
||||
setEtag(response.headers?.etag || '');
|
||||
setLastModified(response.headers?.['last-modified'] || '');
|
||||
const lengthHeader = response.headers?.['content-length-source'];
|
||||
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
|
||||
setError('');
|
||||
} catch (error) {
|
||||
console.error('Error fetching ASNs:', error);
|
||||
setError('Не удалось загрузить список ASN. Проверьте, запущен ли бэкенд.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
||||
|
||||
const handleAddItem = () => {
|
||||
const asnOk = isValidAsn(newItem.asn);
|
||||
const communityOk = isValidCommunity(newItem.community);
|
||||
setNewInvalid({ asn: !asnOk, community: !communityOk });
|
||||
if (!asnOk || !communityOk) {
|
||||
setError('Введите корректный номер ASN и числовой community.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setItems([...items, { asn: String(newItem.asn).trim(), community: String(newItem.community).trim() }]);
|
||||
setNewItem({ asn: '', community: '' });
|
||||
setNewInvalid({ asn: false, community: false });
|
||||
};
|
||||
|
||||
const handleEdit = (item) => {
|
||||
setEditingAsn(item.asn);
|
||||
setEditingValue(item.community);
|
||||
};
|
||||
|
||||
const handleSaveEdit = (asn) => {
|
||||
if (!isValidCommunity(editingValue)) {
|
||||
setError('Community должен быть числом.');
|
||||
return;
|
||||
}
|
||||
const updatedItems = items.map(i =>
|
||||
i.asn === asn ? { ...i, community: String(editingValue).trim() } : i
|
||||
);
|
||||
setItems(updatedItems);
|
||||
setEditingAsn(null);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingAsn(null);
|
||||
};
|
||||
|
||||
const handleDeleteItem = (asnToDelete) => {
|
||||
setItems(items.filter(i => i.asn !== asnToDelete));
|
||||
};
|
||||
|
||||
const confirmDelete = (item) => {
|
||||
setItemToDelete(item);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const executeDelete = () => {
|
||||
if (itemToDelete) {
|
||||
handleDeleteItem(itemToDelete.asn);
|
||||
setShowDeleteModal(false);
|
||||
setItemToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deduplicate = (arr) => {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const i of arr) {
|
||||
const key = String(i.asn).trim();
|
||||
if (!key) continue;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ asn: key, community: String(i.community || '').trim() });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const computeDiff = (before, after) => {
|
||||
const mapBefore = new Map(before.map(i => [i.asn, i]));
|
||||
const mapAfter = new Map(after.map(i => [i.asn, i]));
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const changed = [];
|
||||
for (const [k, v] of mapAfter) {
|
||||
if (!mapBefore.has(k)) { added.push(v); continue; }
|
||||
const prev = mapBefore.get(k);
|
||||
if (String(prev.community) !== String(v.community)) changed.push({ from: prev, to: v });
|
||||
}
|
||||
for (const [k, v] of mapBefore) {
|
||||
if (!mapAfter.has(k)) removed.push(v);
|
||||
}
|
||||
return { added, removed, changed };
|
||||
};
|
||||
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
|
||||
|
||||
const handlePreviewDiff = () => {
|
||||
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
||||
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
||||
const unique = deduplicate(valid);
|
||||
setDiff(computeDiff(originalItems, unique));
|
||||
setShowDiff(true);
|
||||
};
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
||||
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
||||
const unique = deduplicate(valid);
|
||||
setDiff(computeDiff(originalItems, unique));
|
||||
setConfirmSaveOpen(true);
|
||||
};
|
||||
|
||||
const performSave = async () => {
|
||||
setConfirmSaveOpen(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
||||
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
||||
const unique = deduplicate(valid);
|
||||
|
||||
// Загрузим полный список, совместим и сохраним
|
||||
const fullRes = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' } });
|
||||
const fullPayload = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
|
||||
const full = fullPayload.map(item => ({ asn: String(item.domain).trim(), community: String(item.type || '').trim() }));
|
||||
const fullMap = new Map(full.map(i => [i.asn, { asn: i.asn, community: i.community }]));
|
||||
const originalPageMap = new Map(originalItems.map(i => [String(i.asn).trim(), true]));
|
||||
const uniqueMap = new Map(unique.map(i => [String(i.asn).trim(), { asn: i.asn, community: i.community }]));
|
||||
for (const key of originalPageMap.keys()) {
|
||||
if (!uniqueMap.has(key)) fullMap.delete(key);
|
||||
}
|
||||
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
|
||||
const fullToSave = Array.from(fullMap.values()).map(i => ({ domain: i.asn, type: i.community }));
|
||||
|
||||
const et = fullRes?.headers?.etag || etag;
|
||||
const payload = { domains: fullToSave, etag: et };
|
||||
const response = await api.post(`/asns`, payload, { validateStatus: () => true });
|
||||
if (response.status === 412) {
|
||||
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
|
||||
return;
|
||||
}
|
||||
if (response.status >= 400) throw new Error(`Save failed with status ${response.status}`);
|
||||
setEtag(response.headers?.etag || et);
|
||||
await fetchItems();
|
||||
setSuccess('Изменения успешно сохранены!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
notifyMutationSuccess('Изменения сохранены');
|
||||
} catch (error) {
|
||||
console.error('Error saving changes:', error);
|
||||
setError('Не удалось сохранить изменения.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
setImportOpen(true);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
|
||||
const response = await api.get(`/asns`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
|
||||
const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
|
||||
const all = payload.map(item => ({ asn: item.domain, community: item.type }));
|
||||
const header = ['asn', 'community'];
|
||||
const csv = [header, ...all.map(i => [i.asn, i.community])]
|
||||
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `asns_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
window.notify?.error('Экспорт ASN не удался', String(e?.message || e));
|
||||
}
|
||||
};
|
||||
|
||||
// Drag&Drop импорт (быстрый путь)
|
||||
const onDropImport = async (e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||
const parsed = lines.map(l => {
|
||||
const [a, c] = l.split(/\s+/);
|
||||
return { asn: (a || '').trim(), community: (c || '').trim() };
|
||||
}).filter(i => isValidAsn(i.asn) && isValidCommunity(i.community));
|
||||
setItems(prev => {
|
||||
const merged = [...prev, ...parsed];
|
||||
const map = new Map();
|
||||
for (const it of merged) {
|
||||
const key = String(it.asn).trim();
|
||||
if (!map.has(key)) map.set(key, { asn: key, community: String(it.community).trim() });
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
};
|
||||
|
||||
const clearInvalid = () => {
|
||||
setItems(prev => prev.filter(i => i.asn || i.community)
|
||||
.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Импорт через модалку =====
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const parseLine = (line) => {
|
||||
const [a, c] = String(line).split(/\s+/);
|
||||
return { asn: (a || '').trim(), community: (c || '').trim() };
|
||||
};
|
||||
const validateItem = (obj) => isValidAsn(obj.asn) && isValidCommunity(obj.community);
|
||||
const applyImportedItems = (list) => {
|
||||
setItems(prev => {
|
||||
const merged = [...prev, ...list];
|
||||
const map = new Map();
|
||||
for (const it of merged) {
|
||||
const key = String(it.asn).trim();
|
||||
if (!map.has(key)) map.set(key, { asn: key, community: String(it.community).trim() });
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
setImportOpen(false);
|
||||
};
|
||||
|
||||
// Сортировка
|
||||
const sortedItems = [...items].sort((a, b) => {
|
||||
let valA = a[sortField] || '';
|
||||
let valB = b[sortField] || '';
|
||||
if (typeof valA === 'string') valA = valA.toLowerCase();
|
||||
if (typeof valB === 'string') valB = valB.toLowerCase();
|
||||
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Локальная фильтрация и пагинация
|
||||
const filtered = sortedItems.filter(i => {
|
||||
const byCommunity = !filterCommunity || i.community === filterCommunity;
|
||||
const term = String(searchTerm || '').toLowerCase();
|
||||
const bySearch = !term || String(i.asn).toLowerCase().includes(term);
|
||||
return byCommunity && bySearch;
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize);
|
||||
|
||||
// Подгружаем имена ASN из кэша/внешних API (после вычисления paginatedItems)
|
||||
useEffect(() => {
|
||||
const visible = paginatedItems.map(i => i.asn);
|
||||
visible.forEach(async (asn) => {
|
||||
const cached = getAsnNameSync(asn);
|
||||
if (cached !== null && typeof cached !== 'undefined') {
|
||||
setAsnNameMap(prev => ({ ...prev, [asn]: cached }));
|
||||
return;
|
||||
}
|
||||
const name = await getAsnName(asn);
|
||||
if (name !== null) setAsnNameMap(prev => ({ ...prev, [asn]: name }));
|
||||
});
|
||||
}, [JSON.stringify(paginatedItems)]);
|
||||
|
||||
// Для фильтра - список всех уникальных community
|
||||
const allCommunities = Array.from(new Set(items.map(i => String(i.community))));
|
||||
|
||||
// Сортировка по клику
|
||||
const handleSort = (field) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
// Улучшенный инлайн-редакт
|
||||
useEffect(() => {
|
||||
if (editingAsn && editInputRef.current) {
|
||||
editInputRef.current.focus();
|
||||
}
|
||||
}, [editingAsn]);
|
||||
|
||||
// Глобальные хоткеи: Ctrl+S — сохранить изменения
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||||
e.preventDefault();
|
||||
if (!loading) handleSaveChanges();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]);
|
||||
|
||||
const handleEditKeyDown = (e, asn) => {
|
||||
if (e.key === 'Enter') handleSaveEdit(asn);
|
||||
if (e.key === 'Escape') handleCancelEdit();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<ErrorAlert message={error} onClose={() => setError('')} />
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible" role="alert">
|
||||
<div className="d-flex">
|
||||
<div>
|
||||
<IconCheck className="icon alert-icon" />
|
||||
</div>
|
||||
<div>
|
||||
{success}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageHeader
|
||||
title="ASNs"
|
||||
pretitle={(
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Главная', to: '/dashboard' },
|
||||
{ label: 'Данные' },
|
||||
{ label: 'ASNs' },
|
||||
]} />
|
||||
)}
|
||||
actions={(
|
||||
<PageHeaderActions
|
||||
loading={loading}
|
||||
onPreview={handlePreviewDiff}
|
||||
disablePreview={loading || items.length === 0}
|
||||
onRefresh={fetchItems}
|
||||
disableRefresh={loading}
|
||||
onImport={handleImport}
|
||||
onExport={handleExport}
|
||||
disableExport={items.length === 0}
|
||||
onClear={clearInvalid}
|
||||
disableClear={items.length === 0}
|
||||
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }}
|
||||
disableClearCommunities={items.length === 0 || !filterCommunity}
|
||||
onSave={handleSaveChanges}
|
||||
disableSave={loading}
|
||||
onHistory={() => setHistoryOpen(true)}
|
||||
onOnlineUpdate={() => setWsOpen(true)}
|
||||
onBackgroundUpdate={async () => {
|
||||
try {
|
||||
const res = await fetch('/api/update-bgp/background', { method: 'POST' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data?.message || `HTTP ${res.status}`);
|
||||
}
|
||||
notifyMutationSuccess('Фоновое обновление запущено', data);
|
||||
} catch (e) {
|
||||
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="row">
|
||||
<div className="col-lg-3">
|
||||
{/* Classic Add New ASN Card (restored) */}
|
||||
<div className="card card-md">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconPlus className="icon me-2" />
|
||||
Добавить новый ASN
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Номер AS</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control${newInvalid.asn ? ' is-invalid' : ''}`}
|
||||
placeholder="12345"
|
||||
value={newItem.asn}
|
||||
onChange={(e) => setNewItem({ ...newItem, asn: e.target.value })}
|
||||
/>
|
||||
{newInvalid.asn && <div className="invalid-feedback">Только цифры</div>}
|
||||
<div className="form-text">Формат: только цифры, например 12345</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Community</label>
|
||||
<CommunityAutocompleteInput
|
||||
value={newItem.community}
|
||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
||||
communities={communities}
|
||||
placeholder="Начните вводить номер или имя"
|
||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
||||
/>
|
||||
{newInvalid.community && <div className="invalid-feedback">Только цифры</div>}
|
||||
<div className="form-text">Формат: N или N:N (например 65000:100)</div>
|
||||
{(() => {
|
||||
const match = communities.find(c => c.value === String(newItem.community).trim());
|
||||
if (!match) return null;
|
||||
return (
|
||||
<div className="form-text">
|
||||
{match.name && (<><strong>{match.name}</strong> — </>)}
|
||||
{match.description || ''}
|
||||
{match.tags && match.tags.length > 0 && (
|
||||
<> (<span className="text-muted">{match.tags.join(', ')}</span>)</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button type="submit" className="btn btn-primary w-100">
|
||||
<IconPlus className="icon me-2" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Дополнительные варианты */}
|
||||
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false}>
|
||||
<QuickAddBar
|
||||
placeholder={'12345 65000:100'}
|
||||
help={'Вставьте строки: ASN ПРОБЕЛ COMMUNITY'}
|
||||
parseLine={(line) => { const [a, c] = String(line).split(/\s+/); return { asn: (a||'').trim(), community: (c||'').trim() }; }}
|
||||
validateItem={(obj) => isValidAsn(obj.asn) && isValidCommunity(obj.community)}
|
||||
onApply={(list) => {
|
||||
setItems(prev => {
|
||||
const merged = [...prev, ...list];
|
||||
const map = new Map();
|
||||
for (const it of merged) {
|
||||
const key = String(it.asn).trim();
|
||||
if (!map.has(key)) map.set(key, { asn: key, community: String(it.community).trim() });
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="card card-md border-dashed mt-3"
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={onDropImport}
|
||||
>
|
||||
<div className="card-body text-center text-muted">
|
||||
Перетащите файл TXT/CSV сюда для импорта (формат: ASN community)
|
||||
</div>
|
||||
</div>
|
||||
</AccordionCard>
|
||||
{/* Drag & Drop импорт */}
|
||||
<div
|
||||
className="card card-md border-dashed"
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={onDropImport}
|
||||
>
|
||||
<div className="card-body text-center text-muted">
|
||||
Перетащите файл TXT/CSV сюда для импорта (формат: ASN community)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
||||
</div>
|
||||
|
||||
<div className="col-lg-9">
|
||||
<div className="card">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 className="card-title mb-0">Список ASN <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
||||
<div className="d-flex gap-2 w-50">
|
||||
{/* Фильтр по community */}
|
||||
<select
|
||||
className="form-select w-auto"
|
||||
value={filterCommunity}
|
||||
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
|
||||
title={(() => {
|
||||
const meta = communities.find(c => String(c.value) === String(filterCommunity));
|
||||
if (!meta) return 'Все community';
|
||||
const name = meta.name ? meta.name + ' — ' : '';
|
||||
const desc = meta.description || '';
|
||||
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
|
||||
return name + desc + tags;
|
||||
})()}
|
||||
>
|
||||
<option value="">Все community</option>
|
||||
{allCommunities.map(community => {
|
||||
const meta = communities.find(c => String(c.value) === String(community));
|
||||
const label = meta && meta.name ? `${community} — ${meta.name}` : community;
|
||||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community;
|
||||
return (
|
||||
<option key={community} value={String(community)} title={title}>{label}</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{/* Поиск */}
|
||||
<div className="input-icon flex-grow-1">
|
||||
<span className="input-icon-addon">
|
||||
<IconSearch size={18} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск ASN..."
|
||||
value={searchTerm}
|
||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
aria-label="Поиск ASN"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
{/* Мета-информация будет показана внизу карточки */}
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={3} />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3}>
|
||||
<EmptyState
|
||||
title="Нет ASN"
|
||||
description="Импортируйте или добавьте записи, чтобы начать."
|
||||
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
||||
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
|
||||
const header = ['asn','community'];
|
||||
const lines = [header, ['12345','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url);
|
||||
}}>Шаблон CSV</button>}
|
||||
/>
|
||||
</TableEmpty>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="cursor-pointer" onClick={() => handleSort('asn')}>
|
||||
Номер AS
|
||||
{sortField === 'asn' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th>
|
||||
Имя AS
|
||||
</th>
|
||||
<th className="cursor-pointer" onClick={() => handleSort('community')}>
|
||||
Community
|
||||
{sortField === 'community' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="text-end"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedItems.map((item) => (
|
||||
<tr key={item.asn} className={editingAsn === item.asn ? 'table-info' : ''}>
|
||||
<td className="font-monospace" title="ASN">{item.asn}</td>
|
||||
<td title={asnNameMap[item.asn] || ''}>
|
||||
{asnNameMap[item.asn] ? (
|
||||
<span>{asnNameMap[item.asn]}</span>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{renderCommunityBadge(item.community)}</td>
|
||||
<td className="text-end">
|
||||
{editingAsn === item.asn ? (
|
||||
<>
|
||||
<CommunityAutocompleteInput
|
||||
value={editingValue}
|
||||
onChange={setEditingValue}
|
||||
communities={communities}
|
||||
onKeyDown={(e) => handleEditKeyDown(e, item.asn)}
|
||||
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||
/>
|
||||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
||||
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{/* Пагинация */}
|
||||
{totalPages > 1 && (
|
||||
<div className="card-footer d-flex align-items-center justify-content-between">
|
||||
<div className="text-muted">
|
||||
Показано {((currentPage - 1) * pageSize) + 1} - {((currentPage - 1) * pageSize) + paginatedItems.length} из {totalItems || paginatedItems.length}
|
||||
</div>
|
||||
<ul className="pagination m-0">
|
||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>Первая</button>
|
||||
</li>
|
||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>Назад</button>
|
||||
</li>
|
||||
{(() => {
|
||||
const pages = [];
|
||||
let start = Math.max(1, currentPage - 2);
|
||||
let end = Math.min(totalPages, currentPage + 2);
|
||||
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
||||
if (currentPage >= totalPages - 2) 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');
|
||||
return pages.map((p) => (
|
||||
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
||||
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
||||
) : (
|
||||
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(p)}>{p}</button>
|
||||
</li>
|
||||
)
|
||||
));
|
||||
})()}
|
||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages}>Вперед</button>
|
||||
</li>
|
||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}>Последняя</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<S3MetaBar keys={["asns"]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteModal}
|
||||
title={'Удалить ASN?'}
|
||||
message={!itemToDelete ? '' : `Вы уверены, что хотите удалить "${itemToDelete.asn}"? Это действие необратимо.`}
|
||||
confirmText={'Удалить'}
|
||||
cancelText={'Отмена'}
|
||||
destructive
|
||||
size={'sm'}
|
||||
onConfirm={executeDelete}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
<ConfirmDiffModal
|
||||
show={confirmSaveOpen}
|
||||
diff={diff}
|
||||
onConfirm={performSave}
|
||||
onClose={() => setConfirmSaveOpen(false)}
|
||||
/>
|
||||
<ImportModal
|
||||
show={importOpen}
|
||||
title={'Импорт ASN'}
|
||||
description={'Формат: ASN ПРОБЕЛ COMMUNITY'}
|
||||
parseLine={parseLine}
|
||||
validateItem={validateItem}
|
||||
onConfirm={applyImportedItems}
|
||||
onClose={() => setImportOpen(false)}
|
||||
sampleHeader={['asn','community']}
|
||||
placeholder={'12345 65000:100'}
|
||||
/>
|
||||
<HistoryModal
|
||||
resource="asns"
|
||||
show={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onRolledBack={() => fetchItems()}
|
||||
/>
|
||||
<WsUpdateModal
|
||||
show={wsOpen}
|
||||
url={wsUrl}
|
||||
onClose={() => setWsOpen(false)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={clearCommunitiesOpen}
|
||||
title={'Удалить записи community'}
|
||||
message={`Будут удалены все записи с community "${filterCommunity}". Действие НЕ сохраняет изменения автоматически.`}
|
||||
confirmText={'Удалить'}
|
||||
cancelText={'Отмена'}
|
||||
onConfirm={() => { setItems(prev => prev.filter(i => i.community !== filterCommunity)); setClearCommunitiesOpen(false); }}
|
||||
onCancel={() => setClearCommunitiesOpen(false)}
|
||||
/>
|
||||
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ASNsNewManager;
|
||||
+505
-24
@@ -1,24 +1,217 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
/* Custom styles for S3 Lists Manager */
|
||||
/* Most styles are now provided by @tabler/core */
|
||||
|
||||
/* Custom avatar background */
|
||||
.avatar {
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23e9ecef"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>');
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
/* Navbar styles */
|
||||
.navbar {
|
||||
position: relative;
|
||||
z-index: 1030;
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
.navbar-nav .nav-item.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--tblr-body-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link:hover {
|
||||
color: var(--tblr-primary);
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link.active {
|
||||
color: var(--tblr-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link .icon {
|
||||
margin-right: 0.5rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-toggle::after {
|
||||
margin-left: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
min-width: 10rem;
|
||||
padding: 0.5rem 0;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--tblr-body-color);
|
||||
text-align: left;
|
||||
list-style: none;
|
||||
background-color: var(--tblr-bg-surface);
|
||||
background-clip: padding-box;
|
||||
border: 1px solid var(--tblr-border-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-menu-columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
min-width: 16rem;
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-menu-column {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
clear: both;
|
||||
font-weight: 400;
|
||||
color: var(--tblr-body-color);
|
||||
text-align: inherit;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-item:hover {
|
||||
color: var(--tblr-primary);
|
||||
background-color: var(--tblr-bg-surface-hover);
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-item.active {
|
||||
color: var(--tblr-primary);
|
||||
background-color: var(--tblr-primary-lt);
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-item .icon {
|
||||
margin-right: 0.5rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* Navbar brand */
|
||||
.navbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 0.3125rem;
|
||||
padding-bottom: 0.3125rem;
|
||||
margin-right: 1rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: inherit;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
color: var(--tblr-body-color);
|
||||
}
|
||||
|
||||
.navbar-brand:hover {
|
||||
color: var(--tblr-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar-brand-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* User menu */
|
||||
.navbar-nav .nav-item.dropdown .nav-link {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-item.dropdown .avatar {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Убрали глобальную перекраску .badge, чтобы работали текстовые цвета Tabler
|
||||
(например, badge bg-blue-lt text-blue) */
|
||||
|
||||
/* Responsive navbar */
|
||||
@media (max-width: 991.98px) {
|
||||
.navbar-collapse {
|
||||
flex-basis: 100%;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-menu {
|
||||
position: static;
|
||||
float: none;
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
background-color: var(--tblr-bg-surface);
|
||||
border: 1px solid var(--tblr-border-color);
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.08);
|
||||
}
|
||||
.navbar-nav .dropdown-item { padding: 0.5rem 0.75rem; }
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Custom focus states */
|
||||
.btn:focus,
|
||||
.form-control:focus,
|
||||
.nav-link:focus {
|
||||
outline: 2px solid var(--tblr-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.btn:disabled {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Spinning animation for loading icons */
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
@@ -27,16 +220,304 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
/* Utility: pointer cursor */
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Header actions toolbar: single row, nice spacing, scroll on overflow */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px; /* to avoid cutting off focus outline */
|
||||
/* Ensure dropdowns are not covered by following content (e.g., tables) */
|
||||
position: relative; /* local stacking context for toolbar controls */
|
||||
z-index: auto; /* keep toolbar below modals */
|
||||
}
|
||||
.header-actions .btn-group {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.header-actions .btn-group .btn-group { gap: 0; align-items: stretch; }
|
||||
.header-actions .btn-group .dropdown-toggle-split { padding-left: .5rem; padding-right: .5rem; display: inline-flex; align-items: center; height: 100%; }
|
||||
.header-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
height: 100%;
|
||||
}
|
||||
.header-actions .btn svg { width: 18px; height: 18px; }
|
||||
|
||||
/* Ensure dropdowns in header-actions overlay tables/cards correctly but stay below modals */
|
||||
.header-actions .dropdown-menu,
|
||||
.page-header .dropdown-menu {
|
||||
z-index: 1035; /* above navbar(1030), below modal-backdrop(1040) and modal(1050) */
|
||||
}
|
||||
|
||||
/* Keep page-header in normal stacking order (below modals) */
|
||||
.page-header { position: relative; z-index: auto; }
|
||||
|
||||
/* Responsive sizing for small screens */
|
||||
@media (max-width: 992px) {
|
||||
.header-actions { gap: 0.4rem; }
|
||||
.header-actions .btn {
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.header-actions .btn .me-1 { margin-right: 0.4rem !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.page-header .col-auto.ms-auto { width: 100%; }
|
||||
.header-actions { width: 100%; }
|
||||
}
|
||||
|
||||
/* Custom responsive adjustments */
|
||||
@media (max-width: 992px) {
|
||||
.page-wrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
/* Custom dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card {
|
||||
background-color: var(--tblr-dark);
|
||||
border-color: var(--tblr-border-color);
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: var(--tblr-dark);
|
||||
border-color: var(--tblr-border-color);
|
||||
}
|
||||
|
||||
.navbar-vertical {
|
||||
background-color: var(--tblr-dark);
|
||||
border-color: var(--tblr-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
/* ===== UX/UI Enhancement Animations ===== */
|
||||
|
||||
/* Плавные переходы для всех интерактивных элементов */
|
||||
.transition-all {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Анимация появления карточек */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.card.animate-in {
|
||||
animation: fadeInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Анимация при добавлении элемента в таблицу */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.table tbody tr.new-item {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
background-color: rgba(32, 107, 196, 0.1);
|
||||
}
|
||||
|
||||
/* Пульсация при обновлении */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(32, 107, 196, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgba(32, 107, 196, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.badge.updating {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Hover эффекты для интерактивных карточек */
|
||||
.card.interactive {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card.interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Ripple effect для кнопок */
|
||||
@keyframes ripple {
|
||||
to {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-ripple {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-ripple::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.btn-ripple:active::after {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
transition: 0s;
|
||||
animation: ripple 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* Улучшенная анимация skeleton loader */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-glow .placeholder {
|
||||
animation: shimmer 2s infinite linear;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#f8f9fa 0%,
|
||||
#e9ecef 50%,
|
||||
#f8f9fa 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
}
|
||||
|
||||
/* Анимация модальных окон */
|
||||
.modal.fade.show .modal-dialog {
|
||||
animation: modalSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Плавный скролл */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Улучшенные focus rings для доступности */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--tblr-primary, #206bc4);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
.btn:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba(32, 107, 196, 0.3);
|
||||
}
|
||||
|
||||
/* Skip link для клавиатурных пользователей */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--tblr-primary, #206bc4);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
z-index: 10000;
|
||||
border-radius: 0 0 4px 0;
|
||||
transition: top 0.2s;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Hover эффект для строк таблицы с focus */
|
||||
.table tr:focus-within {
|
||||
background-color: rgba(32, 107, 196, 0.05);
|
||||
}
|
||||
|
||||
/* Pulse для кнопок загрузки */
|
||||
.btn.loading {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn.loading::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: pulse-button 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-button {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Анимация badge появления */
|
||||
.badge.animate {
|
||||
animation: badgePop 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
|
||||
@keyframes badgePop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
+400
-35
@@ -1,41 +1,406 @@
|
||||
import { Container, Navbar, Tabs, Tab } from 'react-bootstrap';
|
||||
import DataManager from './DataManager';
|
||||
import { useState, useEffect, createContext, useContext } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
IconBrandTabler,
|
||||
IconWorld,
|
||||
IconNetwork,
|
||||
IconSettings,
|
||||
IconHome,
|
||||
IconDatabase,
|
||||
IconServer,
|
||||
IconFilter,
|
||||
IconDownload,
|
||||
IconCreditCard
|
||||
} from '@tabler/icons-react';
|
||||
import ServerManager from './ServerManager';
|
||||
import FilterManager from './FilterManager';
|
||||
import DomainsNewManager from './DomainsNewManager';
|
||||
import IPRangesManager from './IPRangesManager';
|
||||
import ASNsNewManager from './ASNsNewManager';
|
||||
import AutoUrlManager from './AutoUrlManager';
|
||||
import BillingManager from './BillingManager';
|
||||
import CommunitiesManager from './CommunitiesManager';
|
||||
import Dashboard from './Dashboard';
|
||||
import './App.css';
|
||||
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
||||
import SettingsModal from './components/SettingsModal.jsx';
|
||||
import ToastContainer from './components/ToastContainer.jsx';
|
||||
import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPalette.jsx';
|
||||
// axios не используется напрямую; сетевые вызовы через src/lib/api.js
|
||||
|
||||
function App() {
|
||||
// --- Simple i18n (RU/EN) ---
|
||||
const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k });
|
||||
|
||||
function LanguageProvider({ children }) {
|
||||
const [lang, setLang] = useState(() => localStorage.getItem('lang') || 'ru');
|
||||
useEffect(() => { localStorage.setItem('lang', lang); }, [lang]);
|
||||
const dict = {
|
||||
ru: {
|
||||
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
||||
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
||||
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
||||
light: 'Светлая', dark: 'Тёмная'
|
||||
},
|
||||
en: {
|
||||
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
||||
dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
|
||||
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
|
||||
light: 'Light', dark: 'Dark'
|
||||
}
|
||||
};
|
||||
const t = (key) => (dict[lang] && key in dict[lang] ? dict[lang][key] : key);
|
||||
return (
|
||||
<>
|
||||
<Navbar bg="dark" variant="dark" expand="lg">
|
||||
<Container>
|
||||
<Navbar.Brand href="#">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" className="d-inline-block align-top me-2" viewBox="0 0 16 16">
|
||||
<path d="M4.928 1.428a.5.5 0 0 0-.01.02H2.5A1.5 1.5 0 0 0 1 3v1.5a.5.5 0 0 0 1 0V3a.5.5 0 0 1 .5-.5h2.418a.5.5 0 0 0 .01-.02zM7.5 2.5a.5.5 0 0 1 .5-.5h3.5a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1h-3a.5.5 0 0 1-.5-.5zM.5 5a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 1 .5-.5zM2 7.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
|
||||
<path d="M1.5 14a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 1 0v-1a.5.5 0 0 0-.5-.5zM3 14.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zM13.5 14a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 1 0v-1a.5.5 0 0 0-.5-.5z"/>
|
||||
</svg>
|
||||
S3 Lists Manager
|
||||
</Navbar.Brand>
|
||||
</Container>
|
||||
</Navbar>
|
||||
|
||||
<Container className="mt-4">
|
||||
<Tabs defaultActiveKey="domains" id="main-tabs" className="mb-3" fill>
|
||||
<Tab eventKey="domains" title="Домены">
|
||||
<DataManager
|
||||
entityName="домен"
|
||||
entityKey="domains"
|
||||
placeholder="example.com"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="asns" title="AS">
|
||||
<DataManager
|
||||
entityName="AS"
|
||||
entityKey="asns"
|
||||
placeholder="AS12345"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Container>
|
||||
</>
|
||||
<LanguageContext.Provider value={{ lang, setLang, t }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Theme provider (light/dark) ---
|
||||
const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });
|
||||
|
||||
function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light');
|
||||
useEffect(() => {
|
||||
localStorage.setItem('theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
}, [theme]);
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
Link,
|
||||
useLocation,
|
||||
Navigate
|
||||
} from 'react-router-dom';
|
||||
import { IconMenu2 } from '@tabler/icons-react';
|
||||
|
||||
function App() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
}
|
||||
}
|
||||
});
|
||||
return (
|
||||
<Router>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LanguageProvider>
|
||||
<ThemeProvider>
|
||||
<ToastContainer>
|
||||
<NotifyProvider>
|
||||
<MainLayout />
|
||||
</NotifyProvider>
|
||||
</ToastContainer>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
</QueryClientProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
function MainLayout() {
|
||||
const location = useLocation();
|
||||
const { lang, setLang, t } = useContext(LanguageContext);
|
||||
const { theme, setTheme } = useContext(ThemeContext);
|
||||
// Навбар стал лаконичным: без неиспользуемых уведомлений/иконок
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
// Состояние для управления выпадающими меню
|
||||
const [dropdownStates, setDropdownStates] = useState({
|
||||
data: false,
|
||||
management: false,
|
||||
tools: false
|
||||
});
|
||||
|
||||
// Закрытие всех выпадающих меню при клике вне их области
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (!event.target.closest('.nav-item.dropdown')) {
|
||||
setDropdownStates({
|
||||
data: false,
|
||||
management: false,
|
||||
tools: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleDropdown = (dropdown) => {
|
||||
setDropdownStates(prev => {
|
||||
// Закрываем все выпадающие меню
|
||||
const newState = {
|
||||
data: false,
|
||||
management: false,
|
||||
tools: false
|
||||
};
|
||||
// Открываем только выбранное меню, если оно было закрыто
|
||||
if (!prev[dropdown]) {
|
||||
newState[dropdown] = true;
|
||||
}
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
const navCategories = [
|
||||
{
|
||||
id: 'home',
|
||||
title: t('home'),
|
||||
icon: IconHome,
|
||||
path: '/dashboard',
|
||||
single: true
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
title: t('data'),
|
||||
icon: IconDatabase,
|
||||
items: [
|
||||
{ id: 'domains', title: t('domains'), path: '/domains', icon: IconWorld },
|
||||
{ id: 'ip-ranges', title: t('ipRanges'), path: '/ip-ranges', icon: IconNetwork },
|
||||
{ id: 'asns', title: t('asns'), path: '/asns', icon: IconNetwork },
|
||||
{ id: 'communities', title: t('communities'), path: '/communities', icon: IconFilter }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'management',
|
||||
title: t('management'),
|
||||
icon: IconServer,
|
||||
items: [
|
||||
{ id: 'servers', title: t('servers'), path: '/servers', icon: IconServer },
|
||||
{ id: 'filters', title: t('filters'), path: '/filters', icon: IconFilter },
|
||||
{ id: 'billing', title: t('billing'), path: '/billing', icon: IconCreditCard }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
title: t('tools'),
|
||||
icon: IconSettings,
|
||||
items: [
|
||||
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload }
|
||||
]
|
||||
},
|
||||
// Убрали неиспользуемые/неработающие разделы
|
||||
];
|
||||
|
||||
// Определяем активную вкладку по адресу
|
||||
const getActiveTab = () => {
|
||||
for (const category of navCategories) {
|
||||
if (category.single && location.pathname === category.path) {
|
||||
return category.id;
|
||||
}
|
||||
if (category.items) {
|
||||
for (const item of category.items) {
|
||||
if (location.pathname.startsWith(item.path)) {
|
||||
return item.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'home';
|
||||
};
|
||||
|
||||
const activeTab = getActiveTab();
|
||||
|
||||
return (
|
||||
<div className="page d-flex flex-column min-vh-100">
|
||||
{/* Skip link для доступности */}
|
||||
<a href="#main-content" className="skip-link">
|
||||
Перейти к основному содержанию
|
||||
</a>
|
||||
|
||||
{/* Верхнее меню Tabler Admin */}
|
||||
<header className="navbar navbar-expand-md navbar-light d-print-none" style={{boxShadow: '0 1px 0 0 #e9ecef'}}>
|
||||
<div className="container-xl">
|
||||
{/* Логотип */}
|
||||
<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
|
||||
<Link to="/" className="navbar-brand d-flex align-items-center">
|
||||
<IconBrandTabler className="navbar-brand-icon me-2" />
|
||||
<span className="fw-bold">S3 Lists Manager</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Кнопка бургера для мобильных */}
|
||||
<button
|
||||
className="navbar-toggler d-md-none"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar-menu"
|
||||
aria-controls="navbar-menu"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<IconMenu2 />
|
||||
</button>
|
||||
|
||||
{/* Основное меню */}
|
||||
<div className="collapse navbar-collapse" id="navbar-menu">
|
||||
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
|
||||
<ul className="navbar-nav">
|
||||
{navCategories.map(category => (
|
||||
<li key={category.id} className="nav-item dropdown">
|
||||
{category.single ? (
|
||||
<Link
|
||||
to={category.path}
|
||||
className={`nav-link${activeTab === category.id ? ' active' : ''}`}
|
||||
>
|
||||
<category.icon className="icon icon-tabler" />
|
||||
<span className="nav-link-title">{category.title}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<a
|
||||
className={`nav-link dropdown-toggle${activeTab.startsWith(category.id) ? ' active' : ''}`}
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleDropdown(category.id);
|
||||
}}
|
||||
>
|
||||
<category.icon className="icon icon-tabler" />
|
||||
<span className="nav-link-title">{category.title}</span>
|
||||
</a>
|
||||
{dropdownStates[category.id] && (
|
||||
<div className="dropdown-menu show" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dropdown-menu-columns">
|
||||
<div className="dropdown-menu-column">
|
||||
{category.items.map(item => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
className={`dropdown-item${activeTab === item.id ? ' active' : ''}`}
|
||||
>
|
||||
<item.icon className="icon icon-tabler" />
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Правая часть навбара: язык, тема и действия */}
|
||||
<div className="navbar-nav flex-row order-md-last">
|
||||
{/* Язык */}
|
||||
<div className="nav-item dropdown me-1">
|
||||
<a
|
||||
href="#"
|
||||
className="nav-link px-2 dropdown-toggle"
|
||||
data-bs-toggle="dropdown"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<span className="badge">{lang.toUpperCase()}</span>
|
||||
</a>
|
||||
<div className="dropdown-menu dropdown-menu-end">
|
||||
<a
|
||||
href="#"
|
||||
className={`dropdown-item ${lang === 'ru' ? 'active' : ''}`}
|
||||
onClick={(e) => { e.preventDefault(); setLang('ru'); }}
|
||||
>
|
||||
Русский
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className={`dropdown-item ${lang === 'en' ? 'active' : ''}`}
|
||||
onClick={(e) => { e.preventDefault(); setLang('en'); }}
|
||||
>
|
||||
English
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Тема */}
|
||||
<div className="nav-item dropdown me-1">
|
||||
<a
|
||||
href="#"
|
||||
className="nav-link px-2 dropdown-toggle"
|
||||
data-bs-toggle="dropdown"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
{theme === 'dark' ? '🌙' : '☀️'}
|
||||
</a>
|
||||
<div className="dropdown-menu dropdown-menu-end">
|
||||
<a
|
||||
href="#"
|
||||
className={`dropdown-item ${theme === 'light' ? 'active' : ''}`}
|
||||
onClick={(e) => { e.preventDefault(); setTheme('light'); }}
|
||||
>
|
||||
☀️ {t('light')}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`}
|
||||
onClick={(e) => { e.preventDefault(); setTheme('dark'); }}
|
||||
>
|
||||
🌙 {t('dark')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Горячие клавиши */}
|
||||
<div className="nav-item me-1">
|
||||
<KeyboardShortcutsButton className="nav-link px-2" />
|
||||
</div>
|
||||
|
||||
{/* Настройки */}
|
||||
<div className="nav-item">
|
||||
<a
|
||||
href="#"
|
||||
className="nav-link px-2"
|
||||
onClick={(e) => { e.preventDefault(); setSettingsOpen(true); }}
|
||||
title="Настройки"
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main-content" className="container-xl mt-4 flex-grow-1" tabIndex="-1">
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/domains" element={<DomainsNewManager />} />
|
||||
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
||||
<Route path="/asns" element={<ASNsNewManager />} />
|
||||
<Route path="/communities" element={<CommunitiesManager />} />
|
||||
<Route path="/auto-urls" element={<AutoUrlManager />} />
|
||||
<Route path="/servers" element={<ServerManager />} />
|
||||
<Route path="/billing" element={<BillingManager />} />
|
||||
<Route path="/filters" element={<FilterManager />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<footer className="footer mt-auto py-2">
|
||||
<div className="container-xl text-muted small">
|
||||
Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}
|
||||
</div>
|
||||
</footer>
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<CommandPalette />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import {
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconDownload,
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconLoader,
|
||||
IconUpload,
|
||||
IconDeviceFloppy,
|
||||
IconInfoCircle,
|
||||
IconCircleCheck,
|
||||
IconCopy
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
function AutoUrlManager() {
|
||||
const [urls, setUrls] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [messageType, setMessageType] = useState('');
|
||||
const [touched, setTouched] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchUrls();
|
||||
}, []);
|
||||
|
||||
const fetchUrls = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/auto-urls');
|
||||
setUrls(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching URLs:', error);
|
||||
setMessage('Ошибка при загрузке URL-адресов');
|
||||
setMessageType('error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addUrl = () => {
|
||||
setUrls([...urls, { url: '', community: '' }]);
|
||||
};
|
||||
|
||||
const removeUrl = (index) => {
|
||||
setUrls(urls.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateUrl = (index, field, value) => {
|
||||
const newUrls = [...urls];
|
||||
newUrls[index][field] = value;
|
||||
setUrls(newUrls);
|
||||
setTouched(prev => ({ ...prev, [index]: true }));
|
||||
};
|
||||
|
||||
const isValidHttpUrl = (value) => {
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isValidCommunity = (value) => {
|
||||
return /^[0-9]+$/.test(String(value).trim());
|
||||
};
|
||||
|
||||
const getRowValidity = (row) => ({
|
||||
url: row.url ? isValidHttpUrl(row.url) : false,
|
||||
community: row.community ? isValidCommunity(row.community) : false
|
||||
});
|
||||
|
||||
const hasAtLeastOneValidRow = urls.some(u => isValidHttpUrl(u.url) && isValidCommunity(u.community));
|
||||
|
||||
const saveUrls = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setMessage('');
|
||||
|
||||
// Validate URLs
|
||||
const validUrls = urls
|
||||
.filter(u => isValidHttpUrl(u.url) && isValidCommunity(u.community))
|
||||
.map(u => ({ url: u.url.trim(), community: String(u.community).trim() }));
|
||||
if (validUrls.length === 0) {
|
||||
setMessage('Добавьте хотя бы одну корректную запись (валидный URL и числовой community)');
|
||||
setMessageType('error');
|
||||
return;
|
||||
}
|
||||
|
||||
await api.post('/auto-urls', { urls: validUrls });
|
||||
setUrls(validUrls);
|
||||
setMessage('URL-адреса сохранены успешно');
|
||||
setMessageType('success');
|
||||
} catch (error) {
|
||||
console.error('Error saving URLs:', error);
|
||||
setMessage('Ошибка при сохранении URL-адресов');
|
||||
setMessageType('error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processUrls = async () => {
|
||||
try {
|
||||
setProcessing(true);
|
||||
setMessage('');
|
||||
|
||||
const response = await api.post('/auto-urls/process');
|
||||
setMessage(response.data.message);
|
||||
setMessageType('success');
|
||||
} catch (error) {
|
||||
console.error('Error processing URLs:', error);
|
||||
setMessage(error.response?.data || 'Ошибка при обработке URL-адресов');
|
||||
setMessageType('error');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyExample = async () => {
|
||||
const text = 'https://test.com/ips.txt 555\nhttps://example.com/blacklist.txt 666';
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setMessage('Пример формата скопирован');
|
||||
setMessageType('success');
|
||||
setTimeout(() => setMessage(''), 2000);
|
||||
} catch (e) {
|
||||
setMessage('Не удалось скопировать пример');
|
||||
setMessageType('error');
|
||||
setTimeout(() => setMessage(''), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '200px' }}>
|
||||
<div className="spinner-border" role="status" aria-label="Загрузка"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header d-print-none mb-4">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h2 className="page-title">Автоматические URL</h2>
|
||||
<div className="page-pretitle">Управление автоматическими URL-адресами для загрузки списков IP и доменов</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`alert alert-${messageType === 'error' ? 'danger' : 'success'} alert-dismissible`} role="alert">
|
||||
<div className="d-flex">
|
||||
{messageType === 'error' ? <IconAlertCircle className="me-2" /> : <IconCheck className="me-2" />}
|
||||
{message}
|
||||
</div>
|
||||
<button type="button" className="btn-close" onClick={() => setMessage('')}></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">URL-адреса для автоматической загрузки</h3>
|
||||
<div className="card-actions">
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
onClick={() => {
|
||||
const text = window.prompt('Вставьте строки вида: URL ПРОБЕЛ COMMUNITY (по одной записи на строку)');
|
||||
if (!text) return;
|
||||
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||
const parsed = lines.map(l => {
|
||||
const [u, c] = l.split(/\s+/);
|
||||
return { url: u || '', community: c || '' };
|
||||
});
|
||||
setUrls(prev => [...prev, ...parsed]);
|
||||
}}
|
||||
disabled={saving || processing}
|
||||
title="Массовое добавление из буфера"
|
||||
>
|
||||
<IconUpload className="me-1" />
|
||||
Импорт
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={addUrl}
|
||||
disabled={saving || processing}
|
||||
>
|
||||
<IconPlus className="me-1" />
|
||||
Добавить URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{urls.length === 0 ? (
|
||||
<div className="text-center text-muted py-4">
|
||||
<IconAlertCircle size={48} className="mb-3" />
|
||||
<p>Нет добавленных URL-адресов</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={addUrl}
|
||||
disabled={saving || processing}
|
||||
>
|
||||
<IconPlus className="me-1" />
|
||||
Добавить первый URL
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>URL</th>
|
||||
<th>Community</th>
|
||||
<th className="text-end" style={{ width: 100 }}>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{urls.map((url, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
{(() => {
|
||||
const v = getRowValidity(url);
|
||||
const invalid = touched[index] && !v.url;
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control${invalid ? ' is-invalid' : ''}`}
|
||||
placeholder="https://example.com/ips.txt"
|
||||
value={url.url}
|
||||
onChange={(e) => updateUrl(index, 'url', e.target.value)}
|
||||
disabled={saving || processing}
|
||||
/>
|
||||
{invalid && (
|
||||
<div className="invalid-feedback">Укажите корректный http/https URL</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td>
|
||||
{(() => {
|
||||
const v = getRowValidity(url);
|
||||
const invalid = touched[index] && !v.community;
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control${invalid ? ' is-invalid' : ''}`}
|
||||
placeholder="555"
|
||||
value={url.community}
|
||||
onChange={(e) => updateUrl(index, 'community', e.target.value)}
|
||||
disabled={saving || processing}
|
||||
/>
|
||||
{invalid && (
|
||||
<div className="invalid-feedback">Только цифры, например 555</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<button
|
||||
className="btn btn-outline-danger btn-icon btn-sm"
|
||||
onClick={() => removeUrl(index)}
|
||||
disabled={saving || processing}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<div className="text-muted">
|
||||
{urls.length > 0 && (
|
||||
<span className="badge bg-blue-lt text-blue">Всего URL-адресов: {urls.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="btn btn-outline-secondary me-2"
|
||||
onClick={() => setUrls(urls.filter(u => u.url || u.community))}
|
||||
disabled={saving || processing || urls.length === 0}
|
||||
title="Удалить пустые строки"
|
||||
>
|
||||
Очистить пустые
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary me-2"
|
||||
onClick={saveUrls}
|
||||
disabled={saving || processing || !hasAtLeastOneValidRow}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status" />
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconDeviceFloppy className="me-1" />
|
||||
Сохранить
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={processUrls}
|
||||
disabled={saving || processing || !hasAtLeastOneValidRow}
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status" />
|
||||
Обработка...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconDownload className="me-1" />
|
||||
Загрузить списки
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mt-4">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title"><IconInfoCircle className="me-2" />Информация</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row g-4">
|
||||
<div className="col-md-6">
|
||||
<h4 className="mb-3">Как это работает</h4>
|
||||
<ul className="list-unstyled m-0">
|
||||
<li className="d-flex align-items-start mb-2">
|
||||
<span className="text-blue me-2"><IconCircleCheck size={18} /></span>
|
||||
<span>Добавьте URL-адреса, которые содержат списки IP-адресов или доменных имён</span>
|
||||
</li>
|
||||
<li className="d-flex align-items-start mb-2">
|
||||
<span className="text-blue me-2"><IconCircleCheck size={18} /></span>
|
||||
<span>Укажите community для каждого URL (только цифры)</span>
|
||||
</li>
|
||||
<li className="d-flex align-items-start mb-2">
|
||||
<span className="text-blue me-2"><IconCircleCheck size={18} /></span>
|
||||
<span>Нажмите «Загрузить списки» для обработки всех URL</span>
|
||||
</li>
|
||||
<li className="d-flex align-items-start">
|
||||
<span className="text-blue me-2"><IconCircleCheck size={18} /></span>
|
||||
<span>IP-диапазоны попадут в <code>bgp_data/ips.txt</code>, домены — в <code>bgp_data/domains_community.txt</code></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<h4 className="mb-3">Формат файла</h4>
|
||||
<div className="card card-sm">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Пример</h3>
|
||||
<div className="card-actions">
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={copyExample}>
|
||||
<IconCopy className="me-1" />
|
||||
Копировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<pre
|
||||
className="m-0 p-2 bg-dark text-light rounded"
|
||||
style={{
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace",
|
||||
fontSize: '0.875rem',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<code>{`https://test.com/ips.txt 555\nhttps://example.com/blacklist.txt 666`}</code>
|
||||
</pre>
|
||||
<div className="text-muted small mt-2">Каждая строка: URL и community через пробел</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutoUrlManager;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDatabase,
|
||||
IconRefresh,
|
||||
IconUpload,
|
||||
IconDownload,
|
||||
IconDeviceFloppy,
|
||||
IconHash,
|
||||
IconFilter,
|
||||
IconChartBar,
|
||||
IconList
|
||||
} from '@tabler/icons-react';
|
||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
import EmptyState from './components/EmptyState.jsx';
|
||||
import { TableEmpty } from './components/TableSkeleton.jsx';
|
||||
import Breadcrumbs from './components/Breadcrumbs.jsx';
|
||||
import CommunityStats from './components/CommunityStats.jsx';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
function CommunitiesManager() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortField, setSortField] = useState('value');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
// Состояние для переключения между списком и статистикой
|
||||
const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats'
|
||||
|
||||
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
|
||||
const [editingValue, setEditingValue] = useState(null);
|
||||
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
|
||||
const editRef = useRef(null);
|
||||
|
||||
useEffect(() => { fetchItems(); }, []);
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [dictRes, asnsRes, ipRes, domRes] = await Promise.all([
|
||||
api.get(`/communities`),
|
||||
api.get(`/asns`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
|
||||
api.get(`/ip-ranges`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
|
||||
api.get(`/domains-new`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
|
||||
]);
|
||||
const dict = Array.isArray(dictRes.data) ? dictRes.data : [];
|
||||
const asnsPayload = Array.isArray(asnsRes.data?.items) ? asnsRes.data.items : (Array.isArray(asnsRes.data) ? asnsRes.data : []);
|
||||
const ipPayload = Array.isArray(ipRes.data?.items) ? ipRes.data.items : (Array.isArray(ipRes.data) ? ipRes.data : []);
|
||||
const domPayload = Array.isArray(domRes.data?.items) ? domRes.data.items : (Array.isArray(domRes.data) ? domRes.data : []);
|
||||
|
||||
const usedCommunities = new Set();
|
||||
// ASNs: fields domain/type
|
||||
for (const it of asnsPayload) {
|
||||
const v = String(it.type ?? '').trim();
|
||||
if (v) usedCommunities.add(v);
|
||||
}
|
||||
// IP ranges: fields ipRange/community
|
||||
for (const it of ipPayload) {
|
||||
const v = String(it.community ?? '').trim();
|
||||
if (v) usedCommunities.add(v);
|
||||
}
|
||||
// Domains: fields domain/community
|
||||
for (const it of domPayload) {
|
||||
const v = String(it.community ?? '').trim();
|
||||
if (v) usedCommunities.add(v);
|
||||
}
|
||||
|
||||
const map = new Map();
|
||||
// put dictionary items first
|
||||
for (const d of dict) {
|
||||
const value = String(d.value || '').trim();
|
||||
if (!value) continue;
|
||||
map.set(value, {
|
||||
value,
|
||||
name: String(d.name || ''),
|
||||
description: String(d.description || ''),
|
||||
tags: Array.isArray(d.tags) ? d.tags : [],
|
||||
gatewayDefault: String(d.gatewayDefault || ''),
|
||||
color: String(d.color || ''),
|
||||
_external: false,
|
||||
});
|
||||
}
|
||||
// add unknown ones from usage
|
||||
for (const v of usedCommunities) {
|
||||
if (!map.has(v)) {
|
||||
map.set(v, { value: v, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true });
|
||||
}
|
||||
}
|
||||
setItems(Array.from(map.values()));
|
||||
setError('');
|
||||
} catch (e) {
|
||||
console.error('Error fetching communities:', e);
|
||||
setError('Не удалось загрузить справочник community.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAll = async (data) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// не отправляем служебное поле _external
|
||||
const payload = data.map(({ _external, ...rest }) => rest);
|
||||
await api.post(`/communities`, { communities: payload });
|
||||
setSuccess('Справочник сохранён!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (e) {
|
||||
console.error('Error saving communities:', e);
|
||||
setError(e.response?.data || 'Не удалось сохранить справочник.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toTagsArray = (str) => (str || '').split(',').map(t => t.trim()).filter(Boolean);
|
||||
const fromTagsArray = (arr) => (arr || []).join(', ');
|
||||
|
||||
const addItem = () => {
|
||||
const value = String(newItem.value || '').trim();
|
||||
if (!value) { setError('Поле value обязательно.'); return; }
|
||||
if (items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; }
|
||||
setError('');
|
||||
setItems([...items, {
|
||||
value,
|
||||
name: String(newItem.name || ''),
|
||||
description: String(newItem.description || ''),
|
||||
tags: toTagsArray(newItem.tags),
|
||||
gatewayDefault: String(newItem.gatewayDefault || ''),
|
||||
color: String(newItem.color || ''),
|
||||
}]);
|
||||
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
|
||||
};
|
||||
|
||||
const startEdit = (item) => {
|
||||
setEditingValue(item.value);
|
||||
setEditingDraft({
|
||||
value: item.value,
|
||||
name: item.name || '',
|
||||
description: item.description || '',
|
||||
tags: fromTagsArray(item.tags),
|
||||
gatewayDefault: item.gatewayDefault || '',
|
||||
color: item.color || '',
|
||||
});
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
const value = String(editingDraft.value || '').trim();
|
||||
if (!value) { setError('Поле value обязательно.'); return; }
|
||||
if (value !== editingValue && items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; }
|
||||
const next = items.map(i => i.value === editingValue ? {
|
||||
value,
|
||||
name: String(editingDraft.name || ''),
|
||||
description: String(editingDraft.description || ''),
|
||||
tags: toTagsArray(editingDraft.tags),
|
||||
gatewayDefault: String(editingDraft.gatewayDefault || ''),
|
||||
color: String(editingDraft.color || ''),
|
||||
} : i);
|
||||
setItems(next);
|
||||
setEditingValue(null);
|
||||
};
|
||||
|
||||
const cancelEdit = () => setEditingValue(null);
|
||||
|
||||
const deleteItem = (value) => setItems(items.filter(i => i.value !== value));
|
||||
|
||||
const handleImport = () => {
|
||||
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, gatewayDefault, color)');
|
||||
if (!text) return;
|
||||
try {
|
||||
const arr = JSON.parse(text);
|
||||
if (!Array.isArray(arr)) throw new Error('Ожидается массив');
|
||||
setItems(prev => [...prev, ...arr.filter(x => x && x.value)]);
|
||||
} catch (e) {
|
||||
setError('Неверный JSON.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const blob = new Blob([JSON.stringify(items, null, 2)], { type: 'application/json;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `communities_${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleSave = () => saveAll(items);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const arr = [...items];
|
||||
arr.sort((a, b) => {
|
||||
let va = (a[sortField] ?? '').toString().toLowerCase();
|
||||
let vb = (b[sortField] ?? '').toString().toLowerCase();
|
||||
if (va < vb) return sortOrder === 'asc' ? -1 : 1;
|
||||
if (va > vb) return sortOrder === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return arr;
|
||||
}, [items, sortField, sortOrder]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = searchTerm.trim().toLowerCase();
|
||||
if (!q) return sorted;
|
||||
return sorted.filter(i =>
|
||||
(i.value || '').toLowerCase().includes(q) ||
|
||||
(i.name || '').toLowerCase().includes(q) ||
|
||||
(i.description || '').toLowerCase().includes(q) ||
|
||||
(i.tags || []).some(t => (t || '').toLowerCase().includes(q))
|
||||
);
|
||||
}, [sorted, searchTerm]);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / pageSize) || 1;
|
||||
const page = Math.min(currentPage, totalPages);
|
||||
const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
const changeSort = (f) => {
|
||||
if (sortField === f) setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortField(f); setSortOrder('asc'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={(<><IconFilter className="me-2" />Справочник Community</>)}
|
||||
pretitle={(
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Главная', to: '/dashboard' },
|
||||
{ label: 'Данные' },
|
||||
{ label: 'Community' },
|
||||
]} />
|
||||
)}
|
||||
actions={(
|
||||
<PageHeaderActions
|
||||
loading={loading}
|
||||
onRefresh={fetchItems}
|
||||
disableRefresh={loading}
|
||||
onImport={handleImport}
|
||||
onExport={handleExport}
|
||||
disableExport={items.length === 0}
|
||||
onSave={handleSave}
|
||||
disableSave={loading}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Вкладки: Список / Статистика */}
|
||||
<div className="card mb-3">
|
||||
<div className="card-body p-2">
|
||||
<div className="btn-group w-100" role="group">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${activeTab === 'list' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||
onClick={() => setActiveTab('list')}
|
||||
>
|
||||
<IconList className="me-2" size={18} />
|
||||
Список communities
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${activeTab === 'stats' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
>
|
||||
<IconChartBar className="me-2" size={18} />
|
||||
Статистика использования
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||
<IconX className="me-2" />{error}
|
||||
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible" role="alert">
|
||||
<IconCheck className="me-2" />{success}
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Отображение списка communities */}
|
||||
{activeTab === 'list' && (
|
||||
<div className="row g-3">
|
||||
<div className="col-lg-3">
|
||||
<div className="card card-md">
|
||||
<div className="card-header"><h3 className="card-title"><IconPlus className="me-2" />Добавить</h3></div>
|
||||
<div className="card-body">
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Value</label>
|
||||
<input className="form-control" value={newItem.value} onChange={(e)=>setNewItem({...newItem, value:e.target.value})} placeholder="65001:200 или 100" />
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Название</label>
|
||||
<input className="form-control" value={newItem.name} onChange={(e)=>setNewItem({...newItem, name:e.target.value})} placeholder="Напр. Social" />
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Описание</label>
|
||||
<textarea className="form-control" rows={2} value={newItem.description} onChange={(e)=>setNewItem({...newItem, description:e.target.value})} />
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Теги (через запятую)</label>
|
||||
<input className="form-control" value={newItem.tags} onChange={(e)=>setNewItem({...newItem, tags:e.target.value})} placeholder="video,bank" />
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Gateway по умолчанию</label>
|
||||
<input className="form-control" value={newItem.gatewayDefault} onChange={(e)=>setNewItem({...newItem, gatewayDefault:e.target.value})} placeholder="SWE-HIPHOST" />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Цвет бейджа (опц.)</label>
|
||||
<input className="form-control" value={newItem.color} onChange={(e)=>setNewItem({...newItem, color:e.target.value})} placeholder="blue, green, red ..." />
|
||||
</div>
|
||||
<button className="btn btn-primary w-100" onClick={addItem}><IconPlus className="me-2" />Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Карточка действий не нужна — действия перенесены в page-header */}
|
||||
</div>
|
||||
|
||||
<div className="col-lg-9">
|
||||
<div className="card">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 className="card-title mb-0">Список community <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
||||
<div className="input-icon w-50">
|
||||
<span className="input-icon-addon"><IconSearch size={18} /></span>
|
||||
<input className="form-control" placeholder="Поиск по value, названию, описанию и тегам" value={searchTerm} onChange={(e)=>{setSearchTerm(e.target.value); setCurrentPage(1);}} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="cursor-pointer" onClick={()=>changeSort('value')}>Value {sortField==='value' && <span className="ms-1">{sortOrder==='asc'?'▲':'▼'}</span>}</th>
|
||||
<th className="cursor-pointer" onClick={()=>changeSort('name')}>Название {sortField==='name' && <span className="ms-1">{sortOrder==='asc'?'▲':'▼'}</span>}</th>
|
||||
<th>Описание</th>
|
||||
<th>Теги</th>
|
||||
<th>Gateway</th>
|
||||
<th className="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="text-center text-muted py-4">Загрузка...</td></tr>
|
||||
) : paginated.length === 0 ? (
|
||||
<tr><td colSpan={6} className="text-center text-muted py-4">
|
||||
<EmptyState title="Нет community" description="Добавьте запись, чтобы начать." action={<button className="btn btn-primary" onClick={addItem}>Добавить</button>} />
|
||||
</td></tr>
|
||||
) : paginated.map((item) => (
|
||||
<tr key={item.value} className={editingValue === item.value ? 'table-info' : ''}>
|
||||
<td>
|
||||
{editingValue === item.value ? (
|
||||
<input className="form-control form-control-sm" value={editingDraft.value} onChange={(e)=>setEditingDraft({...editingDraft, value:e.target.value})} ref={editRef} />
|
||||
) : (
|
||||
<>
|
||||
<code className="text-blue">{item.value}</code>
|
||||
{item._external && (
|
||||
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingValue === item.value ? (
|
||||
<input className="form-control form-control-sm" value={editingDraft.name} onChange={(e)=>setEditingDraft({...editingDraft, name:e.target.value})} />
|
||||
) : item.name || <span className="text-muted">—</span>}
|
||||
</td>
|
||||
<td>
|
||||
{editingValue === item.value ? (
|
||||
<input className="form-control form-control-sm" value={editingDraft.description} onChange={(e)=>setEditingDraft({...editingDraft, description:e.target.value})} />
|
||||
) : (
|
||||
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingValue === item.value ? (
|
||||
<input className="form-control form-control-sm" value={editingDraft.tags} onChange={(e)=>setEditingDraft({...editingDraft, tags:e.target.value})} />
|
||||
) : (
|
||||
(item.tags || []).map(t => <span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingValue === item.value ? (
|
||||
<input className="form-control form-control-sm" value={editingDraft.gatewayDefault} onChange={(e)=>setEditingDraft({...editingDraft, gatewayDefault:e.target.value})} />
|
||||
) : (
|
||||
<span className="text-muted">{item.gatewayDefault || '—'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{editingValue === item.value ? (
|
||||
<>
|
||||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={saveEdit}><IconCheck size={16} /></button>
|
||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={cancelEdit}><IconX size={16} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={()=>startEdit(item)}><IconEdit size={16} /></button>
|
||||
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={()=>deleteItem(item.value)}><IconTrash size={16} /></button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="card-footer d-flex align-items-center justify-content-between">
|
||||
<div className="text-muted">Показано {((page - 1) * pageSize) + 1} - {Math.min(page * pageSize, filtered.length)} из {filtered.length}</div>
|
||||
<ul className="pagination m-0">
|
||||
<li className={`page-item${page === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={()=>setCurrentPage(1)} disabled={page===1}>Первая</button>
|
||||
</li>
|
||||
<li className={`page-item${page === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={()=>setCurrentPage(page-1)} disabled={page===1}>Назад</button>
|
||||
</li>
|
||||
<li className={`page-item${page === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={()=>setCurrentPage(page+1)} disabled={page===totalPages}>Вперед</button>
|
||||
</li>
|
||||
<li className={`page-item${page === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={()=>setCurrentPage(totalPages)} disabled={page===totalPages}>Последняя</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Отображение статистики communities */}
|
||||
{activeTab === 'stats' && (
|
||||
<CommunityStats communities={items} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommunitiesManager;
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from './lib/api.js';
|
||||
import {
|
||||
IconWorld,
|
||||
IconNetwork,
|
||||
IconServer,
|
||||
IconClock,
|
||||
IconMapPin,
|
||||
IconCloud,
|
||||
IconShield,
|
||||
IconAlertTriangle,
|
||||
IconRefresh,
|
||||
IconFilter,
|
||||
IconDownload,
|
||||
IconCreditCard,
|
||||
IconSearch
|
||||
} from '@tabler/icons-react';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
import Breadcrumbs from './components/Breadcrumbs.jsx';
|
||||
import TopNStats from './components/TopNStats.jsx';
|
||||
|
||||
function StatCard({ icon: Icon, color, value, title, subtitle, to }) {
|
||||
return (
|
||||
<div className="card h-100 position-relative">
|
||||
<div className="card-body d-flex align-items-center">
|
||||
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
||||
<Icon size={32} />
|
||||
</span>
|
||||
<div className="flex-grow-1">
|
||||
<div className="h3 mb-0 fw-bold">{value} <span className="fs-5 fw-normal">{title}</span></div>
|
||||
<div className="text-muted lh-1">{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
{to && (
|
||||
<Link to={to} className="stretched-link" aria-label={title}></Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ title, value, icon: Icon, color, description }) {
|
||||
return (
|
||||
<div className="card h-100 position-relative">
|
||||
<div className="card-body d-flex align-items-center">
|
||||
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
||||
<Icon size={32} />
|
||||
</span>
|
||||
<div className="flex-grow-1">
|
||||
<div className="h3 mb-0 fw-bold">
|
||||
{value} <span className="fs-5 fw-normal">{title}</span>
|
||||
</div>
|
||||
{description && (
|
||||
<div className="text-muted lh-1">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
// Глобальный поиск удалён по требованию UX
|
||||
const [stats, setStats] = useState({
|
||||
domainsCount: null,
|
||||
ipRangesCount: null,
|
||||
asnsCount: null,
|
||||
serversCount: null,
|
||||
lastModified: null,
|
||||
countriesCount: null,
|
||||
providersCount: null,
|
||||
onlineServers: null,
|
||||
totalServers: null
|
||||
});
|
||||
const [raw, setRaw] = useState({ domains: [], ipRanges: [], asns: [], servers: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStats() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Загружаем данные с обработкой ошибок для каждого endpoint
|
||||
const results = await Promise.allSettled([
|
||||
api.get('/domains-new', { params: { countOnly: true } }),
|
||||
api.get('/ip-ranges', { params: { countOnly: true } }),
|
||||
api.get('/asns', { params: { countOnly: true } }),
|
||||
api.get('/servers'),
|
||||
api.get('/servers/availability', { params: { ttlSeconds: 60 } }),
|
||||
api.get('/s3/last-modified')
|
||||
]);
|
||||
|
||||
// Обрабатываем результаты
|
||||
const domainsRes = results[0];
|
||||
const ipRangesRes = results[1];
|
||||
const asnsRes = results[2];
|
||||
const serversRes = results[3];
|
||||
const availabilityRes = results[4];
|
||||
const s3Res = results[5];
|
||||
|
||||
// Получаем данные серверов для подсчета дополнительной статистики
|
||||
const domains = [];
|
||||
const ipRanges = [];
|
||||
const asns = [];
|
||||
const serversRaw = serversRes.status === 'fulfilled' ? serversRes.value.data : [];
|
||||
const servers = Array.isArray(serversRaw) ? serversRaw : [];
|
||||
setRaw({ domains, ipRanges, asns, servers });
|
||||
const countries = new Set(servers.map(server => server.country).filter(Boolean));
|
||||
const providers = new Set(servers.map(server => server.provider).filter(Boolean));
|
||||
const onlineServers = availabilityRes.status === 'fulfilled' ? (availabilityRes.value.data?.online || 0) : (servers.filter(server => server.status === 'Онлайн').length);
|
||||
|
||||
// Получаем дату последнего обновления
|
||||
// Новый формат: объект с ключами { domainsNew, asns, servers, filters, ipRanges }
|
||||
const lmRaw = s3Res.status === 'fulfilled' ? s3Res.value.data?.domainsNew?.lastModified : null;
|
||||
const lastModified = lmRaw ? new Date(lmRaw).toLocaleString() : new Date().toLocaleString();
|
||||
|
||||
const domainsCount = domainsRes.status === 'fulfilled' && typeof domainsRes.value.data?.total === 'number'
|
||||
? domainsRes.value.data.total
|
||||
: 0;
|
||||
const ipRangesCount = ipRangesRes.status === 'fulfilled' && typeof ipRangesRes.value.data?.total === 'number'
|
||||
? ipRangesRes.value.data.total
|
||||
: 0;
|
||||
const asnsCount = asnsRes.status === 'fulfilled' && typeof asnsRes.value.data?.total === 'number'
|
||||
? asnsRes.value.data.total
|
||||
: 0;
|
||||
|
||||
setStats({
|
||||
domainsCount,
|
||||
ipRangesCount,
|
||||
asnsCount,
|
||||
serversCount: servers.length,
|
||||
lastModified,
|
||||
countriesCount: countries.size,
|
||||
providersCount: providers.size,
|
||||
onlineServers,
|
||||
totalServers: servers.length
|
||||
});
|
||||
|
||||
// Проверяем, есть ли ошибки
|
||||
const errors = results.filter(result => result.status === 'rejected');
|
||||
if (errors.length > 0) {
|
||||
console.warn('Некоторые API недоступны:', errors.map(e => e.reason?.message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Критическая ошибка загрузки статистики:', e);
|
||||
setError('Не удалось загрузить статистику');
|
||||
setStats({
|
||||
domainsCount: null,
|
||||
ipRangesCount: null,
|
||||
asnsCount: null,
|
||||
serversCount: null,
|
||||
lastModified: null,
|
||||
countriesCount: null,
|
||||
providersCount: null,
|
||||
onlineServers: null,
|
||||
totalServers: null
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
<IconAlertTriangle className="me-2" />
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Глобальный поиск удалён */}
|
||||
{/* Заголовок страницы */}
|
||||
<PageHeader
|
||||
title="Панель"
|
||||
pretitle={(
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Главная' },
|
||||
]} />
|
||||
)}
|
||||
actions={(<button className="btn btn-outline-primary" onClick={() => window.location.reload()}><IconRefresh className="me-2" /> Обновить</button>)}
|
||||
/>
|
||||
|
||||
{/* Основные метрики */}
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-md-3">
|
||||
<div className="animate-in">
|
||||
<StatCard
|
||||
icon={IconWorld}
|
||||
color="blue"
|
||||
value={loading ? '...' : (stats.domainsCount ?? '—')}
|
||||
title="Доменов"
|
||||
subtitle="Всего доменов в системе"
|
||||
to="/domains"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="animate-in" style={{animationDelay: '0.1s'}}>
|
||||
<StatCard
|
||||
icon={IconNetwork}
|
||||
color="green"
|
||||
value={loading ? '...' : (stats.ipRangesCount ?? '—')}
|
||||
title="IP-диапазонов"
|
||||
subtitle="Всего IP диапазонов"
|
||||
to="/ip-ranges"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="animate-in" style={{animationDelay: '0.2s'}}>
|
||||
<StatCard
|
||||
icon={IconNetwork}
|
||||
color="purple"
|
||||
value={loading ? '...' : (stats.asnsCount ?? '—')}
|
||||
title="AS"
|
||||
subtitle="Всего Autonomous Systems"
|
||||
to="/asns"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="animate-in" style={{animationDelay: '0.3s'}}>
|
||||
<StatCard
|
||||
icon={IconServer}
|
||||
color="orange"
|
||||
value={loading ? '...' : (stats.serversCount ?? '—')}
|
||||
title="Серверов"
|
||||
subtitle="Всего серверов"
|
||||
to="/servers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Дополнительные метрики */}
|
||||
<div className="row g-3 mb-4 justify-content-center">
|
||||
<div className="col-sm-6 col-md-3">
|
||||
<MetricCard
|
||||
title="Стран"
|
||||
value={loading ? '...' : stats.countriesCount ?? '—'}
|
||||
icon={IconMapPin}
|
||||
color="blue"
|
||||
description="Географическое покрытие"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-6 col-md-3">
|
||||
<MetricCard
|
||||
title="Провайдеров"
|
||||
value={loading ? '...' : stats.providersCount ?? '—'}
|
||||
icon={IconCloud}
|
||||
color="green"
|
||||
description="Облачные провайдеры"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-6 col-md-3">
|
||||
<MetricCard
|
||||
title="Онлайн серверов"
|
||||
value={loading ? '...' : `${stats.onlineServers ?? '—'}/${stats.totalServers ?? '—'}`}
|
||||
icon={IconServer}
|
||||
color="success"
|
||||
description="Активные серверы"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-6 col-md-3">
|
||||
<MetricCard
|
||||
title="Последнее обновление"
|
||||
value={loading ? '...' : (stats.lastModified ? stats.lastModified : '—')}
|
||||
icon={IconClock}
|
||||
color="orange"
|
||||
description="S3 синхронизация"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top-N статистика */}
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-3">Топ статистика</h3>
|
||||
<TopNStats data={{ servers: raw.servers }} loading={loading} />
|
||||
</div>
|
||||
|
||||
{/* Убрали быстрые действия, карточки выше кликабельны */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
+406
-111
@@ -1,6 +1,20 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Table, Button, Form, Row, Col, Card, Alert } from 'react-bootstrap';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDatabase,
|
||||
IconRefresh,
|
||||
IconArrowsExchange,
|
||||
IconDownload,
|
||||
IconUpload,
|
||||
IconAlertTriangle
|
||||
} from '@tabler/icons-react';
|
||||
import ConfirmDialog from './components/ConfirmDialog.jsx'
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
@@ -14,18 +28,32 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
||||
const [editingValue, setEditingValue] = useState('');
|
||||
const [bulkFrom, setBulkFrom] = useState('SharkFIN');
|
||||
const [bulkTo, setBulkTo] = useState('SharkAM');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortField, setSortField] = useState('domain');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [filterType, setFilterType] = useState('');
|
||||
const editInputRef = useRef(null);
|
||||
const pageSize = 10;
|
||||
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, []);
|
||||
}, [entityKey]);
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/${entityKey}`);
|
||||
const response = await api.get(`/${entityKey}`);
|
||||
setItems(response.data);
|
||||
setError('');
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${entityKey}:`, error);
|
||||
setError(`Не удалось загрузить ${entityName}. Проверьте, запущен ли бэкенд.`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,15 +88,30 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
||||
setItems(items.filter(i => i.domain !== domainNameToDelete));
|
||||
};
|
||||
|
||||
const confirmDelete = (item) => {
|
||||
setItemToDelete(item);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const executeDelete = () => {
|
||||
if (itemToDelete) {
|
||||
handleDeleteItem(itemToDelete.domain);
|
||||
setShowDeleteModal(false);
|
||||
setItemToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// The backend expects a payload with a 'domains' key for both endpoints.
|
||||
await axios.post(`${API_URL}/${entityKey}`, { domains: items });
|
||||
await api.post(`/${entityKey}`, { domains: items });
|
||||
setSuccess('Изменения успешно сохранены!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (error) {
|
||||
console.error('Error saving changes:', error);
|
||||
setError('Не удалось сохранить изменения.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,146 +120,398 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
||||
setError('Оба поля для массового обновления должны быть заполнены.');
|
||||
return;
|
||||
}
|
||||
const confirm = window.confirm(`Вы уверены, что хотите заменить все шлюзы "${bulkFrom}" на "${bulkTo}"? Это действие необратимо.`);
|
||||
if (confirm) {
|
||||
const updatedItems = items.map(i =>
|
||||
i.type === bulkFrom ? { ...i, type: bulkTo } : i
|
||||
);
|
||||
setItems(updatedItems);
|
||||
setSuccess(`Шлюзы "${bulkFrom}" были успешно заменены на "${bulkTo}". Не забудьте сохранить изменения.`);
|
||||
}
|
||||
setConfirmState({
|
||||
open: true,
|
||||
text: `Вы уверены, что хотите заменить все шлюзы "${bulkFrom}" на "${bulkTo}"? Это действие необратимо.`,
|
||||
onConfirm: () => {
|
||||
const updatedItems = items.map(i => i.type === bulkFrom ? { ...i, type: bulkTo } : i);
|
||||
setItems(updatedItems);
|
||||
setSuccess(`Шлюзы "${bulkFrom}" были успешно заменены на "${bulkTo}". Не забудьте сохранить изменения.`);
|
||||
setConfirmState({ open: false, text: '', onConfirm: null });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const filteredItems = items.filter(i =>
|
||||
// Сортировка
|
||||
const sortedItems = [...items].sort((a, b) => {
|
||||
let valA = a[sortField] || '';
|
||||
let valB = b[sortField] || '';
|
||||
if (typeof valA === 'string') valA = valA.toLowerCase();
|
||||
if (typeof valB === 'string') valB = valB.toLowerCase();
|
||||
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Фильтрация по шлюзу
|
||||
const filteredByType = filterType
|
||||
? sortedItems.filter(i => i.type === filterType)
|
||||
: sortedItems;
|
||||
|
||||
// Поиск
|
||||
const filteredItems = filteredByType.filter(i =>
|
||||
i.domain.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Пагинация
|
||||
const totalPages = Math.ceil(filteredItems.length / pageSize);
|
||||
const paginatedItems = filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
// Для фильтра - список всех уникальных шлюзов
|
||||
const allTypes = Array.from(new Set(items.map(i => i.type)));
|
||||
|
||||
// Сортировка по клику
|
||||
const handleSort = (field) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
// Улучшенный инлайн-редакт
|
||||
useEffect(() => {
|
||||
if (editingDomain && editInputRef.current) {
|
||||
editInputRef.current.focus();
|
||||
}
|
||||
}, [editingDomain]);
|
||||
|
||||
const handleEditKeyDown = (e, domainName) => {
|
||||
if (e.key === 'Enter') handleSaveEdit(domainName);
|
||||
if (e.key === 'Escape') handleCancelEdit();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <Alert variant="danger" onClose={() => setError('')} dismissible>{error}</Alert>}
|
||||
{success && <Alert variant="success" onClose={() => setSuccess('')} dismissible>{success}</Alert>}
|
||||
{error && (
|
||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||
<div className="d-flex">
|
||||
<div>
|
||||
<IconX className="icon alert-icon" />
|
||||
</div>
|
||||
<div>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible" role="alert">
|
||||
<div className="d-flex">
|
||||
<div>
|
||||
<IconCheck className="icon alert-icon" />
|
||||
</div>
|
||||
<div>
|
||||
{success}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row>
|
||||
<Col lg={4}>
|
||||
<Card className="mb-4">
|
||||
<Card.Header as="h5">Добавить новый {entityName}</Card.Header>
|
||||
<Card.Body>
|
||||
<Form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Имя {entityName}</Form.Label>
|
||||
<Form.Control
|
||||
<div className="row">
|
||||
<div className="col-lg-4">
|
||||
{/* Add New Item Card */}
|
||||
<div className="card card-md">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconPlus className="icon me-2" />
|
||||
Добавить новый {entityName}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Имя {entityName}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={placeholder}
|
||||
value={newItem.domain}
|
||||
onChange={(e) => setNewItem({ ...newItem, domain: e.target.value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Шлюз</Form.Label>
|
||||
<Form.Control
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Шлюз</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={newItem.type}
|
||||
onChange={(e) => setNewItem({ ...newItem, type: e.target.value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button variant="primary" type="submit" className="w-100">Добавить</Button>
|
||||
</Form>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button type="submit" className="btn btn-primary w-100">
|
||||
<IconPlus className="icon me-2" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="mb-4">
|
||||
<Card.Header as="h5">Массовое обновление</Card.Header>
|
||||
<Card.Body>
|
||||
<Form.Group className="mb-2">
|
||||
<Form.Label>Заменить с</Form.Label>
|
||||
<Form.Control type="text" value={bulkFrom} onChange={e => setBulkFrom(e.target.value)} />
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Заменить на</Form.Label>
|
||||
<Form.Control type="text" value={bulkTo} onChange={e => setBulkTo(e.target.value)} />
|
||||
</Form.Group>
|
||||
<Button variant="warning" onClick={handleBulkUpdate} className="w-100">Выполнить замену</Button>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col lg={8}>
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<h5 className="mb-0">Список: {entityName}</h5>
|
||||
<Button variant="success" onClick={handleSaveChanges} disabled={items.length === 0}>
|
||||
Сохранить все изменения в S3
|
||||
</Button>
|
||||
{/* Bulk Update Card */}
|
||||
<div className="card card-md">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconArrowsExchange className="icon me-2" />
|
||||
Массовое обновление
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Заменить с</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={bulkFrom}
|
||||
onChange={(e) => setBulkFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder={`Поиск по ${entityName}...`}
|
||||
className="mb-3"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<Table striped bordered hover responsive>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Заменить на</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={bulkTo}
|
||||
onChange={(e) => setBulkTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-warning w-100"
|
||||
onClick={handleBulkUpdate}
|
||||
>
|
||||
<IconArrowsExchange className="icon me-2" />
|
||||
Обновить все
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Card */}
|
||||
<div className="card card-md">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconDatabase className="icon me-2" />
|
||||
Действия
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="d-grid gap-2">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSaveChanges}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconDatabase className="icon me-2" />
|
||||
Сохранить в S3
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={fetchItems}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh className="icon me-2" />
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-8">
|
||||
<div className="card">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 className="card-title mb-0">Список {entityName === 'домен' ? 'доменов' : entityName}</h3>
|
||||
<div className="d-flex gap-2 w-50">
|
||||
{/* Фильтр по шлюзу */}
|
||||
<select className="form-select w-auto" value={filterType} onChange={e => { setFilterType(e.target.value); setCurrentPage(1); }}>
|
||||
<option value="">Все шлюзы</option>
|
||||
{allTypes.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Поиск */}
|
||||
<div className="input-icon flex-grow-1">
|
||||
<span className="input-icon-addon">
|
||||
<IconSearch size={18} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={`Поиск ${entityName}...`}
|
||||
value={searchTerm}
|
||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{entityName}</th>
|
||||
<th>Шлюз</th>
|
||||
<th className="text-center">Действия</th>
|
||||
<th className="cursor-pointer" onClick={() => handleSort('domain')}>
|
||||
Домен
|
||||
{sortField === 'domain' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="cursor-pointer" onClick={() => handleSort('type')}>
|
||||
Шлюз
|
||||
{sortField === 'type' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="text-end"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((item, index) => (
|
||||
<tr key={item.domain}>
|
||||
<td>{index + 1}</td>
|
||||
{paginatedItems.map((item) => (
|
||||
<tr key={item.domain} className={editingDomain === item.domain ? 'table-info' : ''}>
|
||||
<td>{item.domain}</td>
|
||||
<td>
|
||||
{editingDomain === item.domain ? (
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
item.type
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<td><span className="badge bg-blue-lt text-blue">{item.type}</span></td>
|
||||
<td className="text-end">
|
||||
{editingDomain === item.domain ? (
|
||||
<>
|
||||
<Button variant="success" size="sm" className="me-2" onClick={() => handleSaveEdit(item.domain)}>
|
||||
Сохранить
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleCancelEdit}>
|
||||
Отмена
|
||||
</Button>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control d-inline-block w-auto me-2"
|
||||
value={editingValue}
|
||||
ref={editInputRef}
|
||||
onChange={e => setEditingValue(e.target.value)}
|
||||
onKeyDown={e => handleEditKeyDown(e, item.domain)}
|
||||
style={{maxWidth: 120}}
|
||||
/>
|
||||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.domain)}><IconCheck size={16} /></button>
|
||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="info" size="sm" className="me-2" onClick={() => handleEdit(item)}>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => handleDeleteItem(item.domain)}>
|
||||
Удалить
|
||||
</Button>
|
||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
||||
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
{items.length > 0 && filteredItems.length === 0 && (
|
||||
<p className="text-center text-muted">По вашему запросу ничего не найдено.</p>
|
||||
)}
|
||||
{items.length === 0 && <p className="text-center text-muted">Список пуст. Добавьте новый элемент.</p>}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</table>
|
||||
</div>
|
||||
{/* Пагинация */}
|
||||
{totalPages > 1 && (
|
||||
<div className="card-footer d-flex align-items-center justify-content-between">
|
||||
<div className="text-muted">
|
||||
Показано {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, filteredItems.length)} из {filteredItems.length}
|
||||
</div>
|
||||
<ul className="pagination m-0">
|
||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>Первая</button>
|
||||
</li>
|
||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>Назад</button>
|
||||
</li>
|
||||
{(() => {
|
||||
const pages = [];
|
||||
let start = Math.max(1, currentPage - 2);
|
||||
let end = Math.min(totalPages, currentPage + 2);
|
||||
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
||||
if (currentPage >= totalPages - 2) 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');
|
||||
return pages.map((p) => (
|
||||
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
||||
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
||||
) : (
|
||||
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(p)}>{p}</button>
|
||||
</li>
|
||||
)
|
||||
));
|
||||
})()}
|
||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages}>Вперед</button>
|
||||
</li>
|
||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}>Последняя</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||||
<div className="modal-content">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
></button>
|
||||
<div className="modal-status bg-danger"></div>
|
||||
<div className="modal-body text-center py-4">
|
||||
<IconTrash className="icon mb-2 text-danger icon-lg" />
|
||||
<h3>Удалить {entityName}?</h3>
|
||||
<div className="text-muted">
|
||||
Вы уверены, что хотите удалить "{itemToDelete?.domain}"? Это действие необратимо.
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<div className="w-100">
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
<button
|
||||
className="btn w-100"
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
<div className="col">
|
||||
<button
|
||||
className="btn btn-danger w-100"
|
||||
onClick={executeDelete}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmState.open}
|
||||
title="Подтверждение"
|
||||
message={confirmState.text}
|
||||
onCancel={() => setConfirmState({ open: false, text: '', onConfirm: null })}
|
||||
onConfirm={() => confirmState.onConfirm?.()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,929 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
|
||||
import CommunityBadge from './components/CommunityBadge.jsx';
|
||||
import ConfirmModal from './components/ConfirmModal.jsx';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDatabase,
|
||||
IconRefresh,
|
||||
IconUpload,
|
||||
IconDownload,
|
||||
IconDeviceFloppy,
|
||||
IconHash,
|
||||
IconClock,
|
||||
IconFileText,
|
||||
IconFilter
|
||||
} from '@tabler/icons-react';
|
||||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
import EmptyState from './components/EmptyState.jsx';
|
||||
import Breadcrumbs from './components/Breadcrumbs.jsx';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||
import WsUpdateModal from './components/WsUpdateModal.jsx';
|
||||
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
||||
import HistoryModal from './components/HistoryModal.jsx';
|
||||
import ErrorAlert from './components/ErrorAlert.jsx';
|
||||
import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
|
||||
import ImportModal from './components/ImportModal.jsx';
|
||||
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 { useToast } from './components/ToastContainer.jsx';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
function DomainsNewManager() {
|
||||
const toast = useToast();
|
||||
const [items, setItems] = useState([]);
|
||||
const [originalItems, setOriginalItems] = useState([]);
|
||||
const [etag, setEtag] = useState('');
|
||||
const [lastModified, setLastModified] = useState('');
|
||||
const [contentLength, setContentLength] = useState(null);
|
||||
const [newItem, setNewItem] = useState({ domain: '', community: '' });
|
||||
const [newInvalid, setNewInvalid] = useState({ domain: false, community: false });
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const searchTimer = useRef(null);
|
||||
const didInit = useRef(false);
|
||||
const [editingDomain, setEditingDomain] = useState(null);
|
||||
const [editingValue, setEditingValue] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [sortField, setSortField] = useState('domain');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [filterCommunity, setFilterCommunity] = useState('');
|
||||
const editInputRef = useRef(null);
|
||||
const pageSize = 10;
|
||||
|
||||
// Bulk operations
|
||||
const [selectedDomains, setSelectedDomains] = useState(new Set());
|
||||
|
||||
// Справочник community для подсказок
|
||||
const [communities, setCommunities] = useState([]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get(`/communities`);
|
||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
||||
} catch (e) {
|
||||
// тихо игнорируем
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
|
||||
|
||||
const isValidDomain = (value) => {
|
||||
const v = String(value).trim().toLowerCase();
|
||||
// Простой валидатор FQDN: sub.domain.tld
|
||||
return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(v);
|
||||
};
|
||||
|
||||
// Допускаем как числовые, так и строковые (AS:NNN) community
|
||||
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
|
||||
|
||||
const [wsOpen, setWsOpen] = useState(false);
|
||||
const [wsUrl, setWsUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => { try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {} })();
|
||||
}, []);
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } });
|
||||
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||
const total = payload.length;
|
||||
setItems(payload);
|
||||
setOriginalItems(payload);
|
||||
setTotalItems(Number.isFinite(total) ? total : 0);
|
||||
setEtag(response.headers?.etag || '');
|
||||
setLastModified(response.headers?.['last-modified'] || '');
|
||||
const lengthHeader = response.headers?.['content-length-source'];
|
||||
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
|
||||
setError('');
|
||||
} catch (error) {
|
||||
console.error('Error fetching domains-new:', error);
|
||||
setError('Не удалось загрузить домены. Проверьте, запущен ли бэкенд.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Подгрузка при изменении страницы/поиска/фильтра
|
||||
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
||||
|
||||
const handleAddItem = () => {
|
||||
const domainOk = isValidDomain(newItem.domain);
|
||||
const communityOk = isValidCommunity(newItem.community);
|
||||
setNewInvalid({ domain: !domainOk, community: !communityOk });
|
||||
if (!domainOk || !communityOk) {
|
||||
setError('Заполните корректный домен (example.com) и числовой community.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setItems([...items, { domain: newItem.domain.trim().toLowerCase(), community: String(newItem.community).trim() }]);
|
||||
setNewItem({ domain: '', community: '' });
|
||||
setNewInvalid({ domain: false, community: false });
|
||||
};
|
||||
|
||||
const handleEdit = (item) => {
|
||||
setEditingDomain(item.domain);
|
||||
setEditingValue(item.community);
|
||||
};
|
||||
|
||||
const handleSaveEdit = (domainName) => {
|
||||
if (!isValidCommunity(editingValue)) {
|
||||
setError('Community должен быть числом.');
|
||||
return;
|
||||
}
|
||||
const updatedItems = items.map(i =>
|
||||
i.domain === domainName ? { ...i, community: String(editingValue).trim() } : i
|
||||
);
|
||||
setItems(updatedItems);
|
||||
setEditingDomain(null);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingDomain(null);
|
||||
};
|
||||
|
||||
const handleDeleteItem = (domainNameToDelete) => {
|
||||
setItems(items.filter(i => i.domain !== domainNameToDelete));
|
||||
};
|
||||
|
||||
const confirmDelete = (item) => {
|
||||
setItemToDelete(item);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const executeDelete = () => {
|
||||
if (itemToDelete) {
|
||||
handleDeleteItem(itemToDelete.domain);
|
||||
setShowDeleteModal(false);
|
||||
setItemToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deduplicate = (arr) => {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const i of arr) {
|
||||
const key = String(i.domain).trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ domain: key, community: String(i.community || '').trim() });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const computeDiff = (before, after) => {
|
||||
const mapBefore = new Map(before.map(i => [i.domain, i]));
|
||||
const mapAfter = new Map(after.map(i => [i.domain, i]));
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const changed = [];
|
||||
for (const [k, v] of mapAfter) {
|
||||
if (!mapBefore.has(k)) { added.push(v); continue; }
|
||||
const prev = mapBefore.get(k);
|
||||
if (String(prev.community) !== String(v.community)) changed.push({ from: prev, to: v });
|
||||
}
|
||||
for (const [k, v] of mapBefore) {
|
||||
if (!mapAfter.has(k)) removed.push(v);
|
||||
}
|
||||
return { added, removed, changed };
|
||||
};
|
||||
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
|
||||
|
||||
const handlePreviewDiff = () => {
|
||||
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
||||
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
||||
const unique = deduplicate(valid);
|
||||
setDiff(computeDiff(originalItems, unique));
|
||||
setShowDiff(true);
|
||||
};
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
// подготовим diff и спросим подтверждение
|
||||
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
||||
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
||||
const unique = deduplicate(valid);
|
||||
setDiff(computeDiff(originalItems, unique));
|
||||
setConfirmSaveOpen(true);
|
||||
};
|
||||
|
||||
const performSave = async () => {
|
||||
setConfirmSaveOpen(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
||||
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
||||
const unique = deduplicate(valid);
|
||||
|
||||
// Подгружаем весь список, применяем изменения текущей страницы и сохраняем полные данные
|
||||
const fullRes = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } });
|
||||
const full = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
|
||||
const fullMap = new Map(full.map(i => [String(i.domain).trim().toLowerCase(), { domain: String(i.domain).trim().toLowerCase(), community: String(i.community || '').trim() }]));
|
||||
const originalPageMap = new Map(originalItems.map(i => [String(i.domain).trim().toLowerCase(), true]));
|
||||
const uniqueMap = new Map(unique.map(i => [String(i.domain).trim().toLowerCase(), { domain: String(i.domain).trim().toLowerCase(), community: String(i.community).trim() }]));
|
||||
// Удаления: всё, что было на странице, но отсутствует в изменённом наборе
|
||||
for (const key of originalPageMap.keys()) {
|
||||
if (!uniqueMap.has(key)) fullMap.delete(key);
|
||||
}
|
||||
// Добавления/изменения
|
||||
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
|
||||
const fullToSave = Array.from(fullMap.values());
|
||||
|
||||
const et = fullRes?.headers?.etag || etag;
|
||||
const response = await api.post(`/domains-new`, { domains: fullToSave, etag: et }, { validateStatus: () => true });
|
||||
if (response.status === 412) {
|
||||
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
|
||||
return;
|
||||
}
|
||||
if (response.status >= 400) {
|
||||
throw new Error(`Save failed with status ${response.status}`);
|
||||
}
|
||||
setEtag(response.headers?.etag || et);
|
||||
// После сохранения перезагрузим текущую страницу для консистентности
|
||||
await fetchItems();
|
||||
setSuccess('Изменения успешно сохранены!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
notifyMutationSuccess('Изменения сохранены');
|
||||
} catch (error) {
|
||||
console.error('Error saving changes:', error);
|
||||
setError('Не удалось сохранить изменения.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Drag&Drop импорт
|
||||
const onDropImport = async (e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||
const parsed = lines.map(l => {
|
||||
const [d, c] = l.split(/\s+/);
|
||||
return { domain: (d || '').toLowerCase(), community: (c || '').trim() };
|
||||
}).filter(i => isValidDomain(i.domain) && isValidCommunity(i.community));
|
||||
setItems(prev => {
|
||||
const merged = [...prev, ...parsed];
|
||||
const map = new Map();
|
||||
for (const it of merged) {
|
||||
const key = String(it.domain).trim().toLowerCase();
|
||||
if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() });
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
setImportOpen(true);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
|
||||
const response = await api.get(`/domains-new`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
|
||||
const all = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
|
||||
const header = ['domain', 'community'];
|
||||
const csv = [header, ...all.map(i => [i.domain, i.community])]
|
||||
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `domains_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
window.notify?.error('Экспорт доменов не удался', String(e?.message || e));
|
||||
}
|
||||
};
|
||||
|
||||
const clearInvalid = () => {
|
||||
setItems(prev => prev.filter(i => i.domain || i.community)
|
||||
.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Импорт через модалку =====
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const parseLine = (line) => {
|
||||
const [d, c] = String(line).split(/\s+/);
|
||||
return { domain: (d || '').toLowerCase(), community: (c || '').trim() };
|
||||
};
|
||||
const validateItem = (obj) => isValidDomain(obj.domain) && isValidCommunity(obj.community);
|
||||
const applyImportedItems = (list) => {
|
||||
setItems(prev => {
|
||||
const merged = [...prev, ...list];
|
||||
const map = new Map();
|
||||
for (const it of merged) {
|
||||
const key = String(it.domain).trim().toLowerCase();
|
||||
if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() });
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
setImportOpen(false);
|
||||
};
|
||||
|
||||
// Сортировка
|
||||
const sortedItems = [...items].sort((a, b) => {
|
||||
let valA = a[sortField] || '';
|
||||
let valB = b[sortField] || '';
|
||||
if (typeof valA === 'string') valA = valA.toLowerCase();
|
||||
if (typeof valB === 'string') valB = valB.toLowerCase();
|
||||
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Локальная фильтрация и пагинация
|
||||
const filtered = sortedItems.filter(i => {
|
||||
const byCommunity = !filterCommunity || i.community === filterCommunity;
|
||||
const term = String(searchTerm || '').toLowerCase();
|
||||
const bySearch = !term || String(i.domain).toLowerCase().includes(term);
|
||||
return byCommunity && bySearch;
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize);
|
||||
|
||||
// Для фильтра - список всех уникальных community
|
||||
const allCommunities = Array.from(new Set(items.map(i => i.community)));
|
||||
|
||||
// Сортировка по клику
|
||||
const handleSort = (field) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
// Улучшенный инлайн-редакт
|
||||
useEffect(() => {
|
||||
if (editingDomain && editInputRef.current) {
|
||||
editInputRef.current.focus();
|
||||
}
|
||||
}, [editingDomain]);
|
||||
|
||||
// Глобальные хоткеи: Ctrl+S — сохранить изменения
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||||
e.preventDefault();
|
||||
if (!loading) handleSaveChanges();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]);
|
||||
|
||||
const handleEditKeyDown = (e, domainName) => {
|
||||
if (e.key === 'Enter') handleSaveEdit(domainName);
|
||||
if (e.key === 'Escape') handleCancelEdit();
|
||||
};
|
||||
|
||||
// Bulk operations handlers
|
||||
const handleSelectAll = () => {
|
||||
setSelectedDomains(new Set(paginatedItems.map(i => i.domain)));
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setSelectedDomains(new Set());
|
||||
};
|
||||
|
||||
const handleToggleSelect = (domain) => {
|
||||
const newSet = new Set(selectedDomains);
|
||||
if (newSet.has(domain)) {
|
||||
newSet.delete(domain);
|
||||
} else {
|
||||
newSet.add(domain);
|
||||
}
|
||||
setSelectedDomains(newSet);
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedDomains.size === 0) return;
|
||||
const newItems = items.filter(i => !selectedDomains.has(i.domain));
|
||||
setItems(newItems);
|
||||
setSelectedDomains(new Set());
|
||||
toast.success(`Удалено ${selectedDomains.size} доменов`);
|
||||
};
|
||||
|
||||
const handleBulkExport = () => {
|
||||
if (selectedDomains.size === 0) return;
|
||||
const selectedItems = items.filter(i => selectedDomains.has(i.domain));
|
||||
const text = selectedItems.map(i => `${i.domain} ${i.community}`).join('\n');
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `domains-selected-${selectedDomains.size}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(`Экспортировано ${selectedDomains.size} доменов`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<ErrorAlert message={error} onClose={() => setError('')} />
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible" role="alert">
|
||||
<div className="d-flex">
|
||||
<div>
|
||||
<IconCheck className="icon alert-icon" />
|
||||
</div>
|
||||
<div>
|
||||
{success}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageHeader
|
||||
title="Домены"
|
||||
pretitle={(
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Главная', to: '/dashboard' },
|
||||
{ label: 'Данные' },
|
||||
{ label: 'Домены', count: filtered.length },
|
||||
]}
|
||||
showBack={false}
|
||||
filterInfo={filterCommunity ? `Community: ${filterCommunity}` : (searchTerm ? `Поиск: "${searchTerm}"` : null)}
|
||||
count={filtered.length}
|
||||
/>
|
||||
)}
|
||||
actions={(
|
||||
<PageHeaderActions
|
||||
loading={loading}
|
||||
onPreview={handlePreviewDiff}
|
||||
disablePreview={loading || items.length === 0}
|
||||
onRefresh={fetchItems}
|
||||
disableRefresh={loading}
|
||||
onImport={handleImport}
|
||||
onExport={handleExport}
|
||||
disableExport={items.length === 0}
|
||||
onClear={clearInvalid}
|
||||
disableClear={items.length === 0}
|
||||
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }}
|
||||
disableClearCommunities={items.length === 0 || !filterCommunity}
|
||||
onSave={handleSaveChanges}
|
||||
disableSave={loading}
|
||||
onHistory={() => setHistoryOpen(true)}
|
||||
onOnlineUpdate={() => setWsOpen(true)}
|
||||
onBackgroundUpdate={async () => {
|
||||
try {
|
||||
const res = await fetch('/api/update-bgp/background', { method: 'POST' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data?.message || `HTTP ${res.status}`);
|
||||
}
|
||||
notifyMutationSuccess('Фоновое обновление запущено', data);
|
||||
} catch (e) {
|
||||
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="row">
|
||||
<div className="col-lg-3">
|
||||
{/* Classic Add New Domain Card (restored) */}
|
||||
<div className="card card-md animate-in">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconPlus className="icon me-2" />
|
||||
Добавить новый домен
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Домен</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control${newInvalid.domain ? ' is-invalid' : ''}`}
|
||||
placeholder="example.com"
|
||||
value={newItem.domain}
|
||||
onChange={(e) => setNewItem({ ...newItem, domain: e.target.value })}
|
||||
/>
|
||||
{newInvalid.domain && <div className="invalid-feedback">Неверный формат домена</div>}
|
||||
<div className="form-text">Без http/https, только доменное имя</div>
|
||||
<div className="form-text">Формат: sub.domain.tld (например foo.example.org)</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Community</label>
|
||||
<CommunityAutocompleteInput
|
||||
value={newItem.community}
|
||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
||||
communities={communities}
|
||||
placeholder="Начните вводить номер или имя"
|
||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
||||
/>
|
||||
{newInvalid.community && <div className="invalid-feedback">Только цифры</div>}
|
||||
<div className="form-text">Формат: N или N:N (например 65000:100)</div>
|
||||
{(() => {
|
||||
const match = communities.find(c => c.value === String(newItem.community).trim());
|
||||
if (!match) return null;
|
||||
return (
|
||||
<div className="form-text">
|
||||
{match.name && (<><strong>{match.name}</strong> — </>)}
|
||||
{match.description || ''}
|
||||
{match.tags && match.tags.length > 0 && (
|
||||
<> (<span className="text-muted">{match.tags.join(', ')}</span>)</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button type="submit" className="btn btn-primary w-100">
|
||||
<IconPlus className="icon me-2" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Дополнительные варианты */}
|
||||
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false} className="animate-in" style={{animationDelay: '0.1s'}}>
|
||||
<QuickAddBar
|
||||
placeholder={'example.com 65000:100'}
|
||||
help={'Вставьте строки: DOMAIN ПРОБЕЛ COMMUNITY'}
|
||||
parseLine={(line) => { const [d, c] = String(line).split(/\s+/); return { domain: (d||'').toLowerCase(), community: (c||'').trim() }; }}
|
||||
validateItem={(obj) => isValidDomain(obj.domain) && isValidCommunity(obj.community)}
|
||||
onApply={(list) => {
|
||||
setItems(prev => {
|
||||
const merged = [...prev, ...list];
|
||||
const map = new Map();
|
||||
for (const it of merged) {
|
||||
const key = String(it.domain).trim().toLowerCase();
|
||||
if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() });
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="card card-md border-dashed mt-3"
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={onDropImport}
|
||||
>
|
||||
<div className="card-body text-center text-muted">
|
||||
Перетащите файл TXT/CSV сюда для импорта (формат: domain community)
|
||||
</div>
|
||||
</div>
|
||||
</AccordionCard>
|
||||
|
||||
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
||||
{/* Drag & Drop импорт */}
|
||||
<div
|
||||
className="card card-md border-dashed animate-in"
|
||||
style={{animationDelay: '0.2s'}}
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={onDropImport}
|
||||
>
|
||||
<div className="card-body text-center text-muted">
|
||||
Перетащите файл TXT/CSV сюда для импорта (формат: domain community)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-9">
|
||||
{/* Bulk Actions Bar */}
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedDomains.size}
|
||||
totalCount={paginatedItems.length}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
onDelete={handleBulkDelete}
|
||||
onExport={handleBulkExport}
|
||||
/>
|
||||
|
||||
<div className="card animate-in" style={{animationDelay: '0.3s'}}>
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center w-100">
|
||||
<div className="col-auto">
|
||||
<h3 className="card-title mb-0">
|
||||
Список доменов
|
||||
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="d-flex gap-2">
|
||||
{/* Сохранённые фильтры */}
|
||||
<SavedFilters
|
||||
pageKey="domains"
|
||||
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
|
||||
onApplyFilter={(filters) => {
|
||||
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
|
||||
if (filters.filterCommunity !== undefined) setFilterCommunity(filters.filterCommunity);
|
||||
if (filters.sortField !== undefined) setSortField(filters.sortField);
|
||||
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Фильтр по community */}
|
||||
<select
|
||||
className="form-select w-auto"
|
||||
value={filterCommunity}
|
||||
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
|
||||
title={(() => {
|
||||
const meta = communities.find(c => c.value === filterCommunity);
|
||||
if (!meta) return 'Все community';
|
||||
const name = meta.name ? meta.name + ' — ' : '';
|
||||
const desc = meta.description || '';
|
||||
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
|
||||
return name + desc + tags;
|
||||
})()}
|
||||
>
|
||||
<option value="">Все community</option>
|
||||
{allCommunities.map(community => {
|
||||
const meta = communities.find(c => c.value === community);
|
||||
const label = meta && meta.name ? `${community} — ${meta.name}` : community;
|
||||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community;
|
||||
return (
|
||||
<option key={community} value={community} title={title}>{label}</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="input-icon flex-grow-1">
|
||||
<span className="input-icon-addon">
|
||||
<IconSearch size={18} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск домена..."
|
||||
value={searchTerm}
|
||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
aria-label="Поиск домена"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Мета-информация будет показана внизу карточки */}
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={4} hasCheckbox={true} />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3}>
|
||||
<EmptyState
|
||||
title="Нет доменов"
|
||||
description="Импортируйте или добавьте записи, чтобы начать."
|
||||
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
||||
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
|
||||
const header = ['domain','community'];
|
||||
const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
|
||||
}}>Шаблон CSV</button>}
|
||||
/>
|
||||
</TableEmpty>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0}
|
||||
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
|
||||
/>
|
||||
</th>
|
||||
<th className="cursor-pointer" onClick={() => handleSort('domain')}>
|
||||
Домен
|
||||
{sortField === 'domain' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="cursor-pointer" onClick={() => handleSort('community')}>
|
||||
Community
|
||||
{sortField === 'community' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="text-end"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedItems.map((item) => (
|
||||
<tr key={item.domain} className={editingDomain === item.domain ? 'table-info' : ''}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={selectedDomains.has(item.domain)}
|
||||
onChange={() => handleToggleSelect(item.domain)}
|
||||
/>
|
||||
</td>
|
||||
<td>{item.domain}</td>
|
||||
<td>{renderCommunityBadge(item.community)}</td>
|
||||
<td className="text-end">
|
||||
{editingDomain === item.domain ? (
|
||||
<>
|
||||
<CommunityAutocompleteInput
|
||||
value={editingValue}
|
||||
onChange={setEditingValue}
|
||||
communities={communities}
|
||||
onKeyDown={(e) => handleEditKeyDown(e, item.domain)}
|
||||
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||
/>
|
||||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.domain)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
||||
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{/* Пагинация */}
|
||||
{totalPages > 1 && (
|
||||
<div className="card-footer d-flex align-items-center justify-content-between">
|
||||
<div className="text-muted">
|
||||
Показано {((currentPage - 1) * pageSize) + 1} - {((currentPage - 1) * pageSize) + paginatedItems.length} из {totalItems || paginatedItems.length}
|
||||
</div>
|
||||
<ul className="pagination m-0">
|
||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>Первая</button>
|
||||
</li>
|
||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>Назад</button>
|
||||
</li>
|
||||
{(() => {
|
||||
const pages = [];
|
||||
let start = Math.max(1, currentPage - 2);
|
||||
let end = Math.min(totalPages, currentPage + 2);
|
||||
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
||||
if (currentPage >= totalPages - 2) 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');
|
||||
return pages.map((p) => (
|
||||
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
||||
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
||||
) : (
|
||||
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(p)}>{p}</button>
|
||||
</li>
|
||||
)
|
||||
));
|
||||
})()}
|
||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages}>Вперед</button>
|
||||
</li>
|
||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}>Последняя</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<S3MetaBar keys={["domainsNew"]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
show={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={executeDelete}
|
||||
title="Удалить домен?"
|
||||
message={
|
||||
itemToDelete ? (
|
||||
<>
|
||||
<p>Вы уверены, что хотите удалить <strong>"{itemToDelete.domain}"</strong>?</p>
|
||||
<p className="text-muted mb-0">Это действие необратимо.</p>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
variant="danger"
|
||||
icon={IconTrash}
|
||||
/>
|
||||
{/* Confirm Save Modal */}
|
||||
<ConfirmDiffModal
|
||||
show={confirmSaveOpen}
|
||||
diff={diff}
|
||||
onConfirm={performSave}
|
||||
onClose={() => setConfirmSaveOpen(false)}
|
||||
/>
|
||||
<ImportModal
|
||||
show={importOpen}
|
||||
title={'Импорт доменов'}
|
||||
description={'Формат: DOMAIN ПРОБЕЛ COMMUNITY'}
|
||||
parseLine={parseLine}
|
||||
validateItem={validateItem}
|
||||
onConfirm={applyImportedItems}
|
||||
onClose={() => setImportOpen(false)}
|
||||
sampleHeader={['domain','community']}
|
||||
placeholder={'example.com 65000:100'}
|
||||
/>
|
||||
<HistoryModal
|
||||
resource="domains-new"
|
||||
show={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onRolledBack={() => fetchItems()}
|
||||
/>
|
||||
<WsUpdateModal
|
||||
show={wsOpen}
|
||||
url={wsUrl}
|
||||
onClose={() => setWsOpen(false)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
show={clearCommunitiesOpen}
|
||||
onClose={() => setClearCommunitiesOpen(false)}
|
||||
onConfirm={() => {
|
||||
setItems(prev => prev.filter(i => i.community !== filterCommunity));
|
||||
setClearCommunitiesOpen(false);
|
||||
}}
|
||||
title="Удалить записи community?"
|
||||
message={
|
||||
<>
|
||||
<p>Будут удалены все домены с community <strong>"{filterCommunity}"</strong>.</p>
|
||||
<p className="text-warning mb-0">
|
||||
<IconAlertTriangle size={16} className="me-1" />
|
||||
Действие НЕ сохраняет изменения автоматически.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
variant="warning"
|
||||
/>
|
||||
{/* Diff Modal */}
|
||||
{showDiff && (
|
||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div className="modal-content">
|
||||
<button type="button" className="btn-close" onClick={() => setShowDiff(false)}></button>
|
||||
<div className="modal-header"><h3 className="modal-title">Изменения</h3></div>
|
||||
<div className="modal-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
|
||||
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
|
||||
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn" onClick={() => setShowDiff(false)}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DomainsNewManager;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
import React, { useMemo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
MiniMap,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
addEdge,
|
||||
MarkerType,
|
||||
Handle,
|
||||
Position,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize } from '@tabler/icons-react';
|
||||
|
||||
function GraphView({ servers, connections, onCreateConnection }) {
|
||||
const flowRef = useRef(null);
|
||||
const instanceRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const getTunnelStyle = useCallback((tunnelType) => {
|
||||
const map = {
|
||||
GRE: { color: '#206bc4', dash: '0', width: 2.5 },
|
||||
IPSec: { color: '#f59f00', dash: '6,4', width: 2.5 },
|
||||
WireGuard: { color: '#2fb344', dash: '0', width: 3.2 },
|
||||
OpenVPN: { color: '#be4bdb', dash: '3,3', width: 2.5 },
|
||||
};
|
||||
return map[tunnelType] || { color: '#667382', dash: '5,5', width: 2 };
|
||||
}, []);
|
||||
|
||||
const getCountryColor = useCallback((country) => {
|
||||
const colors = {
|
||||
RU: '#dc2626',
|
||||
US: '#2563eb',
|
||||
DE: '#059669',
|
||||
SE: '#7c3aed',
|
||||
NL: '#ea580c',
|
||||
SG: '#0891b2',
|
||||
};
|
||||
return colors[country] || '#6b7280';
|
||||
}, []);
|
||||
|
||||
const computeGridPosition = useCallback((index, total) => {
|
||||
const cols = Math.ceil(Math.sqrt(total));
|
||||
const row = Math.floor(index / cols);
|
||||
const col = index % cols;
|
||||
const spacingX = 280;
|
||||
const spacingY = 180;
|
||||
return { x: 80 + col * spacingX, y: 80 + row * spacingY };
|
||||
}, []);
|
||||
|
||||
const initialNodesData = useMemo(() => {
|
||||
return (servers || []).map((s, i) => ({
|
||||
id: String(s.ip),
|
||||
type: 'server',
|
||||
position: computeGridPosition(i, servers.length),
|
||||
data: { server: s },
|
||||
}));
|
||||
}, [servers, computeGridPosition]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodesData);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(initialNodesData);
|
||||
}, [initialNodesData, setNodes]);
|
||||
|
||||
const initialEdgesData = useMemo(() => {
|
||||
return (connections || []).map((c, idx) => {
|
||||
const style = getTunnelStyle(c.tunnelType);
|
||||
return {
|
||||
id: `${c.from}-${c.to}-${idx}`,
|
||||
source: String(c.from),
|
||||
target: String(c.to),
|
||||
label: c.ipA && c.ipB ? `${c.tunnelType} ${c.ipA} ⇄ ${c.ipB}` : c.tunnelType,
|
||||
style: { stroke: style.color, strokeWidth: style.width, strokeDasharray: style.dash },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: style.color, width: 20, height: 20 },
|
||||
animated: false,
|
||||
};
|
||||
});
|
||||
}, [connections, getTunnelStyle]);
|
||||
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdgesData);
|
||||
|
||||
useEffect(() => {
|
||||
setEdges(initialEdgesData);
|
||||
}, [initialEdgesData, setEdges]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params) => {
|
||||
// Делегируем создание связи в родителя, чтобы сохранить единую логику хранения
|
||||
if (!params?.source || !params?.target || params.source === params.target) return;
|
||||
if (onCreateConnection) {
|
||||
onCreateConnection({ from: params.source, to: params.target });
|
||||
}
|
||||
},
|
||||
[onCreateConnection]
|
||||
);
|
||||
|
||||
const nodeTypes = useMemo(
|
||||
() => ({
|
||||
server: ({ data }) => {
|
||||
const s = data.server || {};
|
||||
const countryColor = getCountryColor(s.country);
|
||||
const getFlagEmoji = (code) => {
|
||||
if (!code) return '';
|
||||
const map = { SWE: 'SE', UK: 'GB' };
|
||||
const ccRaw = String(code).trim().toUpperCase();
|
||||
const cc = (map[ccRaw] || ccRaw).slice(0, 2);
|
||||
if (cc.length !== 2) return ccRaw;
|
||||
return cc.replace(/./g, (ch) => String.fromCodePoint(127397 + ch.charCodeAt()));
|
||||
};
|
||||
const flag = getFlagEmoji(s.country);
|
||||
return (
|
||||
<div
|
||||
className="card shadow-sm"
|
||||
style={{
|
||||
width: 200,
|
||||
borderRadius: 12,
|
||||
border: `2px solid ${countryColor}`,
|
||||
overflow: 'hidden',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
{/* Точка входа соединений */}
|
||||
<Handle type="target" position={Position.Left} style={{ background: countryColor }} />
|
||||
<div className="px-2 py-1 d-flex align-items-center" style={{ background: '#f8fafc', borderBottom: '1px solid #eef2f7' }}>
|
||||
<div className="d-flex align-items-center" style={{ gap: 6 }}>
|
||||
<span style={{ fontSize: 16, lineHeight: '1' }}>{flag}</span>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
background: '#fff',
|
||||
color: '#475569',
|
||||
border: `1px solid ${countryColor}`,
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{String(s.country || '').toUpperCase().slice(0, 3)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ms-2 text-truncate" style={{ fontWeight: 700, color: '#1f2937', fontSize: 13 }}>{s.dns}</div>
|
||||
</div>
|
||||
<div className="px-2 py-2" style={{ lineHeight: 1.2 }}>
|
||||
<div style={{ fontSize: 12, color: '#334155' }}>{s.ip}</div>
|
||||
<div style={{ fontSize: 10, color: '#6b7280' }}>{s.provider}</div>
|
||||
</div>
|
||||
{/* Точка исхода соединений */}
|
||||
<Handle type="source" position={Position.Right} style={{ background: countryColor }} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[getCountryColor]
|
||||
);
|
||||
|
||||
const onInit = useCallback((inst) => {
|
||||
instanceRef.current = inst;
|
||||
// Автоподгон при инициализации
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
inst.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
||||
} catch {}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceRef.current) return;
|
||||
// Подгон при изменении данных
|
||||
const i = instanceRef.current;
|
||||
const t = setTimeout(() => {
|
||||
try {
|
||||
i.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
||||
} catch {}
|
||||
}, 50);
|
||||
return () => clearTimeout(t);
|
||||
}, [servers, connections]);
|
||||
|
||||
// Трек полноэкранного режима и авто-fit после входа/выхода
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const fs = Boolean(document.fullscreenElement);
|
||||
setIsFullscreen(fs);
|
||||
if (instanceRef.current) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
instanceRef.current.fitView({ padding: 0.2 });
|
||||
} catch {}
|
||||
}, 60);
|
||||
}
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handler);
|
||||
return () => document.removeEventListener('fullscreenchange', handler);
|
||||
}, []);
|
||||
|
||||
if (!servers || servers.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-5">
|
||||
<div className="text-muted">
|
||||
<h4>Нет серверов для отображения</h4>
|
||||
<p>Добавьте серверы, чтобы увидеть граф связей</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: isFullscreen ? '100vh' : 500,
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
background: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<ReactFlow
|
||||
ref={flowRef}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
defaultEdgeOptions={{
|
||||
type: 'default',
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
}}
|
||||
onInit={onInit}
|
||||
style={{ width: '100%', height: '100%', background: '#f8fafc' }}
|
||||
>
|
||||
<Background variant="dots" gap={20} size={1} color="#e5e7eb" />
|
||||
<MiniMap
|
||||
nodeColor={(n) => getCountryColor(n.data?.server?.country)}
|
||||
nodeStrokeWidth={2}
|
||||
maskColor="rgba(0,0,0,0.05)"
|
||||
/>
|
||||
</ReactFlow>
|
||||
{/* Своя панель управления: -, +, Fit, Fullscreen */}
|
||||
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 10, pointerEvents: 'auto' }}>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => instanceRef.current?.zoomOut?.()}
|
||||
title="Уменьшить"
|
||||
>
|
||||
<IconZoomOut size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => instanceRef.current?.zoomIn?.()}
|
||||
title="Увеличить"
|
||||
>
|
||||
<IconZoomIn size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => instanceRef.current?.fitView?.({ padding: 0.2 })}
|
||||
title="Подогнать к окну"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (!document.fullscreenElement) {
|
||||
el.requestFullscreen?.();
|
||||
} else {
|
||||
document.exitFullscreen?.();
|
||||
}
|
||||
}}
|
||||
title="Во весь экран"
|
||||
>
|
||||
{isFullscreen ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GraphView;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { IconChevronDown } from '@tabler/icons-react';
|
||||
|
||||
function AccordionCard({ title, defaultOpen = false, children }) {
|
||||
const [open, setOpen] = useState(Boolean(defaultOpen));
|
||||
return (
|
||||
<div className="card card-md">
|
||||
<div className="card-header d-flex align-items-center justify-content-between" role="button" onClick={() => setOpen(v => !v)} aria-expanded={open}>
|
||||
<h3 className="card-title m-0">{title}</h3>
|
||||
<IconChevronDown className={`icon ms-2 transition ${open ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
{open && (
|
||||
<div className="card-body">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccordionCard;
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { IconArrowLeft, IconFilter } from '@tabler/icons-react'
|
||||
|
||||
/**
|
||||
* Улучшенный компонент Breadcrumbs с поддержкой:
|
||||
* - Динамических фильтров
|
||||
* - Счетчиков результатов
|
||||
* - Кнопки "Назад" с сохранением состояния
|
||||
*/
|
||||
function Breadcrumbs({ items = [], showBack = false, filterInfo = null, count = null }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) return null
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(-1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||
{/* Кнопка "Назад" */}
|
||||
{showBack && (
|
||||
<button
|
||||
className="btn btn-ghost-secondary btn-sm btn-icon"
|
||||
onClick={handleBack}
|
||||
title="Назад"
|
||||
aria-label="Назад"
|
||||
>
|
||||
<IconArrowLeft size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol className="breadcrumb mb-0">
|
||||
{items.map((it, idx) => {
|
||||
const isLast = idx === items.length - 1
|
||||
return (
|
||||
<li key={idx} className={`breadcrumb-item${isLast ? ' active' : ''}`} aria-current={isLast ? 'page' : undefined}>
|
||||
{it.to && !isLast ? (
|
||||
<Link to={it.to} className="text-decoration-none">
|
||||
{it.label}
|
||||
{it.count !== undefined && (
|
||||
<span className="badge bg-blue-lt text-blue ms-1">{it.count}</span>
|
||||
)}
|
||||
</Link>
|
||||
) : (
|
||||
<span>
|
||||
{it.label}
|
||||
{it.count !== undefined && (
|
||||
<span className="badge bg-blue-lt text-blue ms-1">{it.count}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Информация о фильтрах */}
|
||||
{filterInfo && (
|
||||
<span className="badge bg-azure-lt text-azure d-inline-flex align-items-center">
|
||||
<IconFilter size={14} className="me-1" />
|
||||
{filterInfo}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Общий счетчик */}
|
||||
{count !== null && count !== undefined && (
|
||||
<span className="text-muted small">
|
||||
({count} {count === 1 ? 'запись' : count < 5 ? 'записи' : 'записей'})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Breadcrumbs
|
||||
@@ -0,0 +1,120 @@
|
||||
import { IconTrash, IconEdit, IconDownload, IconX, IconChecks } from '@tabler/icons-react'
|
||||
|
||||
/**
|
||||
* Панель массовых действий для таблиц
|
||||
* Показывается когда выбраны элементы
|
||||
*/
|
||||
function BulkActionsBar({
|
||||
selectedCount = 0,
|
||||
totalCount = 0,
|
||||
onSelectAll = () => {},
|
||||
onDeselectAll = () => {},
|
||||
onDelete = null,
|
||||
onEdit = null,
|
||||
onExport = null,
|
||||
customActions = [], // [{icon: Icon, label, onClick, variant}]
|
||||
className = ''
|
||||
}) {
|
||||
if (selectedCount === 0) return null
|
||||
|
||||
const allSelected = selectedCount === totalCount
|
||||
|
||||
return (
|
||||
<div className={`card mb-3 ${className}`} style={{
|
||||
position: 'sticky',
|
||||
top: '10px',
|
||||
zIndex: 10,
|
||||
boxShadow: '0 0.5rem 1rem rgba(0,0,0,.15)'
|
||||
}}>
|
||||
<div className="card-body py-2 px-3">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
{/* Левая часть: информация о выборе */}
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
<div className="d-flex align-items-center">
|
||||
<IconChecks size={20} className="text-blue me-2" />
|
||||
<span className="fw-bold">
|
||||
Выбрано: <span className="text-blue">{selectedCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Кнопка выбрать все / снять выбор */}
|
||||
{!allSelected && totalCount > selectedCount && (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={onSelectAll}
|
||||
>
|
||||
Выбрать все ({totalCount})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-sm btn-ghost-secondary"
|
||||
onClick={onDeselectAll}
|
||||
>
|
||||
<IconX size={16} className="me-1" />
|
||||
Снять выбор
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Правая часть: действия */}
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{/* Экспорт */}
|
||||
{onExport && (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={onExport}
|
||||
title="Экспортировать выбранное"
|
||||
>
|
||||
<IconDownload size={16} className="me-1" />
|
||||
Экспорт
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Редактирование */}
|
||||
{onEdit && (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={onEdit}
|
||||
title="Редактировать выбранное"
|
||||
>
|
||||
<IconEdit size={16} className="me-1" />
|
||||
Изменить
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Пользовательские действия */}
|
||||
{customActions.map((action, idx) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
className={`btn btn-sm ${action.variant || 'btn-outline-primary'}`}
|
||||
onClick={action.onClick}
|
||||
title={action.title}
|
||||
>
|
||||
{Icon && <Icon size={16} className="me-1" />}
|
||||
{action.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Удаление */}
|
||||
{onDelete && (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={onDelete}
|
||||
title="Удалить выбранное"
|
||||
>
|
||||
<IconTrash size={16} className="me-1" />
|
||||
Удалить ({selectedCount})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BulkActionsBar
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
IconSearch,
|
||||
IconWorld,
|
||||
IconNetwork,
|
||||
IconServer,
|
||||
IconFilter,
|
||||
IconHome,
|
||||
IconDatabase,
|
||||
IconCreditCard,
|
||||
IconDownload,
|
||||
IconKeyboard
|
||||
} from '@tabler/icons-react'
|
||||
|
||||
/**
|
||||
* Command Palette - глобальный поиск по командам (Ctrl+K)
|
||||
*/
|
||||
function CommandPalette() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
const inputRef = useRef(null)
|
||||
|
||||
// Список команд
|
||||
const commands = [
|
||||
{ icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] },
|
||||
{ icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] },
|
||||
{ icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] },
|
||||
{ icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },
|
||||
{ icon: IconFilter, label: 'Community', description: 'Справочник BGP Community', action: () => navigate('/communities'), keywords: ['community', 'справочник'] },
|
||||
{ icon: IconServer, label: 'Серверы', description: 'Управление серверами', action: () => navigate('/servers'), keywords: ['серверы', 'servers'] },
|
||||
{ icon: IconFilter, label: 'Фильтры', description: 'Filter Manager', action: () => navigate('/filters'), keywords: ['фильтры', 'filters', 'mikrotik'] },
|
||||
{ icon: IconCreditCard, label: 'Биллинг', description: 'Управление биллингом', action: () => navigate('/billing'), keywords: ['биллинг', 'billing', 'оплата'] },
|
||||
{ icon: IconDownload, label: 'Авто-URL', description: 'Генератор ссылок', action: () => navigate('/auto-urls'), keywords: ['url', 'ссылки', 'генератор'] },
|
||||
]
|
||||
|
||||
// Фильтрация команд по поисковому запросу
|
||||
const filteredCommands = searchTerm.trim() === ''
|
||||
? commands
|
||||
: commands.filter(cmd =>
|
||||
cmd.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
cmd.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
cmd.keywords.some(kw => kw.includes(searchTerm.toLowerCase()))
|
||||
)
|
||||
|
||||
// Открытие/закрытие по Ctrl+K или Cmd+K
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setIsOpen(prev => !prev)
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Навигация по списку стрелками
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.min(prev + 1, filteredCommands.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (filteredCommands[selectedIndex]) {
|
||||
executeCommand(filteredCommands[selectedIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, selectedIndex, filteredCommands])
|
||||
|
||||
// Фокус на input при открытии
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchTerm('')
|
||||
setSelectedIndex(0)
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const executeCommand = (command) => {
|
||||
command.action()
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="modal-backdrop fade show"
|
||||
onClick={() => setIsOpen(false)}
|
||||
style={{ zIndex: 1050 }}
|
||||
></div>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="modal fade show d-block"
|
||||
tabIndex="-1"
|
||||
style={{ zIndex: 1055 }}
|
||||
>
|
||||
<div className="modal-dialog modal-dialog-centered" style={{ maxWidth: '600px' }}>
|
||||
<div className="modal-content">
|
||||
{/* Search input */}
|
||||
<div className="modal-header border-0 pb-0">
|
||||
<div className="input-icon w-100">
|
||||
<span className="input-icon-addon">
|
||||
<IconSearch size={20} />
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="form-control form-control-lg border-0"
|
||||
placeholder="Поиск команд..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value)
|
||||
setSelectedIndex(0)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commands list */}
|
||||
<div className="modal-body pt-2" style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
{filteredCommands.length === 0 ? (
|
||||
<div className="text-center text-muted py-4">
|
||||
Команды не найдены
|
||||
</div>
|
||||
) : (
|
||||
<div className="list-group list-group-flush">
|
||||
{filteredCommands.map((cmd, index) => {
|
||||
const Icon = cmd.icon
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={`list-group-item list-group-item-action d-flex align-items-center ${selectedIndex === index ? 'active' : ''}`}
|
||||
onClick={() => executeCommand(cmd)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span className={`avatar avatar-sm me-3 ${selectedIndex === index ? 'bg-white text-primary' : 'bg-blue-lt text-blue'}`}>
|
||||
<Icon size={20} />
|
||||
</span>
|
||||
<div className="flex-grow-1 text-start">
|
||||
<div className="fw-bold">{cmd.label}</div>
|
||||
<div className="text-muted small">{cmd.description}</div>
|
||||
</div>
|
||||
{selectedIndex === index && (
|
||||
<kbd className="bg-white text-muted border">↵</kbd>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with hints */}
|
||||
<div className="modal-footer border-0 pt-0">
|
||||
<div className="d-flex gap-3 text-muted small w-100 justify-content-center">
|
||||
<div><kbd>↑</kbd> <kbd>↓</kbd> навигация</div>
|
||||
<div><kbd>↵</kbd> выбрать</div>
|
||||
<div><kbd>Esc</kbd> закрыть</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка для показа shortcuts
|
||||
*/
|
||||
export function KeyboardShortcutsButton({ className = '' }) {
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ['Ctrl', 'K'], description: 'Открыть поиск команд' },
|
||||
{ keys: ['Ctrl', 'N'], description: 'Добавить новую запись' },
|
||||
{ keys: ['Ctrl', 'S'], description: 'Сохранить изменения' },
|
||||
{ keys: ['Ctrl', 'F'], description: 'Поиск по таблице' },
|
||||
{ keys: ['Esc'], description: 'Закрыть модалку / Отменить' },
|
||||
{ keys: ['↑', '↓'], description: 'Навигация по списку' },
|
||||
{ keys: ['Enter'], description: 'Подтвердить / Выбрать' },
|
||||
]
|
||||
|
||||
// Если className содержит nav-link, используем ссылку вместо кнопки
|
||||
const isNavLink = className.includes('nav-link')
|
||||
|
||||
return (
|
||||
<>
|
||||
{isNavLink ? (
|
||||
<a
|
||||
href="#"
|
||||
className={className}
|
||||
onClick={(e) => { e.preventDefault(); setShowModal(true); }}
|
||||
title="Горячие клавиши (Ctrl+/)"
|
||||
>
|
||||
<IconKeyboard size={20} />
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className={`btn btn-ghost-secondary btn-icon ${className}`}
|
||||
onClick={() => setShowModal(true)}
|
||||
title="Горячие клавиши (Ctrl+/)"
|
||||
>
|
||||
<IconKeyboard size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<>
|
||||
<div
|
||||
className="modal-backdrop fade show"
|
||||
onClick={() => setShowModal(false)}
|
||||
></div>
|
||||
<div className="modal fade show d-block" tabIndex="-1">
|
||||
<div className="modal-dialog modal-dialog-centered">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Горячие клавиши</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={() => setShowModal(false)}
|
||||
></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="list-group list-group-flush">
|
||||
{shortcuts.map((shortcut, index) => (
|
||||
<div key={index} className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>{shortcut.description}</span>
|
||||
<div className="d-flex gap-1">
|
||||
{shortcut.keys.map((key, i) => (
|
||||
<kbd key={i} className="bg-secondary-lt">{key}</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandPalette
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { IconHash } from '@tabler/icons-react';
|
||||
|
||||
function CommunityAutocompleteInput({
|
||||
value,
|
||||
onChange,
|
||||
communities = [],
|
||||
placeholder = '',
|
||||
className = 'form-control',
|
||||
onSelectMeta,
|
||||
maxSuggestions = 8,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const q = String(value || '').toLowerCase();
|
||||
if (!q) return communities.slice(0, maxSuggestions);
|
||||
const filtered = communities.filter((c) => {
|
||||
const v = (c.value || '').toLowerCase();
|
||||
const n = (c.name || '').toLowerCase();
|
||||
const d = (c.description || '').toLowerCase();
|
||||
const t = Array.isArray(c.tags) ? c.tags.join(' ').toLowerCase() : '';
|
||||
return v.includes(q) || n.includes(q) || d.includes(q) || t.includes(q);
|
||||
});
|
||||
return filtered.slice(0, maxSuggestions);
|
||||
}, [value, communities, maxSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutside = (e) => {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('click', handleOutside);
|
||||
return () => document.removeEventListener('click', handleOutside);
|
||||
}, []);
|
||||
|
||||
const selectItem = (item) => {
|
||||
onChange(item.value);
|
||||
if (onSelectMeta) onSelectMeta(item);
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
if (inputRef.current) inputRef.current.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
if (activeIndex >= 0 && activeIndex < suggestions.length) {
|
||||
e.preventDefault();
|
||||
selectItem(suggestions[activeIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="position-relative" style={{ width: '100%' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{open && suggestions.length > 0 && (
|
||||
<div
|
||||
className="dropdown-menu show"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{suggestions.map((s, idx) => {
|
||||
const label = s.name ? `${s.value} - ${s.name}` : `${s.value}`;
|
||||
const description = s.description || '';
|
||||
const tags = Array.isArray(s.tags) ? s.tags : [];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${s.value}-${idx}`}
|
||||
className={`dropdown-item${idx === activeIndex ? ' active' : ''}`}
|
||||
onMouseDown={(e) => { e.preventDefault(); selectItem(s); }}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
|
||||
<IconHash size={14} />
|
||||
</span>
|
||||
<div className="flex-fill text-start">
|
||||
<div className="fw-medium">{label}</div>
|
||||
{(description || tags.length > 0) && (
|
||||
<div className="text-muted small text-truncate" style={{ maxWidth: '100%' }}>
|
||||
{description}
|
||||
{tags.length > 0 && (
|
||||
<> ({tags.join(', ')})</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommunityAutocompleteInput;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
function normalizeTablerColor(input) {
|
||||
const allowed = new Set(['blue','azure','indigo','purple','pink','red','orange','yellow','lime','green','teal','cyan','grape','gray']);
|
||||
const c = String(input || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
|
||||
return allowed.has(c) ? c : 'blue';
|
||||
}
|
||||
|
||||
function CommunityBadge({ value, communities = [] }) {
|
||||
const v = String(value ?? '').trim();
|
||||
if (!v) return <span className="badge bg-blue-lt text-blue">—</span>;
|
||||
const meta = communities.find(c => String(c?.value) === v);
|
||||
const color = normalizeTablerColor(meta?.color || 'blue');
|
||||
const cls = `badge bg-${color}-lt text-${color}`;
|
||||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${Array.isArray(meta.tags) && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
|
||||
return (
|
||||
<span className={cls} title={title}>{v}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommunityBadge;
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../lib/api.js';
|
||||
import { IconChartBar, IconRefresh, IconTags } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* Компонент для отображения статистики использования communities
|
||||
*/
|
||||
function CommunityStats({ communities = [] }) {
|
||||
const [stats, setStats] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchStats = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get('/communities/stats');
|
||||
setStats(res.data?.stats || []);
|
||||
} catch (e) {
|
||||
console.error('Error loading community stats:', e);
|
||||
setError('Не удалось загрузить статистику');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
// Объединяем статистику с метаданными communities
|
||||
const enrichedStats = stats.map(stat => {
|
||||
const meta = communities.find(c => c.value === stat.community);
|
||||
return {
|
||||
...stat,
|
||||
name: meta?.name || '',
|
||||
description: meta?.description || '',
|
||||
tags: meta?.tags || [],
|
||||
color: meta?.color || '',
|
||||
category: meta?.category || '',
|
||||
};
|
||||
});
|
||||
|
||||
// Группируем по категориям
|
||||
const statsByCategory = enrichedStats.reduce((acc, stat) => {
|
||||
const category = stat.category || 'Без категории';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(stat);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body text-center py-4">
|
||||
<div className="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<span className="text-muted">Загрузка статистики...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="alert alert-warning mb-0">
|
||||
{error}
|
||||
<button className="btn btn-sm btn-outline-primary ms-2" onClick={fetchStats}>
|
||||
<IconRefresh size={14} className="me-1" /> Повторить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalUsage = stats.reduce((sum, s) => sum + s.count, 0);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<h3 className="card-title">
|
||||
<IconChartBar className="me-2" />
|
||||
Статистика использования Communities
|
||||
</h3>
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={fetchStats} disabled={loading}>
|
||||
<IconRefresh size={14} className="me-1" /> Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{stats.length === 0 ? (
|
||||
<div className="text-center text-muted py-3">
|
||||
Нет данных для отображения статистики
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Общая статистика */}
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-4">
|
||||
<div className="card card-sm bg-blue-lt">
|
||||
<div className="card-body">
|
||||
<div className="h1 mb-0">{stats.length}</div>
|
||||
<div className="text-muted">Уникальных communities</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="card card-sm bg-green-lt">
|
||||
<div className="card-body">
|
||||
<div className="h1 mb-0">{totalUsage}</div>
|
||||
<div className="text-muted">Всего использований</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="card card-sm bg-purple-lt">
|
||||
<div className="card-body">
|
||||
<div className="h1 mb-0">{Object.keys(statsByCategory).length}</div>
|
||||
<div className="text-muted">Категорий</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top-10 communities */}
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-3">
|
||||
<IconChartBar className="me-2" size={20} />
|
||||
Top-10 самых используемых
|
||||
</h4>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '10%' }}>#</th>
|
||||
<th style={{ width: '25%' }}>Community</th>
|
||||
<th style={{ width: '35%' }}>Название</th>
|
||||
<th style={{ width: '15%' }}>Использований</th>
|
||||
<th style={{ width: '15%' }}>Процент</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{enrichedStats.slice(0, 10).map((stat, idx) => {
|
||||
const percentage = ((stat.count / totalUsage) * 100).toFixed(1);
|
||||
return (
|
||||
<tr key={stat.community}>
|
||||
<td className="text-muted">{idx + 1}</td>
|
||||
<td>
|
||||
<code className="text-blue">{stat.community}</code>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex align-items-center">
|
||||
<span>{stat.name || '—'}</span>
|
||||
{stat.tags && stat.tags.length > 0 && (
|
||||
<div className="ms-2">
|
||||
{stat.tags.slice(0, 2).map(tag => (
|
||||
<span key={tag} className="badge bg-blue-lt text-blue me-1" style={{ fontSize: '0.7rem' }}>
|
||||
<IconTags size={10} className="me-1" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge bg-green-lt text-green">{stat.count}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="progress flex-fill" style={{ height: 8 }}>
|
||||
<div
|
||||
className="progress-bar bg-blue"
|
||||
style={{ width: `${percentage}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={percentage}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
></div>
|
||||
</div>
|
||||
<span className="ms-2 text-muted" style={{ fontSize: '0.85rem' }}>{percentage}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статистика по категориям */}
|
||||
{Object.keys(statsByCategory).length > 1 && (
|
||||
<div>
|
||||
<h4 className="mb-3">По категориям</h4>
|
||||
<div className="row g-3">
|
||||
{Object.entries(statsByCategory).map(([category, items]) => {
|
||||
const categoryTotal = items.reduce((sum, item) => sum + item.count, 0);
|
||||
const categoryPercentage = ((categoryTotal / totalUsage) * 100).toFixed(1);
|
||||
return (
|
||||
<div key={category} className="col-md-6">
|
||||
<div className="card card-sm">
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h5 className="mb-0">{category}</h5>
|
||||
<div className="text-muted small">{items.length} communities</div>
|
||||
</div>
|
||||
<span className="badge bg-blue-lt text-blue">
|
||||
{categoryTotal} использований
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress" style={{ height: 6 }}>
|
||||
<div
|
||||
className="progress-bar bg-blue"
|
||||
style={{ width: `${categoryPercentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-muted small mt-1">{categoryPercentage}% от общего</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommunityStats;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
function ConfirmDiffModal({ show, diff, onConfirm, onClose }) {
|
||||
if (!show) return null;
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmDiffModal;
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import Modal from './Modal';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* ConfirmModal - модальное окно подтверждения действия
|
||||
* Для критичных операций (удаление, отмена и т.д.)
|
||||
*/
|
||||
function ConfirmModal({
|
||||
show,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title = 'Подтверждение',
|
||||
message,
|
||||
confirmLabel = 'Подтвердить',
|
||||
cancelLabel = 'Отмена',
|
||||
variant = 'danger', // primary, danger, warning, success
|
||||
icon: Icon = IconAlertTriangle,
|
||||
loading = false,
|
||||
showIcon = true
|
||||
}) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
centered
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-${variant}`}
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />}
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
{showIcon && Icon && (
|
||||
<div className="flex-shrink-0 me-3">
|
||||
<Icon className={`text-${variant}`} size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-grow-1">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="mb-0">{message}</p>
|
||||
) : (
|
||||
message
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmModal;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
|
||||
function EmptyState({
|
||||
icon: Icon,
|
||||
title = 'Нет данных',
|
||||
description,
|
||||
action,
|
||||
secondaryAction,
|
||||
}) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<div className="empty-icon">
|
||||
{Icon ? <Icon size={48} className="text-muted" /> : null}
|
||||
</div>
|
||||
<p className="empty-title">{title}</p>
|
||||
{description && (
|
||||
<p className="empty-subtitle text-muted">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{(action || secondaryAction) && (
|
||||
<div className="empty-action">
|
||||
{action}
|
||||
{secondaryAction && (
|
||||
<span className="ms-2">{secondaryAction}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { IconAlertTriangle, IconX } from '@tabler/icons-react';
|
||||
|
||||
function ErrorAlert({ message, details, onClose }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||
<div className="d-flex">
|
||||
<div>
|
||||
<IconAlertTriangle className="icon alert-icon" />
|
||||
</div>
|
||||
<div className="flex-grow-1">
|
||||
{String(message)}
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorAlert;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* FormField - универсальное поле формы с валидацией
|
||||
* Поддерживает иконки, подсказки, ошибки и success состояния
|
||||
*/
|
||||
function FormField({
|
||||
label,
|
||||
name,
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
success,
|
||||
helpText,
|
||||
required,
|
||||
disabled,
|
||||
placeholder,
|
||||
icon: Icon,
|
||||
className = '',
|
||||
inputClassName = '',
|
||||
rows, // для textarea
|
||||
options, // для select
|
||||
...inputProps
|
||||
}) {
|
||||
const inputId = `field-${name}`;
|
||||
const hasError = !!error;
|
||||
const hasSuccess = !!success && !error;
|
||||
const isTextarea = type === 'textarea';
|
||||
const isSelect = type === 'select';
|
||||
|
||||
const inputClasses = `form-control ${hasError ? 'is-invalid' : ''} ${hasSuccess ? 'is-valid' : ''} ${inputClassName}`;
|
||||
|
||||
const handleChange = (e) => {
|
||||
onChange?.(e.target.value, e);
|
||||
};
|
||||
|
||||
const handleBlur = (e) => {
|
||||
onBlur?.(e);
|
||||
};
|
||||
|
||||
const renderInput = () => {
|
||||
if (isTextarea) {
|
||||
return (
|
||||
<textarea
|
||||
id={inputId}
|
||||
name={name}
|
||||
className={inputClasses}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
rows={rows || 3}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSelect) {
|
||||
return (
|
||||
<select
|
||||
id={inputId}
|
||||
name={name}
|
||||
className={inputClasses.replace('form-control', 'form-select')}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
||||
{...inputProps}
|
||||
>
|
||||
{options?.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
id={inputId}
|
||||
name={name}
|
||||
type={type}
|
||||
className={inputClasses}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`mb-3 ${className}`}>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="form-label">
|
||||
{label}
|
||||
{required && <span className="text-danger ms-1" aria-label="обязательное поле">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{Icon ? (
|
||||
<div className="input-icon">
|
||||
<span className="input-icon-addon">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
{renderInput()}
|
||||
</div>
|
||||
) : (
|
||||
renderInput()
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div id={`${inputId}-error`} className="invalid-feedback d-block" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSuccess && (
|
||||
<div className="valid-feedback d-block">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{helpText && !error && !success && (
|
||||
<div id={`${inputId}-help`} className="form-text">
|
||||
{helpText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormField;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import Modal from './Modal';
|
||||
|
||||
/**
|
||||
* FormModal - модальное окно с формой
|
||||
* Автоматически обрабатывает submit и отображает кнопки действий
|
||||
*/
|
||||
function FormModal({
|
||||
show,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
children,
|
||||
submitLabel = 'Сохранить',
|
||||
cancelLabel = 'Отмена',
|
||||
submitIcon: SubmitIcon,
|
||||
cancelIcon: CancelIcon,
|
||||
loading = false,
|
||||
submitVariant = 'primary',
|
||||
disabled = false,
|
||||
...modalProps
|
||||
}) {
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit?.(e);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
// Ctrl+Enter или Cmd+Enter для быстрого submit
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{CancelIcon && <CancelIcon className="icon me-2" />}
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-${submitVariant}`}
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || disabled}
|
||||
>
|
||||
{loading && <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />}
|
||||
{!loading && SubmitIcon && <SubmitIcon className="icon me-2" />}
|
||||
{submitLabel}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
{...modalProps}
|
||||
>
|
||||
<form onSubmit={handleSubmit} onKeyDown={handleKeyDown}>
|
||||
{children}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormModal;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../lib/api.js';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import ConfirmDialog from './ConfirmDialog.jsx';
|
||||
import { useNotify } from './NotifyProvider.jsx';
|
||||
import { IconHistory, IconRefresh, IconDeviceFloppy, IconInfoCircle, IconRotate2 } from '@tabler/icons-react';
|
||||
import { formatDateTimeWithRelative } from '../lib/datetime.js';
|
||||
|
||||
export default function HistoryModal({ resource, show, onClose, onRolledBack }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [items, setItems] = useState([]);
|
||||
const notify = useNotify();
|
||||
|
||||
const { refetch } = useQuery({
|
||||
queryKey: ['history', resource],
|
||||
enabled: false,
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/history/${resource}`)
|
||||
const data = Array.isArray(res.data?.items) ? res.data.items : []
|
||||
setItems(data)
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => { if (show) refetch().catch(() => notify.error('Не удалось загрузить историю версий')); }, [show, resource]);
|
||||
|
||||
const [confirmState, setConfirmState] = useState({ open: false, onConfirm: null });
|
||||
const rollback = async (versionId) => {
|
||||
if (!versionId) return;
|
||||
await new Promise((resolve) => {
|
||||
setConfirmState({
|
||||
open: true,
|
||||
onConfirm: async () => {
|
||||
setConfirmState({ open: false, onConfirm: null });
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post(`/history/${resource}/rollback`, { versionId });
|
||||
notify.success('Откат выполнен');
|
||||
onRolledBack && onRolledBack(res.data || {});
|
||||
onClose && onClose();
|
||||
} catch (e) {
|
||||
notify.error('Не удалось выполнить откат');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="history-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}>
|
||||
<div className="modal-dialog modal-lg 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="history-title" className="modal-title d-flex align-items-center">
|
||||
<IconHistory className="me-2" /> История версий
|
||||
</h5>
|
||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<div className="text-muted small d-flex align-items-center">
|
||||
<IconInfoCircle size={16} className="me-1" />
|
||||
Ресурс: <code className="ms-1">{resource}</code>
|
||||
</div>
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={() => refetch()} disabled={loading}>
|
||||
<IconRefresh className={loading ? 'spin' : ''} />
|
||||
<span className="ms-1">Обновить</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Версия</th>
|
||||
<th>Дата</th>
|
||||
<th>Размер</th>
|
||||
<th>ETag</th>
|
||||
<th className="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr><td colSpan="5" className="text-muted text-center py-4">Нет данных (возможно, версионирование бакета отключено)</td></tr>
|
||||
) : items.map((v) => (
|
||||
<tr key={v.versionId} className={v.isLatest ? 'table-info' : ''}>
|
||||
<td><code>{v.versionId}</code></td>
|
||||
<td>{v.lastModified ? formatDateTimeWithRelative(v.lastModified) : '—'}</td>
|
||||
<td>{typeof v.size === 'number' ? `${v.size} байт` : '—'}</td>
|
||||
<td><code>{v.etag || '—'}</code></td>
|
||||
<td className="text-end">
|
||||
{!v.isLatest && (
|
||||
<button className="btn btn-outline-primary btn-sm" onClick={() => rollback(v.versionId)} disabled={loading} title="Восстановить содержимое файла до этой версии">
|
||||
<IconRotate2 className="me-1" /> Восстановить
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={confirmState.open}
|
||||
title="Восстановить версию файла?"
|
||||
message="Текущее содержимое будет заменено выбранной версией. Действие можно отменить, выбрав предыдущую версию."
|
||||
confirmText="Восстановить"
|
||||
destructive
|
||||
size={'sm'}
|
||||
onCancel={() => setConfirmState({ open: false, onConfirm: null })}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
function ImportModal({
|
||||
show,
|
||||
title = 'Импорт',
|
||||
description = 'Вставьте текст или перетащите файл TXT/CSV. Формат: VALUE COMMUNITY',
|
||||
parseLine,
|
||||
validateItem,
|
||||
onConfirm,
|
||||
onClose,
|
||||
sampleHeader = ['value', 'community'],
|
||||
placeholder = 'value community\nvalue community',
|
||||
}) {
|
||||
const [text, setText] = useState('');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [parsed, setParsed] = useState({ items: [], invalid: [] });
|
||||
const [fileName, setFileName] = useState('');
|
||||
const textAreaRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setText('');
|
||||
setParsed({ items: [], invalid: [] });
|
||||
setFileName('');
|
||||
setDragOver(false);
|
||||
setTimeout(() => textAreaRef.current?.focus(), 0);
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const parseText = (raw) => {
|
||||
const lines = String(raw || '')
|
||||
.split(/\r?\n/)
|
||||
.map(l => l.trim())
|
||||
.filter(Boolean);
|
||||
const items = [];
|
||||
const invalid = [];
|
||||
for (const line of lines) {
|
||||
const obj = parseLine(line);
|
||||
if (obj && validateItem(obj)) items.push(obj); else invalid.push(line);
|
||||
}
|
||||
setParsed({ items, invalid });
|
||||
};
|
||||
|
||||
const handleDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
setFileName(file.name);
|
||||
const content = await file.text();
|
||||
setText(content);
|
||||
parseText(content);
|
||||
};
|
||||
|
||||
const handleChange = (value) => {
|
||||
setText(value);
|
||||
parseText(value);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (parsed.items.length === 0) return;
|
||||
onConfirm?.(parsed.items);
|
||||
};
|
||||
|
||||
const downloadSample = () => {
|
||||
const csv = [sampleHeader, ['example', '65000:100']]
|
||||
.map(r => r.map(x => `"${String(x ?? '').replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sample.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div className="modal-content">
|
||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">{title}</h3>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-2 text-muted">{description}</div>
|
||||
<div
|
||||
className={`mb-3 border-dashed rounded p-3 ${dragOver ? 'bg-blue-lt' : ''}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="me-3 text-muted">
|
||||
{fileName ? `Файл: ${fileName}` : 'Перетащите TXT/CSV сюда или вставьте текст ниже'}
|
||||
</div>
|
||||
<label className="btn btn-outline-primary mb-0">
|
||||
Выбрать файл
|
||||
<input type="file" accept=".txt,.csv,.log" hidden onChange={async (e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
setFileName(f.name);
|
||||
const t = await f.text();
|
||||
setText(t);
|
||||
parseText(t);
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textAreaRef}
|
||||
className="form-control"
|
||||
rows={8}
|
||||
value={text}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<div className="row mt-3 g-3">
|
||||
<div className="col-md-6">
|
||||
<div className="card"><div className="card-body">
|
||||
<div className="text-muted">Готово к импорту</div>
|
||||
<div className="h2 m-0">{parsed.items.length}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="card"><div className="card-body">
|
||||
<div className="text-muted">Пропущено (ошибки)</div>
|
||||
<div className="h2 m-0">{parsed.invalid.length}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-text mt-2">Проверьте предпросмотр и нажмите «Импортировать».</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-outline-secondary" onClick={downloadSample}>Шаблон CSV</button>
|
||||
<button className="btn" onClick={onClose}>Отмена</button>
|
||||
<button className="btn btn-primary" disabled={parsed.items.length === 0} onClick={handleConfirm}>Импортировать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportModal;
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default function LockBanner() { return null }
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* Универсальный Modal компонент
|
||||
* Использует нативный Tabler UI стиль
|
||||
*/
|
||||
function Modal({
|
||||
show,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
size = 'md', // sm, md, lg, xl
|
||||
backdrop = true,
|
||||
keyboard = true,
|
||||
scrollable = false,
|
||||
centered = false,
|
||||
className = ''
|
||||
}) {
|
||||
const modalRef = useRef(null);
|
||||
const backdropRef = useRef(null);
|
||||
|
||||
// Управление классом modal-open на body
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
document.body.classList.add('modal-open');
|
||||
// Trap focus внутри модалки
|
||||
modalRef.current?.focus();
|
||||
} else {
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('modal-open');
|
||||
};
|
||||
}, [show]);
|
||||
|
||||
// Обработка ESC
|
||||
useEffect(() => {
|
||||
if (keyboard && show) {
|
||||
const handleEsc = (e) => {
|
||||
if (e.key === 'Escape') onClose?.();
|
||||
};
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
return () => document.removeEventListener('keydown', handleEsc);
|
||||
}
|
||||
}, [keyboard, show, onClose]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const handleBackdropClick = (e) => {
|
||||
if (backdrop && e.target === backdropRef.current) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="modal-backdrop fade show"
|
||||
onClick={handleBackdropClick}
|
||||
style={{ zIndex: 1050 }}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`modal fade show d-block ${className}`}
|
||||
tabIndex="-1"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ zIndex: 1055 }}
|
||||
ref={modalRef}
|
||||
>
|
||||
<div className={`modal-dialog${size !== 'md' ? ` modal-${size}` : ''}${scrollable ? ' modal-dialog-scrollable' : ''}${centered ? ' modal-dialog-centered' : ''}`}>
|
||||
<div className="modal-content">
|
||||
{title && (
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{title}</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-body">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="modal-footer">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { IconCheck, IconAlertTriangle, IconInfoCircle, IconX } from '@tabler/icons-react';
|
||||
|
||||
const NotifyContext = createContext({
|
||||
add: () => {},
|
||||
success: () => {},
|
||||
error: () => {},
|
||||
info: () => {},
|
||||
warning: () => {},
|
||||
remove: () => {},
|
||||
clear: () => {},
|
||||
});
|
||||
|
||||
const DEFAULT_TIMEOUT = {
|
||||
success: 3000,
|
||||
info: 4000,
|
||||
warning: 5000,
|
||||
error: 7000,
|
||||
};
|
||||
|
||||
export function NotifyProvider({ children }) {
|
||||
const [items, setItems] = useState([]); // { id, type, message, count, createdAt }
|
||||
const timeouts = useRef(new Map());
|
||||
const idSeq = useRef(1);
|
||||
|
||||
const remove = useCallback((id) => {
|
||||
setItems((prev) => prev.filter((n) => n.id !== id));
|
||||
const t = timeouts.current.get(id);
|
||||
if (t) { clearTimeout(t); timeouts.current.delete(id); }
|
||||
}, []);
|
||||
|
||||
const schedule = useCallback((id, type) => {
|
||||
const dur = DEFAULT_TIMEOUT[type] ?? 4000;
|
||||
const old = timeouts.current.get(id);
|
||||
if (old) clearTimeout(old);
|
||||
const t = setTimeout(() => remove(id), dur);
|
||||
timeouts.current.set(id, t);
|
||||
}, [remove]);
|
||||
|
||||
const add = useCallback((type, message, details) => {
|
||||
const text = String(message || '').trim();
|
||||
if (!text) return;
|
||||
setItems((prev) => {
|
||||
const dup = prev.find((n) => n.type === type && n.message === text);
|
||||
if (dup) {
|
||||
const updated = prev.map((n) => n.id === dup.id ? { ...n, count: (n.count || 1) + 1, createdAt: Date.now(), details: details ?? n.details } : n);
|
||||
schedule(dup.id, type);
|
||||
return updated;
|
||||
}
|
||||
const id = idSeq.current++;
|
||||
const next = [...prev, { id, type, message: text, details, count: 1, createdAt: Date.now() }];
|
||||
schedule(id, type);
|
||||
return next;
|
||||
});
|
||||
}, [schedule]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setItems([]);
|
||||
for (const t of timeouts.current.values()) clearTimeout(t);
|
||||
timeouts.current.clear();
|
||||
}, []);
|
||||
|
||||
const api = useMemo(() => ({
|
||||
add,
|
||||
success: (m, d) => add('success', m, d),
|
||||
error: (m, d) => add('error', m, d),
|
||||
info: (m, d) => add('info', m, d),
|
||||
warning: (m, d) => add('warning', m, d),
|
||||
remove,
|
||||
clear,
|
||||
}), [add, remove, clear]);
|
||||
|
||||
useEffect(() => {
|
||||
// Опционально доступ из консоли / старого кода
|
||||
window.notify = api;
|
||||
return () => { if (window.notify === api) delete window.notify; };
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<NotifyContext.Provider value={api}>
|
||||
{children}
|
||||
<NotifyViewport items={items} onClose={remove} />
|
||||
</NotifyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotify() {
|
||||
return useContext(NotifyContext);
|
||||
}
|
||||
|
||||
// Единый тост-успех для мутаций (POST/DELETE/PUT/PATCH)
|
||||
export function notifyMutationSuccess(message, details) {
|
||||
// Тихий режим: по умолчанию успех показываем не тостом, а через inline-баннер на страницах.
|
||||
// Оставляем возможность вручную вызвать тост при необходимости, если передан details?.forceToast === true
|
||||
try {
|
||||
const text = message || 'Операция выполнена';
|
||||
const forceToast = Boolean(details && details.forceToast);
|
||||
if (forceToast && typeof window !== 'undefined' && window.notify?.success) {
|
||||
window.notify.success(text, details);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function colorByType(type) {
|
||||
switch (type) {
|
||||
case 'success': return 'success';
|
||||
case 'error': return 'danger';
|
||||
case 'warning': return 'warning';
|
||||
default: return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
function iconByType(type) {
|
||||
switch (type) {
|
||||
case 'success': return <IconCheck className="me-2" />;
|
||||
case 'warning': return <IconAlertTriangle className="me-2" />;
|
||||
case 'error': return <IconAlertTriangle className="me-2" />;
|
||||
default: return <IconInfoCircle className="me-2" />;
|
||||
}
|
||||
}
|
||||
|
||||
function NotifyViewport({ items, onClose }) {
|
||||
return (
|
||||
<div className="position-fixed top-0 end-0 p-3" style={{ zIndex: 1080, pointerEvents: 'none' }}>
|
||||
<div className="d-flex flex-column gap-2 align-items-end">
|
||||
{items.map((n) => (
|
||||
<div key={n.id} className={`alert alert-${colorByType(n.type)} alert-dismissible shadow-sm`} role="alert" style={{ minWidth: 320, pointerEvents: 'auto' }}>
|
||||
<div className="d-flex align-items-start">
|
||||
<div className="me-1 mt-1">{iconByType(n.type)}</div>
|
||||
<div className="flex-grow-1">
|
||||
{n.message}
|
||||
{n.details && (
|
||||
<details className="small mt-1">
|
||||
<summary>Подробнее</summary>
|
||||
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>{typeof n.details === 'string' ? n.details : JSON.stringify(n.details, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
{n.count > 1 && (
|
||||
<span className="badge bg-white text-body border ms-2">×{n.count}</span>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn-close" aria-label="Close" onClick={() => onClose(n.id)}>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
|
||||
function PageHeader({ title, pretitle, actions, meta }) {
|
||||
return (
|
||||
<div className="page-header d-print-none mb-4">
|
||||
{/* Верхняя строка: только заголовок */}
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h2 className="page-title">{title}</h2>
|
||||
{pretitle && (
|
||||
<div className="page-pretitle mt-1">{pretitle}</div>
|
||||
)}
|
||||
{meta && (
|
||||
<div className="text-muted small mt-1">{meta}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Отдельная строка под заголовком: действия страницы */}
|
||||
{actions && (
|
||||
<div className="mt-4 d-print-none">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHeader
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
IconRefresh,
|
||||
IconUpload,
|
||||
IconDownload,
|
||||
IconDeviceFloppy,
|
||||
IconPlayerPlay,
|
||||
IconBolt,
|
||||
IconEraser,
|
||||
IconDots,
|
||||
IconEye
|
||||
} from '@tabler/icons-react';
|
||||
import PortalDropdown from './PortalDropdown.jsx';
|
||||
import { IconClock } from '@tabler/icons-react';
|
||||
import { formatDateTimeWithRelative } from '../lib/datetime.js';
|
||||
|
||||
function PageHeaderActions({
|
||||
loading = false,
|
||||
onPreview,
|
||||
disablePreview = false,
|
||||
onRefresh,
|
||||
disableRefresh = false,
|
||||
onImport,
|
||||
onExport,
|
||||
disableExport = false,
|
||||
onClear,
|
||||
disableClear = false,
|
||||
onClearCommunities,
|
||||
disableClearCommunities = false,
|
||||
onSave,
|
||||
disableSave = false,
|
||||
onHistory,
|
||||
lastModified,
|
||||
onOnlineUpdate,
|
||||
onBackgroundUpdate,
|
||||
}) {
|
||||
const secondaryButtons = Boolean(onPreview || onImport || onExport || onClear || onClearCommunities || onHistory);
|
||||
// Рендер плейсхолдеров во время загрузки/перезагрузки страницы
|
||||
if (loading) {
|
||||
const showUpdateGroup = Boolean(onBackgroundUpdate || onOnlineUpdate);
|
||||
const placeholderBtn = (width = 120) => (
|
||||
<span
|
||||
className="btn disabled placeholder me-2"
|
||||
style={{ height: 38, width }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="header-actions" role="toolbar" aria-label="Действия страницы (загрузка)">
|
||||
<div className="btn-group">
|
||||
{showUpdateGroup && (
|
||||
<div className="btn-group">
|
||||
{placeholderBtn(170)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="vr d-none d-lg-inline" />
|
||||
<div className="btn-group">
|
||||
{onRefresh && placeholderBtn(120)}
|
||||
{(onImport || onExport) && placeholderBtn(160)}
|
||||
{onClear && placeholderBtn(130)}
|
||||
{onClearCommunities && placeholderBtn(180)}
|
||||
{onSave && placeholderBtn(140)}
|
||||
{onHistory && placeholderBtn(110)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="header-actions" role="toolbar" aria-label="Действия страницы">
|
||||
<div className="btn-group">
|
||||
{(onBackgroundUpdate || onOnlineUpdate) && (
|
||||
<div className="btn-group">
|
||||
{/* Дефолтное действие — фоновое обновление */}
|
||||
<button
|
||||
className="btn btn-outline-success"
|
||||
type="button"
|
||||
onClick={onBackgroundUpdate || onOnlineUpdate}
|
||||
title="Фоновое обновление BGP (POST)"
|
||||
>
|
||||
<IconPlayerPlay className="me-1" /> Фоновое обновление
|
||||
</button>
|
||||
{(onBackgroundUpdate && onOnlineUpdate) && (
|
||||
<PortalDropdown
|
||||
buttonClassName="btn btn-outline-success dropdown-toggle dropdown-toggle-split"
|
||||
buttonProps={{ 'aria-label': 'Другие варианты обновления' }}
|
||||
align="right"
|
||||
buttonContent={null}
|
||||
>
|
||||
<button className="dropdown-item" type="button" onClick={onBackgroundUpdate}>
|
||||
<IconPlayerPlay className="me-1" /> Фоновое обновление
|
||||
</button>
|
||||
<button className="dropdown-item" type="button" onClick={onOnlineUpdate}>
|
||||
<IconBolt className="me-1" /> Online-обновление
|
||||
</button>
|
||||
</PortalDropdown>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="vr d-none d-lg-inline" />
|
||||
<div className="btn-group d-none d-sm-inline-flex">
|
||||
{onPreview && (
|
||||
<button className="btn btn-outline-primary" type="button" onClick={onPreview} disabled={disablePreview} title="Предпросмотр изменений">
|
||||
<IconEye className="me-1" /> Предпросмотр
|
||||
</button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button className="btn btn-outline-secondary" type="button" onClick={onRefresh} disabled={disableRefresh} title="Обновить данные">
|
||||
<IconRefresh className={loading ? 'spin me-1' : 'me-1'} /> Обновить
|
||||
</button>
|
||||
)}
|
||||
{(onImport || onExport) && (
|
||||
<div className="dropdown">
|
||||
<PortalDropdown
|
||||
buttonClassName="btn btn-outline-primary dropdown-toggle"
|
||||
buttonProps={{ title: 'Импорт/Экспорт' }}
|
||||
align="left"
|
||||
buttonContent={<><IconDownload className="me-1" /> Импорт/Экспорт</>}
|
||||
>
|
||||
{onImport && (
|
||||
<button className="dropdown-item" type="button" onClick={onImport}>
|
||||
<IconUpload className="me-1" /> Импорт
|
||||
</button>
|
||||
)}
|
||||
{onExport && (
|
||||
<button className="dropdown-item" type="button" onClick={onExport} disabled={disableExport}>
|
||||
<IconDownload className="me-1" /> Экспорт
|
||||
</button>
|
||||
)}
|
||||
</PortalDropdown>
|
||||
</div>
|
||||
)}
|
||||
{onClear && (
|
||||
<button className="btn btn-outline-secondary" type="button" onClick={onClear} disabled={disableClear} title="Очистить пустые/невалидные">
|
||||
Очистить
|
||||
</button>
|
||||
)}
|
||||
{onClearCommunities && (
|
||||
<button className="btn btn-outline-secondary" type="button" onClick={onClearCommunities} disabled={disableClearCommunities} title="Удалить все записи выбранного community">
|
||||
<IconEraser className="me-1" /> Очистить community
|
||||
</button>
|
||||
)}
|
||||
{onSave && (
|
||||
<button className="btn btn-primary" type="button" onClick={onSave} disabled={disableSave} title="Сохранить">
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconDeviceFloppy className="me-1" /> Сохранить
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onHistory && (
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-outline-secondary" type="button" onClick={onHistory} title="История версий">История</button>
|
||||
{lastModified && (
|
||||
<button className="btn btn-outline-secondary" type="button" onClick={onHistory} title={`Последние изменения: ${formatDateTimeWithRelative(lastModified)}`}>
|
||||
<IconClock className="me-1" />
|
||||
<span className="d-none d-sm-inline">{formatDateTimeWithRelative(lastModified)}</span>
|
||||
<span className="d-inline d-sm-none">{formatDateTimeWithRelative(lastModified)}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Компактное меню для маленьких экранов */}
|
||||
{secondaryButtons && (
|
||||
<div className="btn-group d-inline-flex d-sm-none">
|
||||
<PortalDropdown
|
||||
buttonClassName="btn btn-outline-secondary"
|
||||
buttonProps={{ title: 'Ещё действия' }}
|
||||
align="left"
|
||||
buttonContent={<><IconDots className="me-1" /> Ещё</>}
|
||||
>
|
||||
{onPreview && (
|
||||
<button className="dropdown-item" type="button" onClick={onPreview} disabled={disablePreview}>
|
||||
<IconEye className="me-1" /> Предпросмотр
|
||||
</button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button className="dropdown-item" type="button" onClick={onRefresh} disabled={disableRefresh}>
|
||||
<IconRefresh className="me-1" /> Обновить
|
||||
</button>
|
||||
)}
|
||||
{onImport && (
|
||||
<button className="dropdown-item" type="button" onClick={onImport}>
|
||||
<IconUpload className="me-1" /> Импорт
|
||||
</button>
|
||||
)}
|
||||
{onExport && (
|
||||
<button className="dropdown-item" type="button" onClick={onExport} disabled={disableExport}>
|
||||
<IconDownload className="me-1" /> Экспорт
|
||||
</button>
|
||||
)}
|
||||
{onClear && (
|
||||
<button className="dropdown-item" type="button" onClick={onClear} disabled={disableClear}>
|
||||
Очистить
|
||||
</button>
|
||||
)}
|
||||
{onClearCommunities && (
|
||||
<button className="dropdown-item" type="button" onClick={onClearCommunities} disabled={disableClearCommunities}>
|
||||
<IconEraser className="me-1" /> Очистить community
|
||||
</button>
|
||||
)}
|
||||
{onHistory && (
|
||||
<button className="dropdown-item" type="button" onClick={onHistory}>
|
||||
История
|
||||
</button>
|
||||
)}
|
||||
</PortalDropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageHeaderActions;
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
function getBody() {
|
||||
if (typeof document === 'undefined') return null;
|
||||
return document.body;
|
||||
}
|
||||
|
||||
function PortalDropdown({
|
||||
buttonClassName = 'btn btn-outline-secondary dropdown-toggle',
|
||||
buttonProps = {},
|
||||
align = 'left',
|
||||
children,
|
||||
buttonContent,
|
||||
menuClassName = 'dropdown-menu show',
|
||||
}) {
|
||||
const buttonRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0, minWidth: 0 });
|
||||
const BASE_Z = 1035; // above navbar (1030), below modal-backdrop (1040) and modal (1050)
|
||||
|
||||
const updatePosition = () => {
|
||||
const btn = buttonRef.current;
|
||||
if (!btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const minWidth = rect.width; // make menu at least as wide as button
|
||||
let left = rect.left;
|
||||
if (align === 'right') {
|
||||
const menu = menuRef.current;
|
||||
const menuWidth = menu ? menu.offsetWidth : 0;
|
||||
left = Math.max(0, rect.right - (menuWidth || rect.width));
|
||||
}
|
||||
setCoords({ top: rect.bottom + window.scrollY, left: left + window.scrollX, minWidth });
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open) updatePosition();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onScroll = () => updatePosition();
|
||||
const onResize = () => updatePosition();
|
||||
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||
const onClickOutside = (e) => {
|
||||
if (!menuRef.current || !buttonRef.current) return;
|
||||
if (!menuRef.current.contains(e.target) && !buttonRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('resize', onResize);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const body = getBody();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
{...buttonProps}
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={buttonClassName}
|
||||
onClick={(e) => { e.preventDefault(); setOpen((v) => !v); }}
|
||||
aria-expanded={open ? 'true' : 'false'}
|
||||
>
|
||||
{buttonContent}
|
||||
</button>
|
||||
{open && body && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{ position: 'absolute', top: `${coords.top}px`, left: `${coords.left}px`, zIndex: BASE_Z, minWidth: `${coords.minWidth}px` }}
|
||||
>
|
||||
<div className={menuClassName} style={{ display: 'block' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PortalDropdown;
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { IconX } from '@tabler/icons-react'
|
||||
|
||||
/**
|
||||
* Компонент для отображения прогресса долгих операций
|
||||
* Показывает progress bar и возможность отмены
|
||||
*/
|
||||
function ProgressIndicator({
|
||||
progress = 0, // 0-100
|
||||
total = 100,
|
||||
current = 0,
|
||||
message = 'Обработка...',
|
||||
onCancel = null,
|
||||
variant = 'blue', // blue, green, red, yellow, purple
|
||||
showPercentage = true,
|
||||
showCount = true,
|
||||
animated = true,
|
||||
className = ''
|
||||
}) {
|
||||
const percentage = total > 0 ? Math.min(100, Math.max(0, (current / total) * 100)) : progress
|
||||
|
||||
return (
|
||||
<div className={`card ${className}`}>
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<div className="text-muted">{message}</div>
|
||||
{onCancel && (
|
||||
<button
|
||||
className="btn btn-sm btn-ghost-secondary btn-icon"
|
||||
onClick={onCancel}
|
||||
title="Отменить"
|
||||
>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="progress mb-2" style={{ height: '0.5rem' }}>
|
||||
<div
|
||||
className={`progress-bar ${animated ? 'progress-bar-striped progress-bar-animated' : ''} bg-${variant}`}
|
||||
role="progressbar"
|
||||
style={{ width: `${percentage}%` }}
|
||||
aria-valuenow={percentage}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
{showCount && total > 0 && (
|
||||
<div className="text-muted small">
|
||||
Обработано: <strong>{current}</strong> из <strong>{total}</strong>
|
||||
</div>
|
||||
)}
|
||||
{showPercentage && (
|
||||
<div className={`text-${variant} fw-bold`}>
|
||||
{percentage.toFixed(0)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Линейный progress indicator (более компактный)
|
||||
*/
|
||||
export function LinearProgress({
|
||||
progress = 0,
|
||||
variant = 'blue',
|
||||
message = null,
|
||||
className = ''
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{message && (
|
||||
<div className="text-muted small mb-1">{message}</div>
|
||||
)}
|
||||
<div className="progress" style={{ height: '0.25rem' }}>
|
||||
<div
|
||||
className={`progress-bar progress-bar-indeterminate bg-${variant}`}
|
||||
style={progress > 0 ? { width: `${progress}%` } : {}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Circular progress (кольцевой индикатор)
|
||||
*/
|
||||
export function CircularProgress({
|
||||
progress = 0,
|
||||
size = 64,
|
||||
strokeWidth = 4,
|
||||
variant = 'blue',
|
||||
showPercentage = true,
|
||||
className = ''
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
|
||||
// Цвета Tabler
|
||||
const colors = {
|
||||
blue: '#206bc4',
|
||||
green: '#2fb344',
|
||||
red: '#d63939',
|
||||
yellow: '#f76707',
|
||||
purple: '#ae3ec9',
|
||||
azure: '#4299e1'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`d-inline-flex align-items-center justify-content-center ${className}`}>
|
||||
<svg width={size} height={size}>
|
||||
{/* Фоновое кольцо */}
|
||||
<circle
|
||||
stroke="#e9ecef"
|
||||
fill="transparent"
|
||||
strokeWidth={strokeWidth}
|
||||
r={radius}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
/>
|
||||
{/* Прогресс */}
|
||||
<circle
|
||||
stroke={colors[variant] || colors.blue}
|
||||
fill="transparent"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
r={radius}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
style={{
|
||||
transform: 'rotate(-90deg)',
|
||||
transformOrigin: '50% 50%',
|
||||
transition: 'stroke-dashoffset 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
{/* Процент в центре */}
|
||||
{showPercentage && (
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dy=".3em"
|
||||
fontSize="14"
|
||||
fontWeight="600"
|
||||
fill={colors[variant] || colors.blue}
|
||||
>
|
||||
{Math.round(progress)}%
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressIndicator
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { IconPlus, IconChecks } from '@tabler/icons-react';
|
||||
|
||||
function QuickAddBar({
|
||||
placeholder = 'value community\nvalue community',
|
||||
parseLine,
|
||||
validateItem,
|
||||
onApply,
|
||||
className = '',
|
||||
help = 'Вставьте строки: VALUE ПРОБЕЛ COMMUNITY',
|
||||
}) {
|
||||
const [value, setValue] = useState('');
|
||||
const [readyCount, setReadyCount] = useState(0);
|
||||
const [invalidCount, setInvalidCount] = useState(0);
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const lines = String(value || '')
|
||||
.split(/\r?\n/)
|
||||
.map(l => l.trim())
|
||||
.filter(Boolean);
|
||||
let ok = 0, bad = 0;
|
||||
for (const line of lines) {
|
||||
const obj = parseLine(line);
|
||||
if (obj && validateItem(obj)) ok++; else bad++;
|
||||
}
|
||||
setReadyCount(ok);
|
||||
setInvalidCount(bad);
|
||||
}, [value, parseLine, validateItem]);
|
||||
|
||||
const handleApply = () => {
|
||||
const lines = String(value || '')
|
||||
.split(/\r?\n/)
|
||||
.map(l => l.trim())
|
||||
.filter(Boolean);
|
||||
const items = [];
|
||||
for (const line of lines) {
|
||||
const obj = parseLine(line);
|
||||
if (obj && validateItem(obj)) items.push(obj);
|
||||
}
|
||||
if (items.length > 0) {
|
||||
onApply?.(items);
|
||||
setValue('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`card card-md ${className}`}>
|
||||
<div className="card-header">
|
||||
<h3 className="card-title"><IconPlus className="icon me-2" />Быстрое добавление</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="mb-2 text-muted">{help}</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="form-control"
|
||||
rows={4}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
<div className="row g-3 mt-2">
|
||||
<div className="col">
|
||||
<div className="card"><div className="card-body p-2">
|
||||
<div className="text-muted">Готово</div>
|
||||
<div className="h3 m-0">{readyCount}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="card"><div className="card-body p-2">
|
||||
<div className="text-muted">Ошибки</div>
|
||||
<div className="h3 m-0">{invalidCount}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer d-flex justify-content-end">
|
||||
<button className="btn btn-primary" disabled={readyCount === 0} onClick={handleApply}>
|
||||
<IconChecks className="icon me-2" />Добавить к списку
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuickAddBar;
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../lib/api.js';
|
||||
import { IconHash, IconClock, IconFileText, IconRefresh } from '@tabler/icons-react';
|
||||
import { formatDateTimeWithRelative } from '../lib/datetime.js';
|
||||
|
||||
function S3MetaBar({ keys = [], className = '' }) {
|
||||
const [meta, setMeta] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchMeta = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/s3/last-modified');
|
||||
setMeta(res.data || {});
|
||||
} catch {
|
||||
setMeta({});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchMeta(); }, []);
|
||||
|
||||
const copyMeta = async (k) => {
|
||||
try {
|
||||
const it = meta[k] || {};
|
||||
const text = `etag: ${it?.etag || ''}\nlastModified: ${it?.lastModified || ''}`;
|
||||
await navigator.clipboard?.writeText(text);
|
||||
window.notify?.success?.('Скопировано в буфер');
|
||||
} catch (e) {
|
||||
window.notify?.error?.('Не удалось скопировать', String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const renderKey = (k) => {
|
||||
const it = meta[k];
|
||||
return (
|
||||
<div key={k} className="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span className="badge bg-blue-lt text-blue d-inline-flex align-items-center" title="ETag"><IconHash size={14} className="me-1" /><span className="font-monospace">{it?.etag || '—'}</span></span>
|
||||
<span className="badge bg-blue-lt text-blue d-inline-flex align-items-center" title="Время последнего изменения"><IconClock size={14} className="me-1" />{it?.lastModified ? formatDateTimeWithRelative(it.lastModified) : '—'}</span>
|
||||
<span className="badge bg-blue-lt text-blue d-inline-flex align-items-center"><IconFileText size={14} className="me-1" />{typeof it?.contentLength === 'number' ? `${it.contentLength} байт` : '—'}</span>
|
||||
<button className="btn btn-outline-secondary btn-sm" type="button" onClick={() => copyMeta(k)} title="Копировать ETag/Last-Modified">
|
||||
<IconFileText size={14} className="me-1" />Копировать
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`card-footer ${className}`} style={{ position: 'sticky', bottom: 0, zIndex: 5, background: 'var(--tblr-card-bg)', borderTop: '1px solid rgba(0,0,0,.05)' }}>
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="d-flex flex-column gap-1">
|
||||
{keys.length === 0 ? renderKey('domainsNew') : keys.map(renderKey)}
|
||||
</div>
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={fetchMeta} disabled={loading}>
|
||||
<IconRefresh className={loading ? 'spin' : ''} />
|
||||
<span className="ms-1">Обновить</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default S3MetaBar;
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { IconBookmark, IconBookmarkFilled, IconTrash, IconPlus, IconSearch } from '@tabler/icons-react'
|
||||
|
||||
/**
|
||||
* Компонент для управления сохраненными фильтрами
|
||||
* Использует localStorage для хранения
|
||||
*/
|
||||
function SavedFilters({
|
||||
pageKey, // уникальный ключ страницы (например 'domains', 'servers')
|
||||
currentFilters = {}, // текущие фильтры { searchTerm, filterCommunity, sortField, sortOrder }
|
||||
onApplyFilter = () => {}, // callback для применения фильтра
|
||||
className = ''
|
||||
}) {
|
||||
const [savedFilters, setSavedFilters] = useState([])
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const [filterName, setFilterName] = useState('')
|
||||
const [showList, setShowList] = useState(false)
|
||||
|
||||
const storageKey = `savedFilters_${pageKey}`
|
||||
|
||||
// Загрузка сохраненных фильтров из localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (stored) {
|
||||
setSavedFilters(JSON.parse(stored))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading saved filters:', e)
|
||||
}
|
||||
}, [storageKey])
|
||||
|
||||
// Сохранение фильтров в localStorage
|
||||
const saveToStorage = (filters) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(filters))
|
||||
setSavedFilters(filters)
|
||||
} catch (e) {
|
||||
console.error('Error saving filters:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Сохранить текущий фильтр
|
||||
const handleSaveFilter = () => {
|
||||
if (!filterName.trim()) return
|
||||
|
||||
const newFilter = {
|
||||
id: Date.now(),
|
||||
name: filterName.trim(),
|
||||
filters: { ...currentFilters },
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
const updated = [...savedFilters, newFilter]
|
||||
saveToStorage(updated)
|
||||
setFilterName('')
|
||||
setShowSaveDialog(false)
|
||||
window.notify?.success?.('Фильтр сохранен')
|
||||
}
|
||||
|
||||
// Удалить фильтр
|
||||
const handleDeleteFilter = (id) => {
|
||||
const updated = savedFilters.filter(f => f.id !== id)
|
||||
saveToStorage(updated)
|
||||
window.notify?.success?.('Фильтр удален')
|
||||
}
|
||||
|
||||
// Применить фильтр
|
||||
const handleApplyFilter = (filter) => {
|
||||
onApplyFilter(filter.filters)
|
||||
setShowList(false)
|
||||
window.notify?.success?.(`Применен фильтр: ${filter.name}`)
|
||||
}
|
||||
|
||||
// Проверка, есть ли активные фильтры
|
||||
const hasActiveFilters = Object.values(currentFilters).some(v =>
|
||||
v !== '' && v !== null && v !== undefined && v !== 'asc'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`dropdown ${className}`}>
|
||||
{/* Кнопка открытия */}
|
||||
<button
|
||||
className="btn btn-outline-primary dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<IconBookmark size={18} className="me-1" />
|
||||
Фильтры
|
||||
{savedFilters.length > 0 && (
|
||||
<span className="badge bg-blue ms-1">{savedFilters.length}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="dropdown-menu dropdown-menu-end" style={{ minWidth: '300px' }}>
|
||||
<div className="dropdown-header">Сохраненные фильтры</div>
|
||||
|
||||
{/* Список сохраненных фильтров */}
|
||||
{savedFilters.length === 0 ? (
|
||||
<div className="dropdown-item-text text-muted small">
|
||||
Нет сохраненных фильтров
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{savedFilters.map(filter => (
|
||||
<div key={filter.id} className="dropdown-item d-flex justify-content-between align-items-center">
|
||||
<button
|
||||
className="btn btn-link text-start text-decoration-none p-0 flex-grow-1"
|
||||
onClick={() => handleApplyFilter(filter)}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconBookmarkFilled size={16} className="me-2 text-blue" />
|
||||
<div>
|
||||
<div>{filter.name}</div>
|
||||
<div className="text-muted small">
|
||||
{new Date(filter.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-icon btn-ghost-danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteFilter(filter.id)
|
||||
}}
|
||||
title="Удалить"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dropdown-divider"></div>
|
||||
|
||||
{/* Форма сохранения нового фильтра */}
|
||||
{!showSaveDialog && hasActiveFilters && (
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
>
|
||||
<IconPlus size={18} className="me-2" />
|
||||
Сохранить текущий фильтр
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showSaveDialog && (
|
||||
<div className="dropdown-item-text">
|
||||
<div className="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Название фильтра..."
|
||||
value={filterName}
|
||||
onChange={(e) => setFilterName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveFilter()
|
||||
if (e.key === 'Escape') setShowSaveDialog(false)
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
className="btn btn-sm btn-primary flex-grow-1"
|
||||
onClick={handleSaveFilter}
|
||||
disabled={!filterName.trim()}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => {
|
||||
setShowSaveDialog(false)
|
||||
setFilterName('')
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasActiveFilters && !showSaveDialog && (
|
||||
<div className="dropdown-item-text text-muted small">
|
||||
Установите фильтры, чтобы сохранить
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SavedFilters
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import api from '../lib/api.js'
|
||||
|
||||
export default function SettingsModal({ open, onClose }) {
|
||||
const ref = useRef(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [etag, setEtag] = useState('')
|
||||
const [dohServer, setDohServer] = useState('')
|
||||
const [wsUrl, setWsUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setError('')
|
||||
setSuccess('')
|
||||
setLoading(true)
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await api.get('/ui-settings')
|
||||
const data = res?.data || {}
|
||||
setDohServer(String(data?.dohServer || ''))
|
||||
setWsUrl(String(data?.wsUpdateUrl || ''))
|
||||
const e = res?.headers?.etag || res?.headers?.ETag || ''
|
||||
setEtag(e ? String(e) : '')
|
||||
// фокус на первом поле
|
||||
setTimeout(() => { try { ref.current?.querySelector('input[data-primary]')?.focus() } catch {} }, 0)
|
||||
} catch (e) {
|
||||
setError('Не удалось загрузить настройки')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const validateDoh = (value) => {
|
||||
if (!value) return true // допускаем пустое значение
|
||||
try {
|
||||
const u = new URL(String(value))
|
||||
return u.protocol === 'https:'
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
const validateWs = (value) => {
|
||||
if (!value) return true // допускаем пустое значение
|
||||
try {
|
||||
const u = new URL(String(value))
|
||||
return u.protocol === 'ws:' || u.protocol === 'wss:'
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
if (!validateDoh(dohServer)) { setError('Укажите корректный HTTPS URL для DoH'); return }
|
||||
if (!validateWs(wsUrl)) { setError('Укажите корректный WebSocket URL (ws:// или wss://)'); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = { settings: { dohServer: String(dohServer || '').trim(), wsUpdateUrl: String(wsUrl || '').trim() }, etag }
|
||||
const res = await api.post('/ui-settings', payload)
|
||||
const meta = res?.data || {}
|
||||
setSuccess('Настройки сохранены')
|
||||
setEtag(String(meta?.etag || ''))
|
||||
setTimeout(() => setSuccess(''), 2500)
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.message || 'Ошибка при сохранении настроек')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="settings-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}>
|
||||
<div className="modal-dialog" 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="settings-title" className="modal-title">Настройки интерфейса</h5>
|
||||
<button type="button" className="btn-close" aria-label="Close" onClick={onClose}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{error && (
|
||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||
{error}
|
||||
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible" role="alert">
|
||||
{success}
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<label className="form-label">WebSocket URL (BGP Live)</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control${wsUrl && !validateWs(wsUrl) ? ' is-invalid' : ''}`}
|
||||
placeholder="ws://host:port/ws/update_bgp?api_key=..."
|
||||
value={wsUrl}
|
||||
onChange={(e) => setWsUrl(e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<div className="form-hint">URL для Live-обновления BGP (ws:// или wss://). Можно оставить пустым.</div>
|
||||
{wsUrl && !validateWs(wsUrl) && (
|
||||
<div className="invalid-feedback">Укажите корректный WebSocket URL</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">DoH сервер</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control${dohServer && !validateDoh(dohServer) ? ' is-invalid' : ''}`}
|
||||
placeholder="https://dns.google/dns-query"
|
||||
value={dohServer}
|
||||
onChange={(e) => setDohServer(e.target.value)}
|
||||
disabled={loading || saving}
|
||||
data-primary
|
||||
/>
|
||||
<div className="form-hint">HTTPS URL для DNS-over-HTTPS (например, https://dns.google/dns-query)</div>
|
||||
{dohServer && !validateDoh(dohServer) && (
|
||||
<div className="invalid-feedback">Укажите корректный HTTPS URL</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={saving}>Закрыть</button>
|
||||
<button type="button" className="btn btn-primary" onClick={onSave} disabled={saving || loading}>
|
||||
{saving ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status" />
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
'Сохранить'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Компонент для отображения простых sparkline графиков (мини-графики)
|
||||
* Использует только SVG без внешних библиотек
|
||||
*/
|
||||
function Sparkline({
|
||||
data = [], // массив чисел [10, 20, 15, 30, ...]
|
||||
width = 100,
|
||||
height = 30,
|
||||
strokeColor = 'currentColor',
|
||||
strokeWidth = 2,
|
||||
fillColor = 'none',
|
||||
showDots = false,
|
||||
className = ''
|
||||
}) {
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
const max = Math.max(...data)
|
||||
const min = Math.min(...data)
|
||||
const range = max - min || 1 // избегаем деления на 0
|
||||
|
||||
// Нормализация точек
|
||||
const points = data.map((value, index) => {
|
||||
const x = (index / (data.length - 1 || 1)) * width
|
||||
const y = height - ((value - min) / range) * height
|
||||
return { x, y, value }
|
||||
})
|
||||
|
||||
// Создание path для линии
|
||||
const pathData = points
|
||||
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x},${point.y}`)
|
||||
.join(' ')
|
||||
|
||||
// Создание path для области (fill)
|
||||
const areaData = fillColor !== 'none'
|
||||
? `${pathData} L ${width},${height} L 0,${height} Z`
|
||||
: null
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{/* Область под графиком */}
|
||||
{areaData && (
|
||||
<path
|
||||
d={areaData}
|
||||
fill={fillColor}
|
||||
opacity={0.2}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Линия графика */}
|
||||
<path
|
||||
d={pathData}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Точки на графике */}
|
||||
{showDots && points.map((point, index) => (
|
||||
<circle
|
||||
key={index}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={strokeWidth}
|
||||
fill={strokeColor}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sparkline с трендом (стрелка вверх/вниз)
|
||||
*/
|
||||
export function SparklineWithTrend({
|
||||
data = [],
|
||||
width = 100,
|
||||
height = 30,
|
||||
label = '',
|
||||
showPercentage = true,
|
||||
...rest
|
||||
}) {
|
||||
if (!data || data.length < 2) return null
|
||||
|
||||
const first = data[0]
|
||||
const last = data[data.length - 1]
|
||||
const diff = last - first
|
||||
const percentage = first !== 0 ? ((diff / first) * 100).toFixed(1) : 0
|
||||
const isPositive = diff >= 0
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Sparkline data={data} width={width} height={height} {...rest} />
|
||||
<div className="d-flex align-items-center">
|
||||
{showPercentage && (
|
||||
<span className={`badge ${isPositive ? 'bg-success-lt text-success' : 'bg-danger-lt text-danger'}`}>
|
||||
{isPositive ? '+' : ''}{percentage}%
|
||||
</span>
|
||||
)}
|
||||
{label && <span className="text-muted small ms-1">{label}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sparkline bar chart (столбчатый график)
|
||||
*/
|
||||
export function SparklineBar({
|
||||
data = [],
|
||||
width = 100,
|
||||
height = 30,
|
||||
barColor = 'currentColor',
|
||||
className = ''
|
||||
}) {
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
const max = Math.max(...data, 1)
|
||||
const barWidth = width / data.length - 1
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{data.map((value, index) => {
|
||||
const barHeight = (value / max) * height
|
||||
const x = index * (barWidth + 1)
|
||||
const y = height - barHeight
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill={barColor}
|
||||
rx={1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sparkline
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Skeleton loader для таблиц с анимацией pulse
|
||||
*/
|
||||
export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false }) {
|
||||
const Row = () => (
|
||||
<tr>
|
||||
{hasCheckbox && (
|
||||
<td style={{ width: '40px' }}>
|
||||
<div className="placeholder placeholder-xs col-6"></div>
|
||||
</td>
|
||||
)}
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<td key={i}>
|
||||
<div className="placeholder col-8 placeholder-wave" style={{ height: 14 }}></div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
return (
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
{hasCheckbox && <th style={{ width: '40px' }}></th>}
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<th key={i}><div className="placeholder col-6 placeholder-wave"></div></th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, i) => <Row key={i} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableEmpty({ cols = 3, children }) {
|
||||
return (
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={cols} className="text-center text-muted py-5">
|
||||
{children}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton для карточек статистики на Dashboard
|
||||
*/
|
||||
export function CardSkeleton({ count = 4 }) {
|
||||
return (
|
||||
<div className="row g-3">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="col-md-3">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="placeholder placeholder-lg bg-secondary rounded me-3" style={{ width: '48px', height: '48px' }}></div>
|
||||
<div className="flex-grow-1">
|
||||
<div className="placeholder col-8 placeholder-wave mb-2"></div>
|
||||
<div className="placeholder col-6 placeholder-wave"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton для списка элементов
|
||||
*/
|
||||
export function ListSkeleton({ rows = 5 }) {
|
||||
return (
|
||||
<div className="list-group list-group-flush">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="list-group-item">
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="placeholder bg-secondary rounded me-3" style={{ width: '40px', height: '40px' }}></div>
|
||||
<div className="flex-grow-1">
|
||||
<div className="placeholder col-8 placeholder-wave mb-2"></div>
|
||||
<div className="placeholder col-6 placeholder-wave"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton для формы
|
||||
*/
|
||||
export function FormSkeleton({ fields = 3 }) {
|
||||
return (
|
||||
<div>
|
||||
{Array.from({ length: fields }).map((_, i) => (
|
||||
<div key={i} className="mb-3">
|
||||
<div className="placeholder col-3 placeholder-wave mb-2"></div>
|
||||
<div className="placeholder col-12 placeholder-wave" style={{ height: '38px' }}></div>
|
||||
</div>
|
||||
))}
|
||||
<div className="placeholder col-4 placeholder-wave" style={{ height: '38px' }}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect, createContext, useContext } from 'react'
|
||||
import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconAlertTriangle,
|
||||
IconInfoCircle,
|
||||
IconRefresh
|
||||
} from '@tabler/icons-react'
|
||||
|
||||
/**
|
||||
* Контекст для глобального управления toast уведомлениями
|
||||
*/
|
||||
const ToastContext = createContext({
|
||||
success: () => {},
|
||||
error: () => {},
|
||||
warning: () => {},
|
||||
info: () => {},
|
||||
})
|
||||
|
||||
export const useToast = () => useContext(ToastContext)
|
||||
|
||||
/**
|
||||
* Toast уведомление (отдельный элемент)
|
||||
*/
|
||||
function Toast({ toast, onClose }) {
|
||||
const [isLeaving, setIsLeaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.duration && toast.duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
handleClose()
|
||||
}, toast.duration)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [toast.duration])
|
||||
|
||||
const handleClose = () => {
|
||||
setIsLeaving(true)
|
||||
setTimeout(() => onClose(toast.id), 300) // анимация 300ms
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
switch (toast.type) {
|
||||
case 'success': return <IconCheck size={20} />
|
||||
case 'error': return <IconX size={20} />
|
||||
case 'warning': return <IconAlertTriangle size={20} />
|
||||
case 'info': return <IconInfoCircle size={20} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
const getColorClass = () => {
|
||||
switch (toast.type) {
|
||||
case 'success': return 'alert-success'
|
||||
case 'error': return 'alert-danger'
|
||||
case 'warning': return 'alert-warning'
|
||||
case 'info': return 'alert-info'
|
||||
default: return 'alert-info'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`alert ${getColorClass()} alert-dismissible fade ${isLeaving ? '' : 'show'}`}
|
||||
role="alert"
|
||||
style={{
|
||||
minWidth: '300px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 0.5rem 1rem rgba(0,0,0,.15)',
|
||||
animation: isLeaving ? 'slideOutRight 0.3s ease-out' : 'slideInRight 0.3s ease-out'
|
||||
}}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
<div className="me-2 mt-1">
|
||||
{getIcon()}
|
||||
</div>
|
||||
<div className="flex-grow-1">
|
||||
{toast.title && (
|
||||
<h4 className="alert-title">{toast.title}</h4>
|
||||
)}
|
||||
<div className="text-secondary">{toast.message}</div>
|
||||
|
||||
{/* Действия */}
|
||||
{toast.actions && toast.actions.length > 0 && (
|
||||
<div className="btn-list mt-2">
|
||||
{toast.actions.map((action, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className={`btn btn-sm ${action.variant || 'btn-outline-primary'}`}
|
||||
onClick={() => {
|
||||
action.onClick?.()
|
||||
handleClose()
|
||||
}}
|
||||
>
|
||||
{action.icon && <action.icon size={14} className="me-1" />}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Контейнер для всех toast уведомлений
|
||||
*/
|
||||
export function ToastContainer({ children }) {
|
||||
const [toasts, setToasts] = useState([])
|
||||
|
||||
const addToast = (type, message, options = {}) => {
|
||||
const id = Date.now()
|
||||
const toast = {
|
||||
id,
|
||||
type,
|
||||
message,
|
||||
title: options.title,
|
||||
duration: options.duration !== undefined ? options.duration : (type === 'error' ? 0 :
|
||||
type === 'warning' ? 5000 :
|
||||
type === 'info' ? 4000 :
|
||||
3000),
|
||||
actions: options.actions
|
||||
}
|
||||
setToasts(prev => [...prev, toast])
|
||||
return id
|
||||
}
|
||||
|
||||
const removeToast = (id) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}
|
||||
|
||||
const contextValue = {
|
||||
success: (message, options) => addToast('success', message, options),
|
||||
error: (message, options) => addToast('error', message, options),
|
||||
warning: (message, options) => addToast('warning', message, options),
|
||||
info: (message, options) => addToast('info', message, options),
|
||||
}
|
||||
|
||||
// Глобальный доступ через window.toast (для обратной совместимости)
|
||||
useEffect(() => {
|
||||
window.toast = contextValue
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={contextValue}>
|
||||
{children}
|
||||
|
||||
{/* Контейнер с toast'ами */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px'
|
||||
}}
|
||||
>
|
||||
{toasts.map(toast => (
|
||||
<Toast key={toast.id} toast={toast} onClose={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CSS анимации */}
|
||||
<style>{`
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToastContainer
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { IconTrophy, IconMapPin, IconCloud, IconHash, IconChartBar } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* Компонент для отображения Top-N статистики
|
||||
*/
|
||||
function TopNStats({ data, loading }) {
|
||||
const [topCountries, setTopCountries] = useState([]);
|
||||
const [topProviders, setTopProviders] = useState([]);
|
||||
const [topCommunities, setTopCommunities] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !Array.isArray(data.servers)) return;
|
||||
|
||||
// Подсчет по странам
|
||||
const countriesMap = new Map();
|
||||
data.servers.forEach(s => {
|
||||
const country = s.country || 'Unknown';
|
||||
countriesMap.set(country, (countriesMap.get(country) || 0) + 1);
|
||||
});
|
||||
const topC = Array.from(countriesMap.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
setTopCountries(topC);
|
||||
|
||||
// Подсчет по провайдерам
|
||||
const providersMap = new Map();
|
||||
data.servers.forEach(s => {
|
||||
const provider = s.provider || 'Unknown';
|
||||
providersMap.set(provider, (providersMap.get(provider) || 0) + 1);
|
||||
});
|
||||
const topP = Array.from(providersMap.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
setTopProviders(topP);
|
||||
|
||||
}, [data]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body text-center py-4">
|
||||
<div className="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<span className="text-muted">Загрузка статистики...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getFlagEmoji = (countryCode) => {
|
||||
if (!countryCode) return '🌐';
|
||||
const map = { SWE: 'SE', UK: 'GB', RU: 'RU', US: 'US', DE: 'DE', NL: 'NL', SG: 'SG' };
|
||||
const cc = (map[countryCode.toUpperCase()] || countryCode).slice(0, 2);
|
||||
if (cc.length !== 2) return '🌐';
|
||||
return cc.replace(/./g, (ch) => String.fromCodePoint(127397 + ch.charCodeAt()));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="row g-3">
|
||||
{/* Top Страны */}
|
||||
<div className="col-md-6">
|
||||
<div className="card h-100">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconMapPin className="me-2" size={20} />
|
||||
Top-5 Стран
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{topCountries.length === 0 ? (
|
||||
<div className="text-center text-muted">Нет данных</div>
|
||||
) : (
|
||||
<div className="list-group list-group-flush">
|
||||
{topCountries.map((item, idx) => {
|
||||
const maxCount = topCountries[0]?.count || 1;
|
||||
const percentage = ((item.count / maxCount) * 100).toFixed(0);
|
||||
return (
|
||||
<div key={item.name} className="list-group-item px-0">
|
||||
<div className="d-flex align-items-center justify-content-between mb-1">
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="me-2" style={{ fontSize: idx === 0 ? '1.5rem' : '1.2rem' }}>
|
||||
{idx === 0 ? '🏆' : getFlagEmoji(item.name)}
|
||||
</span>
|
||||
<strong>{item.name}</strong>
|
||||
</div>
|
||||
<span className="badge bg-blue-lt text-blue">{item.count}</span>
|
||||
</div>
|
||||
<div className="progress" style={{ height: 6 }}>
|
||||
<div
|
||||
className="progress-bar bg-blue"
|
||||
style={{ width: `${percentage}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={percentage}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Провайдеры */}
|
||||
<div className="col-md-6">
|
||||
<div className="card h-100">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconCloud className="me-2" size={20} />
|
||||
Top-5 Провайдеров
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{topProviders.length === 0 ? (
|
||||
<div className="text-center text-muted">Нет данных</div>
|
||||
) : (
|
||||
<div className="list-group list-group-flush">
|
||||
{topProviders.map((item, idx) => {
|
||||
const maxCount = topProviders[0]?.count || 1;
|
||||
const percentage = ((item.count / maxCount) * 100).toFixed(0);
|
||||
return (
|
||||
<div key={item.name} className="list-group-item px-0">
|
||||
<div className="d-flex align-items-center justify-content-between mb-1">
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="me-2" style={{ fontSize: '1.2rem' }}>
|
||||
{idx === 0 ? '🏆' : '☁️'}
|
||||
</span>
|
||||
<strong className="text-truncate" style={{ maxWidth: 200 }}>{item.name}</strong>
|
||||
</div>
|
||||
<span className="badge bg-green-lt text-green">{item.count}</span>
|
||||
</div>
|
||||
<div className="progress" style={{ height: 6 }}>
|
||||
<div
|
||||
className="progress-bar bg-green"
|
||||
style={{ width: `${percentage}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={percentage}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopNStats;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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,138 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { IconX, IconPlayerPlay, IconPlayerPause, IconPlugConnected, IconCopy, IconTrash, IconClock } from '@tabler/icons-react';
|
||||
|
||||
function WsUpdateModal({ show, url, onClose }) {
|
||||
const [rawMessages, setRawMessages] = useState([]);
|
||||
const [status, setStatus] = useState('connecting'); // connecting | open | closed | error
|
||||
const wsRef = useRef(null);
|
||||
const bottomRef = useRef(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [startedAt, setStartedAt] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
try {
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
setStatus('connecting');
|
||||
ws.onopen = () => setStatus('open');
|
||||
ws.onmessage = (evt) => {
|
||||
const text = typeof evt.data === 'string' ? evt.data : '';
|
||||
// Ищем JSON-часть в сообщении (после `$ ` или без него)
|
||||
let parsed = null;
|
||||
let jsonStart = text.indexOf('{');
|
||||
if (jsonStart >= 0) {
|
||||
const candidate = text.slice(jsonStart).trim();
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Если пришёл JSON — показываем только поле `line`. Остальные события (start, etc.) не выводим в лог
|
||||
if (parsed) {
|
||||
if (parsed.event === 'start' && parsed.ts) {
|
||||
setStartedAt(new Date(parsed.ts));
|
||||
return; // ничего не выводим
|
||||
}
|
||||
if (typeof parsed.line === 'string' && parsed.line.trim().length > 0) {
|
||||
setRawMessages((prev) => [...prev, { text: parsed.line, json: parsed }]);
|
||||
return;
|
||||
}
|
||||
// Нет поля line — игнорируем
|
||||
return;
|
||||
}
|
||||
|
||||
// Если JSON не распарсился — выводим как есть (обрежем префикс `$ ` при наличии)
|
||||
const clean = text.startsWith('$ ') ? text.slice(2) : text;
|
||||
if (clean.trim().length === 0) return;
|
||||
setRawMessages((prev) => [...prev, { text: clean, json: null }]);
|
||||
};
|
||||
ws.onerror = () => setStatus('error');
|
||||
ws.onclose = () => setStatus('closed');
|
||||
} catch (e) {
|
||||
setStatus('error');
|
||||
}
|
||||
return () => {
|
||||
try { wsRef.current?.close(); } catch {}
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [show, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll) bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [rawMessages, show, autoScroll]);
|
||||
|
||||
const plainLog = useMemo(() => rawMessages.map(m => (typeof m.text === 'string' ? m.text : '')).join('\n'), [rawMessages]);
|
||||
|
||||
const elapsedMs = useMemo(() => {
|
||||
if (!startedAt || rawMessages.length === 0) return null;
|
||||
const lastTs = Date.now();
|
||||
return lastTs - startedAt.getTime();
|
||||
}, [startedAt, rawMessages]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal-dialog modal-lg">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header d-flex align-items-center justify-content-between">
|
||||
<h5 className="modal-title d-flex align-items-center m-0">
|
||||
<IconPlugConnected className="me-2" /> Логи запуска
|
||||
<span className={`badge ms-2 ${status === 'open' ? 'bg-green-lt text-green' : status === 'connecting' ? 'bg-orange-lt text-orange' : status === 'error' ? 'bg-red-lt text-red' : 'bg-secondary-lt text-secondary'}`}>{status}</span>
|
||||
</h5>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={() => setAutoScroll(!autoScroll)} title={autoScroll ? 'Отключить автопрокрутку' : 'Включить автопрокрутку'}>
|
||||
{autoScroll ? <IconPlayerPause className="me-1" /> : <IconPlayerPlay className="me-1" />} Автопрокрутка
|
||||
</button>
|
||||
<button className="btn btn-outline-primary btn-sm" onClick={async () => { try { await navigator.clipboard.writeText(plainLog); } catch {} }} title="Скопировать лог">
|
||||
<IconCopy className="me-1" /> Копировать
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={() => setRawMessages([])} title="Очистить лог">
|
||||
<IconTrash className="me-1" /> Очистить
|
||||
</button>
|
||||
<button type="button" className="btn-close" onClick={onClose} aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="bg-dark text-light p-3 rounded" style={{ maxHeight: '60vh', overflowY: 'auto', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', fontSize: '0.875rem', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
{rawMessages.length === 0 ? (
|
||||
<div className="text-muted">Ожидание сообщений от сервера...</div>
|
||||
) : (
|
||||
rawMessages.map((m, i) => {
|
||||
const isErr = m?.json?.stream === 'stderr' || /\berror\b|\bошиб/i.test(String(m.text));
|
||||
return (
|
||||
<div key={i} className={`text-break ${isErr ? 'text-danger' : ''}`}>
|
||||
<span className="text-secondary">$</span> {m.text}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer d-flex justify-content-between align-items-center">
|
||||
<div className="text-muted small d-flex align-items-center">
|
||||
<IconClock size={16} className="me-1" />
|
||||
<span>
|
||||
Начало {startedAt ? startedAt.toLocaleTimeString() : '—'}
|
||||
{elapsedMs != null && (
|
||||
<>
|
||||
, прошло {elapsedMs} ms
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={onClose}><IconX className="me-1" /> Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WsUpdateModal;
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* useDebounce - hook для debounce значения
|
||||
* Полезен для поиска и других операций, где нужно отложить выполнение
|
||||
*
|
||||
* @param {any} value - значение для debounce
|
||||
* @param {number} delay - задержка в миллисекундах (по умолчанию 300)
|
||||
* @returns {any} - debounced значение
|
||||
*
|
||||
* Пример использования:
|
||||
* const [searchTerm, setSearchTerm] = useState('');
|
||||
* const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
*
|
||||
* useEffect(() => {
|
||||
* // API запрос с debounced значением
|
||||
* fetchResults(debouncedSearchTerm);
|
||||
* }, [debouncedSearchTerm]);
|
||||
*/
|
||||
function useDebounce(value, delay = 300) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем таймер для обновления значения
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Очищаем таймер при изменении value или delay
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export default useDebounce;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* useForm - hook для управления формами с валидацией
|
||||
*
|
||||
* @param {Object} initialValues - начальные значения полей
|
||||
* @param {Object} validationSchema - схема валидации {fieldName: validatorFn}
|
||||
* @returns {Object} - {values, errors, touched, isSubmitting, handlers}
|
||||
*
|
||||
* Пример использования:
|
||||
* const { values, errors, handleChange, handleBlur, handleSubmit } = useForm(
|
||||
* { email: '', password: '' },
|
||||
* {
|
||||
* email: (value) => !value ? 'Email обязателен' : null,
|
||||
* password: (value) => value.length < 6 ? 'Минимум 6 символов' : null
|
||||
* }
|
||||
* );
|
||||
*/
|
||||
function useForm(initialValues, validationSchema = {}) {
|
||||
const [values, setValues] = useState(initialValues);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [touched, setTouched] = useState({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Валидация одного поля
|
||||
const validateField = useCallback((name, value) => {
|
||||
if (!validationSchema[name]) return null;
|
||||
|
||||
const validator = validationSchema[name];
|
||||
const error = validator(value, values);
|
||||
|
||||
return error;
|
||||
}, [validationSchema, values]);
|
||||
|
||||
// Обработчик изменения поля
|
||||
const handleChange = useCallback((name, value) => {
|
||||
setValues(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// Валидация при изменении, если поле уже было touched
|
||||
if (touched[name]) {
|
||||
const error = validateField(name, value);
|
||||
setErrors(prev => ({ ...prev, [name]: error }));
|
||||
}
|
||||
}, [touched, validateField]);
|
||||
|
||||
// Обработчик blur (потеря фокуса)
|
||||
const handleBlur = useCallback((name) => {
|
||||
setTouched(prev => ({ ...prev, [name]: true }));
|
||||
|
||||
const value = values[name];
|
||||
const error = validateField(name, value);
|
||||
setErrors(prev => ({ ...prev, [name]: error }));
|
||||
}, [values, validateField]);
|
||||
|
||||
// Валидация всех полей
|
||||
const validate = useCallback(() => {
|
||||
const newErrors = {};
|
||||
let isValid = true;
|
||||
|
||||
Object.keys(validationSchema).forEach(name => {
|
||||
const error = validateField(name, values[name]);
|
||||
if (error) {
|
||||
newErrors[name] = error;
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
}, [validationSchema, values, validateField]);
|
||||
|
||||
// Обработчик submit
|
||||
const handleSubmit = useCallback(async (onSubmit) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Отмечаем все поля как touched
|
||||
const allTouched = Object.keys(values).reduce((acc, key) => {
|
||||
acc[key] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
setTouched(allTouched);
|
||||
|
||||
const isValid = validate();
|
||||
|
||||
if (isValid) {
|
||||
try {
|
||||
await onSubmit(values);
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [values, validate]);
|
||||
|
||||
// Сброс формы
|
||||
const reset = useCallback(() => {
|
||||
setValues(initialValues);
|
||||
setErrors({});
|
||||
setTouched({});
|
||||
setIsSubmitting(false);
|
||||
}, [initialValues]);
|
||||
|
||||
// Установка значения поля программно
|
||||
const setValue = useCallback((name, value) => {
|
||||
setValues(prev => ({ ...prev, [name]: value }));
|
||||
}, []);
|
||||
|
||||
// Установка ошибки поля программно
|
||||
const setError = useCallback((name, error) => {
|
||||
setErrors(prev => ({ ...prev, [name]: error }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
validate,
|
||||
reset,
|
||||
setValue,
|
||||
setError,
|
||||
setValues,
|
||||
setErrors
|
||||
};
|
||||
}
|
||||
|
||||
export default useForm;
|
||||
|
||||
@@ -1 +1,56 @@
|
||||
/* Using Bootstrap styles, this file can be empty or used for custom global styles. */
|
||||
|
||||
/* Sticky footer helpers */
|
||||
.page { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.footer { margin-top: auto; }
|
||||
|
||||
/* ===== Tabler visual consistency (global overrides) ===== */
|
||||
:root {
|
||||
--app-gap-xs: .25rem;
|
||||
--app-gap-sm: .5rem;
|
||||
--app-gap-md: .75rem;
|
||||
--app-gap-lg: 1rem;
|
||||
}
|
||||
|
||||
/* Header actions under page header */
|
||||
.page-header .header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--app-gap-sm);
|
||||
}
|
||||
.page-header .header-actions .btn-group { margin-right: var(--app-gap-sm); }
|
||||
.page-header .vr { margin: 0 var(--app-gap-sm); }
|
||||
|
||||
/* Buttons: icon spacing left */
|
||||
.btn .icon { margin-right: .375rem; }
|
||||
.btn.btn-icon .icon { margin-right: 0; }
|
||||
|
||||
/* Tables: remove hover background for rows (as per project preference) */
|
||||
.table tbody tr:hover { background-color: transparent; }
|
||||
/* Центровка контента ячеек и компактный вид */
|
||||
.table.card-table th, .table.card-table td { vertical-align: middle; }
|
||||
|
||||
/* Compact card spacing */
|
||||
.card.card-md { margin-bottom: var(--app-gap-lg); }
|
||||
|
||||
/* Utilities used in components */
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.border-dashed { border: 2px dashed rgba(0,0,0,.08); }
|
||||
.transition { transition: all .2s ease; }
|
||||
.rotate-180 { transform: rotate(180deg); }
|
||||
|
||||
/* Input icon alignment tweaks */
|
||||
.input-icon .input-icon-addon { display: flex; align-items: center; }
|
||||
|
||||
/* Modal close button click area */
|
||||
.modal .btn-close { position: absolute; right: .75rem; top: .75rem; }
|
||||
|
||||
/* Фильтры: удобная ширина селектов */
|
||||
.form-select.w-auto { min-width: 180px; }
|
||||
|
||||
/* Икон-кнопки одинакового размера */
|
||||
.btn.btn-icon.btn-sm { width: 2rem; height: 2rem; display: inline-flex; align-items: center; justify-content: center; }
|
||||
|
||||
/* Отступы между бейджами и в empty-action */
|
||||
.badge + .badge { margin-left: .25rem; }
|
||||
.empty .empty-action .btn + .btn { margin-left: .5rem; }
|
||||
@@ -0,0 +1,130 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Базовый axios-клиент для всего приложения
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
validateStatus: (status) => (status >= 200 && status < 300) || status === 304,
|
||||
});
|
||||
|
||||
// Простое кеширование ответов GET по ключу запроса + ETag
|
||||
const responseCache = new Map(); // key -> { etag, data, headers }
|
||||
function buildCacheKey(config) {
|
||||
try {
|
||||
const url = config.baseURL ? new URL(config.url, 'http://x').toString().replace('http://x','') : config.url;
|
||||
const params = config.params || {};
|
||||
const keys = Object.keys(params).sort();
|
||||
const qs = keys.map(k => `${k}=${encodeURIComponent(params[k])}`).join('&');
|
||||
return `${config.method || 'get'} ${url}${qs ? '?' + qs : ''}`;
|
||||
} catch {
|
||||
return `${config.method || 'get'} ${config.url}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Авто-ретрай для идемпотентных GET: до 2 попыток с экспоненциальной задержкой
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
try {
|
||||
const method = String(response?.config?.method || 'get').toLowerCase();
|
||||
if (method === 'get') {
|
||||
const key = buildCacheKey(response.config);
|
||||
const etag = response.headers?.etag;
|
||||
// Если 304 — всегда пробуем вернуть кеш, не трогая response.data
|
||||
if (response.status === 304) {
|
||||
const cached = responseCache.get(key);
|
||||
if (cached) {
|
||||
return { ...response, status: 200, data: cached.data, headers: { ...cached.headers, 'x-from-cache': '1' } };
|
||||
}
|
||||
// нет кеша — вернём пустые семантически корректные данные (чтобы не падали .map)
|
||||
// вызывающий код должен ожидать типы, поэтому лучше не подменять тип неожиданно.
|
||||
// Просто пропустим дальше как есть — до второго интерсептора и обработчиков.
|
||||
return response;
|
||||
}
|
||||
// Не 304: обновляем кеш, но только если есть валидный etag
|
||||
if (etag) {
|
||||
responseCache.set(key, { etag, data: response.data, headers: response.headers });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const config = error?.config || {};
|
||||
const isGet = String(config.method || 'get').toLowerCase() === 'get';
|
||||
const status = error?.response?.status;
|
||||
const retriable = !error.response || (status >= 500 && status !== 501);
|
||||
config.__retryCount = config.__retryCount || 0;
|
||||
if (isGet && retriable && config.__retryCount < 2) {
|
||||
config.__retryCount += 1;
|
||||
const delay = 300 * Math.pow(2, config.__retryCount - 1);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return api(config);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Нормализация ошибок и уведомления по умолчанию
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
try {
|
||||
const status = err?.response?.status;
|
||||
const data = err?.response?.data || {};
|
||||
const message = data?.message || err?.message || 'Ошибка запроса';
|
||||
const code = data?.code;
|
||||
const details = data?.details;
|
||||
const requestId = data?.requestId || err?.response?.headers?.['x-request-id'] || err?.config?.headers?.['X-Request-Id'];
|
||||
if (status >= 400 && typeof window !== 'undefined' && window.notify?.error) {
|
||||
window.notify.add('error', `${message}${status ? ` (${status})` : ''}${requestId ? ` • reqId=${requestId}` : ''}`, details ? { code, requestId, details } : undefined);
|
||||
}
|
||||
} catch {}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
// Request интерсептор: If-Match из etag (если не задан явный заголовок)
|
||||
api.interceptors.request.use((config) => {
|
||||
try {
|
||||
// If-None-Match для GET на базе кеша
|
||||
const method = String(config.method || 'get').toLowerCase();
|
||||
if (method === 'get') {
|
||||
// Исключение: для /communities всегда запрашиваем свежие данные (без условного GET)
|
||||
const rawUrl = String(config.url || '');
|
||||
const pathOnly = rawUrl.split('?')[0];
|
||||
const isCommunities = pathOnly === '/communities' || pathOnly.endsWith('/communities');
|
||||
|
||||
if (!isCommunities) {
|
||||
const key = buildCacheKey(config);
|
||||
const cached = responseCache.get(key);
|
||||
if (cached?.etag) {
|
||||
config.headers = config.headers || {};
|
||||
if (!config.headers['If-None-Match']) config.headers['If-None-Match'] = String(cached.etag);
|
||||
}
|
||||
} else if (config.headers && config.headers['If-None-Match']) {
|
||||
// На всякий случай удалим, если был выставлен где-то ещё
|
||||
delete config.headers['If-None-Match'];
|
||||
}
|
||||
}
|
||||
// Если заголовок не указан, но в теле есть etag — пробуем проставить If-Match
|
||||
if (!config.headers?.['If-Match'] && config.data && typeof config.data === 'object' && config.data.etag) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers['If-Match'] = String(config.data.etag);
|
||||
}
|
||||
} catch {}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Утилита: опциональная распаковка стандартного формата
|
||||
export function unwrapStd(res) {
|
||||
const data = res?.data;
|
||||
if (data && typeof data === 'object' && Array.isArray(data.items)) return data.items;
|
||||
return data;
|
||||
}
|
||||
|
||||
export default api;
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Утилиты для получения названия ASN с кэшем в localStorage
|
||||
|
||||
const CACHE_KEY = 'asnNameCache.v1';
|
||||
const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 дней
|
||||
|
||||
function readCache() {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
if (!raw) return {};
|
||||
const data = JSON.parse(raw);
|
||||
return typeof data === 'object' && data ? data : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(cache) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getCached(asn) {
|
||||
const cache = readCache();
|
||||
const entry = cache[String(asn)];
|
||||
if (!entry) return null;
|
||||
if (Date.now() > (entry.ts || 0)) return null;
|
||||
return entry.name || null;
|
||||
}
|
||||
|
||||
function setCached(asn, name, ttlMs = DEFAULT_TTL_MS) {
|
||||
const cache = readCache();
|
||||
cache[String(asn)] = { name: String(name || '').trim(), ts: Date.now() + ttlMs };
|
||||
writeCache(cache);
|
||||
}
|
||||
|
||||
async function fetchFromBGPView(asn) {
|
||||
try {
|
||||
const url = `https://api.bgpview.io/asn/AS${asn}`;
|
||||
const res = await fetch(url, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
const name = json?.data?.name || json?.data?.description || '';
|
||||
return String(name || '').trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFromRIPE(asn) {
|
||||
try {
|
||||
const url = `https://stat.ripe.net/data/as-overview/data.json?resource=AS${asn}`;
|
||||
const res = await fetch(url, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
const name = json?.data?.holder || json?.data?.name || '';
|
||||
return String(name || '').trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAsnName(asn, { ttlMs = DEFAULT_TTL_MS } = {}) {
|
||||
const key = String(asn).trim();
|
||||
if (!/^[0-9]+$/.test(key)) return null;
|
||||
const cached = getCached(key);
|
||||
if (cached) return cached;
|
||||
// попытаемся получить из провайдеров
|
||||
const providers = [fetchFromBGPView, fetchFromRIPE];
|
||||
for (const p of providers) {
|
||||
const name = await p(key);
|
||||
if (name) {
|
||||
setCached(key, name, ttlMs);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
// Закэшируем пустой ответ на короткое время, чтобы не долбить API
|
||||
setCached(key, '', 60 * 60 * 1000); // 1 час
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAsnNameSync(asn) {
|
||||
return getCached(asn);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
function pad(num) {
|
||||
return String(num).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function formatDateTime(input) {
|
||||
if (!input) return '';
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const day = pad(d.getDate());
|
||||
const month = pad(d.getMonth() + 1);
|
||||
const year = d.getFullYear();
|
||||
const h = pad(d.getHours());
|
||||
const m = pad(d.getMinutes());
|
||||
const s = pad(d.getSeconds());
|
||||
return `${day}.${month}.${year} ${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
export function formatRelative(input) {
|
||||
if (!input) return '';
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 5) return 'только что';
|
||||
if (sec < 60) return `${sec} сек назад`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min} мин назад`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr} ч назад`;
|
||||
const days = Math.floor(hr / 24);
|
||||
return `${days} дн назад`;
|
||||
}
|
||||
|
||||
export function formatDateTimeWithRelative(input) {
|
||||
const abs = formatDateTime(input);
|
||||
const rel = formatRelative(input);
|
||||
if (!abs && !rel) return '';
|
||||
if (abs && rel) return `${abs} (${rel})`;
|
||||
return abs || rel;
|
||||
}
|
||||
|
||||
|
||||
+11
-3
@@ -2,10 +2,18 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import '@tabler/core/dist/css/tabler.min.css'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
// Динамический импорт Tabler JS и присваивание в window
|
||||
import('@tabler/core/dist/js/tabler.min.js').then((mod) => {
|
||||
window.Tabler = window.Tabler || window.globalThis.Tabler || mod;
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
@@ -4,4 +4,13 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Generated
+49
@@ -8,10 +8,40 @@
|
||||
"name": "router-lists-ui",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@tabler/core": "^1.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/core": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/core/-/core-1.3.2.tgz",
|
||||
"integrity": "sha512-QDVJbv48YJrahBLdxYkLi6NutQv4jGbkUWyzxE2NcNJ3s3GGpRx98JmbAoN92NZKNmf26vZdW6k2Q5haVKlS4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "5.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -38,6 +68,25 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.6",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz",
|
||||
"integrity": "sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/twbs"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@popperjs/core": "^2.11.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/core": "^1.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user