refactor(tests): update package.json scripts for linting and testing; enhance CI workflows for linting and testing integration
Frontend CI / frontend (push) Successful in 10m38s
Frontend CI / frontend (push) Successful in 10m38s
This commit is contained in:
@@ -0,0 +1,248 @@
|
|||||||
|
---
|
||||||
|
name: Анализ и оптимизация Router Lists UI
|
||||||
|
overview: Комплексный анализ проекта Router Lists UI (RouterOS Manager) — управление BGP-списками, MikroTik и S3. План включает предложения по новым фичам и оптимизациям архитектуры, производительности и качества кода.
|
||||||
|
todos: []
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Анализ проекта Router Lists UI
|
||||||
|
|
||||||
|
## Текущее состояние проекта
|
||||||
|
|
||||||
|
**Router Lists UI** — fullstack-приложение для управления BGP-списками (домены, IP-диапазоны, ASN), фильтрами и конфигурациями MikroTik на базе S3 (Yandex Object Storage).
|
||||||
|
|
||||||
|
### Архитектура
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Frontend [Frontend - Vite + React]
|
||||||
|
App[App.jsx]
|
||||||
|
Pages[20+ страниц]
|
||||||
|
Hooks[useApiQuery, useErrorHandler]
|
||||||
|
API[api.js + axios]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend [Backend - Express]
|
||||||
|
Server[server.js]
|
||||||
|
Routes[30+ роутов]
|
||||||
|
S3Service[s3Service]
|
||||||
|
Schedulers[4 планировщика]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph External [Внешние сервисы]
|
||||||
|
S3[S3 / Yandex Object Storage]
|
||||||
|
MikroTik[MikroTik RouterOS API]
|
||||||
|
BGP[BGP Background Service]
|
||||||
|
end
|
||||||
|
|
||||||
|
App --> Pages
|
||||||
|
Pages --> Hooks
|
||||||
|
Hooks --> API
|
||||||
|
API -->|REST| Server
|
||||||
|
Server --> Routes
|
||||||
|
Routes --> S3Service
|
||||||
|
S3Service --> S3
|
||||||
|
Routes --> MikroTik
|
||||||
|
Server --> Schedulers
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Технологический стек
|
||||||
|
|
||||||
|
|
||||||
|
| Слой | Технологии |
|
||||||
|
| -------- | ------------------------------------------------------------------------------- |
|
||||||
|
| Frontend | React 19, Vite 7, Tabler, TanStack Query, React Router, Recharts, @xyflow/react |
|
||||||
|
| Backend | Express, AWS SDK (S3), Pino, Prometheus, Helmet, Rate Limit |
|
||||||
|
| Инфра | Docker, Gitea Actions, S3 |
|
||||||
|
|
||||||
|
|
||||||
|
### Сильные стороны
|
||||||
|
|
||||||
|
- Продуманная обработка ошибок (retry, ErrorBoundary, NetworkErrorHandler)
|
||||||
|
- ETag/If-None-Match для условных GET и кеширования
|
||||||
|
- Soft-locks для предотвращения гонок при редактировании
|
||||||
|
- Prometheus-метрики, health/ready endpoints
|
||||||
|
- i18n (RU/EN), темы (light/dark), два варианта layout
|
||||||
|
- Command Palette, горячие клавиши
|
||||||
|
- Типы в `frontend/src/types/index.ts` (частичная типизация)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендуемые фичи
|
||||||
|
|
||||||
|
### 1. Аутентификация и авторизация (высокий приоритет)
|
||||||
|
|
||||||
|
**Проблема:** API открыт без авторизации — любой с доступом к сети может изменять данные.
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
- JWT или session-based auth (express-session + passport)
|
||||||
|
- Роли: `viewer` (только чтение), `editor` (CRUD), `admin` (всё + настройки)
|
||||||
|
- Middleware на backend для проверки токена/сессии
|
||||||
|
- Страница логина, защищённые роуты на frontend
|
||||||
|
|
||||||
|
### 2. Аудит-лог изменений
|
||||||
|
|
||||||
|
- Логирование всех мутаций (кто, когда, что изменил)
|
||||||
|
- Хранение в S3 (`audit/YYYY-MM-DD.jsonl`) или отдельная таблица
|
||||||
|
- UI: страница «История изменений» с фильтрами по ресурсу, пользователю, дате
|
||||||
|
|
||||||
|
### 3. Уведомления и алерты
|
||||||
|
|
||||||
|
- Расширить [AlertsContext](frontend/src/contexts/AlertsContext.jsx): email/Telegram при критичных событиях
|
||||||
|
- Настраиваемые триггеры: сервер недоступен, превышение лимита ресурсов
|
||||||
|
- Интеграция с webhook (например, Slack/Discord)
|
||||||
|
|
||||||
|
### 4. Экспорт/импорт в разных форматах
|
||||||
|
|
||||||
|
- Экспорт доменов/ASN/IP в CSV, JSON, plain text
|
||||||
|
- Импорт из CSV с маппингом колонок
|
||||||
|
- Bulk-операции: «заменить community для выбранных» уже есть — добавить «импорт из файла»
|
||||||
|
|
||||||
|
### 5. Расширенный поиск и фильтры
|
||||||
|
|
||||||
|
- Полнотекстовый поиск по доменам (при больших объёмах — индексация на backend)
|
||||||
|
- Фильтры: по community, по дате добавления (если добавить метаданные)
|
||||||
|
- Сохранённые фильтры (уже есть [SavedFilters](frontend/src/components/SavedFilters.jsx)) — синхронизация с backend
|
||||||
|
|
||||||
|
### 6. Дашборд и отчёты
|
||||||
|
|
||||||
|
- Кастомные виджеты на Dashboard (drag-and-drop)
|
||||||
|
- Отчёты: топ доменов по community, динамика изменений
|
||||||
|
- Scheduled reports (PDF/email по расписанию)
|
||||||
|
|
||||||
|
### 7. API-документация
|
||||||
|
|
||||||
|
- OpenAPI/Swagger для backend API
|
||||||
|
- Интерактивная документация (Swagger UI) на `/api-docs`
|
||||||
|
- Генерация клиента для внешних интеграций
|
||||||
|
|
||||||
|
### 8. Мобильная версия (PWA)
|
||||||
|
|
||||||
|
- Service Worker для офлайн-просмотра кешированных данных
|
||||||
|
- Адаптивные таблицы (уже используется Tabler)
|
||||||
|
- Установка как PWA (manifest.json)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Оптимизации проекта
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
#### 1. Code splitting и lazy loading
|
||||||
|
|
||||||
|
**Проблема:** Все 20+ страниц импортируются синхронно в [App.jsx](frontend/src/App.jsx) — большой начальный бандл.
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Вместо: import Dashboard from './Dashboard';
|
||||||
|
const Dashboard = lazy(() => import('./Dashboard'));
|
||||||
|
// Обернуть Routes в <Suspense fallback={<PageSkeleton />}>
|
||||||
|
```
|
||||||
|
|
||||||
|
Применить для тяжёлых страниц: `TrafficDashboard`, `NetworkMapDashboard`, `RouteOptimizerPage`, `FilterManager`, `MikrotikTools`, `OspfToolsPage`.
|
||||||
|
|
||||||
|
#### 2. Виртуализация таблиц
|
||||||
|
|
||||||
|
- Используется `@tanstack/react-virtual` — проверить, что все большие списки (domains, ip-ranges, asns) используют виртуализацию
|
||||||
|
- В [DomainsNewManager](frontend/src/DomainsNewManager.jsx) — пагинация на backend (`offset`/`limit`), но при `limit=0` загружаются все данные — рассмотреть серверную пагинацию для больших объёмов
|
||||||
|
|
||||||
|
#### 3. Оптимизация API-запросов
|
||||||
|
|
||||||
|
- На Easy Switch при открытии идёт множество параллельных `POST /mikrotik/ping` — добавить batch endpoint `POST /mikrotik/ping-batch` для снижения нагрузки
|
||||||
|
- Prefetch при наведении на пункты меню (React Query `prefetchQuery`)
|
||||||
|
- Дедупликация запросов к `/communities` — уже кешируется в React Query, но вызывается из многих компонентов
|
||||||
|
|
||||||
|
#### 4. Унификация работы с данными
|
||||||
|
|
||||||
|
- Часть страниц использует [useApiQuery](frontend/src/hooks/useApiQuery.js) (domains, ipRanges, asns, servers, communities), часть — ручные `useState` + `api.get/post`
|
||||||
|
- Мигрировать `DomainsNewManager`, `IPRangesManager`, `ASNsNewManager` на единые хуки из `useApiQuery` для консистентности и оптимистичных обновлений
|
||||||
|
|
||||||
|
#### 5. Миграция на TypeScript
|
||||||
|
|
||||||
|
- Есть [types/index.ts](frontend/src/types/index.ts), но большинство компонентов — `.jsx`
|
||||||
|
- Поэтапная миграция: сначала `api.js`, `useApiQuery.js`, затем страницы
|
||||||
|
- Включить `strict` в tsconfig для новых файлов
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
#### 1. Кеширование S3-ответов
|
||||||
|
|
||||||
|
- Добавить in-memory cache (например, `node-cache`) для часто читаемых объектов (`communities`, `servers`, `filters`) с TTL 30–60 сек
|
||||||
|
- Уменьшит количество обращений к S3 при активной работе
|
||||||
|
|
||||||
|
#### 2. Пул соединений и таймауты
|
||||||
|
|
||||||
|
- Проверить настройки AWS SDK: connection pooling, таймауты для S3
|
||||||
|
- Для долгих операций (address-lists, speed-test) — рассмотреть фоновые задачи (очередь jobs)
|
||||||
|
|
||||||
|
#### 3. Валидация и санитизация
|
||||||
|
|
||||||
|
- Использовать единую схему валидации (Ajv) для всех JSON-эндпоинтов
|
||||||
|
- Добавить rate limit на чувствительные операции (apply config, run script)
|
||||||
|
|
||||||
|
#### 4. Структура роутов
|
||||||
|
|
||||||
|
- [server.js](backend/server.js) — ~450 строк, много роутов в одном файле
|
||||||
|
- Вынести группы роутов в отдельные файлы: `textDataRoutes`, `jsonDataRoutes`, `mikrotikRoutes` и подключать через `app.use('/api/...', router)`
|
||||||
|
|
||||||
|
### Инфраструктура и DevOps
|
||||||
|
|
||||||
|
#### 1. Тестирование
|
||||||
|
|
||||||
|
- **Сейчас:** `npm test` — заглушка, vitest упоминается в tsconfig, но тестов нет
|
||||||
|
- Добавить unit-тесты для: `validators`, `apiErrorHandler`, `s3Helpers`, `mikrotikConfig`
|
||||||
|
- E2E (Playwright) для критичных сценариев: логин, добавление домена, применение фильтра
|
||||||
|
|
||||||
|
#### 2. CI/CD
|
||||||
|
|
||||||
|
- Добавить шаг `npm run lint` и `npm run test` в Gitea Actions перед сборкой Docker
|
||||||
|
- Отдельный workflow для frontend: `npm run build` + проверка размера бандла
|
||||||
|
|
||||||
|
#### 3. Мониторинг
|
||||||
|
|
||||||
|
- Prometheus уже есть — добавить алерты (например, в Alertmanager) на `http_errors_total`, `http_request_duration_seconds`
|
||||||
|
- Health check: проверка доступности S3 в `/ready`
|
||||||
|
|
||||||
|
#### 4. Безопасность
|
||||||
|
|
||||||
|
- Регулярное обновление зависимостей (`npm audit`)
|
||||||
|
- Секреты (ENCRYPTION_KEY, AWS keys) — только через env, не в коде
|
||||||
|
- Рассмотреть Vault или аналог для production
|
||||||
|
|
||||||
|
### Документация и DX
|
||||||
|
|
||||||
|
- README — хороший, но API описан текстом; добавить ссылку на OpenAPI
|
||||||
|
- CONTRIBUTING.md с правилами коммитов, кодстайла
|
||||||
|
- Архитектурная диаграмма в репозитории (C4 или подобная)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Приоритизация
|
||||||
|
|
||||||
|
|
||||||
|
| Категория | Действие | Приоритет |
|
||||||
|
| ------------------ | -------------------------- | --------- |
|
||||||
|
| Безопасность | Аутентификация | Высокий |
|
||||||
|
| Производительность | Lazy loading страниц | Высокий |
|
||||||
|
| Производительность | Batch ping endpoint | Средний |
|
||||||
|
| Качество | Unit-тесты | Высокий |
|
||||||
|
| Качество | Миграция на TS | Средний |
|
||||||
|
| UX | Аудит-лог | Средний |
|
||||||
|
| UX | Расширенный экспорт/импорт | Низкий |
|
||||||
|
| Инфра | CI: lint + test | Высокий |
|
||||||
|
| Инфра | S3 in-memory cache | Средний |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Следующие шаги
|
||||||
|
|
||||||
|
1. Выбрать 2–3 пункта из плана для первой итерации
|
||||||
|
2. Для аутентификации — определиться со стратегией (JWT vs session, хранение пользователей)
|
||||||
|
3. Для lazy loading — создать `PageSkeleton` и обновить роуты в App.jsx
|
||||||
|
4. Для тестов — настроить Vitest, написать первые тесты для `validators` и `apiErrorHandler`
|
||||||
|
|
||||||
@@ -8,7 +8,34 @@ on:
|
|||||||
- v4
|
- v4
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Test (frontend build)
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
build-and-push-fast:
|
build-and-push-fast:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -6,8 +6,32 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Test (frontend build)
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-test
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -29,7 +53,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile.fast
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: Frontend CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- tabler
|
||||||
|
- v3
|
||||||
|
- v4
|
||||||
|
paths:
|
||||||
|
- 'frontend/**'
|
||||||
|
- '.gitea/workflows/frontend-ci.yml'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- tabler
|
||||||
|
- v3
|
||||||
|
- v4
|
||||||
|
paths:
|
||||||
|
- 'frontend/**'
|
||||||
|
- '.gitea/workflows/frontend-ci.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Check bundle size
|
||||||
|
id: bundle-size
|
||||||
|
run: |
|
||||||
|
SIZE_BYTES=$(du -sb dist | cut -f1)
|
||||||
|
SIZE_MB=$(awk "BEGIN {printf \"%.2f\", $SIZE_BYTES / 1048576}")
|
||||||
|
echo "size_bytes=$SIZE_BYTES" >> $GITHUB_OUTPUT
|
||||||
|
echo "size_mb=$SIZE_MB" >> $GITHUB_OUTPUT
|
||||||
|
echo "## Bundle size: ${SIZE_MB} MB" >> $GITHUB_STEP_SUMMARY
|
||||||
|
# Fail if total bundle exceeds 5MB
|
||||||
|
BUNDLE_LIMIT_BYTES=5242880
|
||||||
|
if [ "$SIZE_BYTES" -gt "$BUNDLE_LIMIT_BYTES" ]; then
|
||||||
|
echo "::error::Bundle size (${SIZE_MB} MB) exceeds 5MB limit"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -15,7 +15,7 @@ export default defineConfig([
|
|||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: { ...globals.browser, process: 'readonly' },
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 'latest',
|
ecmaVersion: 'latest',
|
||||||
ecmaFeatures: { jsx: true },
|
ecmaFeatures: { jsx: true },
|
||||||
@@ -23,7 +23,8 @@ export default defineConfig([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]', argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -51,13 +51,13 @@ function ASNsNewManager() {
|
|||||||
const [originalItems, setOriginalItems] = useState([]);
|
const [originalItems, setOriginalItems] = useState([]);
|
||||||
const [etag, setEtag] = useState('');
|
const [etag, setEtag] = useState('');
|
||||||
const [lastModified, setLastModified] = useState('');
|
const [lastModified, setLastModified] = useState('');
|
||||||
const [contentLength, setContentLength] = useState(null);
|
const [, setContentLength] = useState(null);
|
||||||
const [newItem, setNewItem] = useState({ asn: '', community: '' });
|
const [newItem, setNewItem] = useState({ asn: '', community: '' });
|
||||||
const [newInvalid, setNewInvalid] = useState({ asn: false, community: false });
|
const [, setNewInvalid] = useState({ asn: false, community: false });
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const searchTimer = useRef(null);
|
const _searchTimer = useRef(null);
|
||||||
const didInit = useRef(false);
|
const didInit = useRef(false);
|
||||||
const [editingValue, setEditingValue] = useState('');
|
const [editingValue, setEditingValue] = useState('');
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
@@ -82,7 +82,7 @@ function ASNsNewManager() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get(`/communities`);
|
const res = await api.get(`/communities`);
|
||||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
setCommunities(Array.isArray(res.data) ? res.data : []);
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
// тихо игнорируем
|
// тихо игнорируем
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -298,7 +298,7 @@ function ASNsNewManager() {
|
|||||||
return { added, removed, changed };
|
return { added, removed, changed };
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [_showDiff, setShowDiff] = useState(false);
|
||||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
@@ -401,7 +401,7 @@ function ASNsNewManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Drag&Drop импорт (быстрый путь)
|
// Drag&Drop импорт (быстрый путь)
|
||||||
const onDropImport = async (e) => {
|
const _onDropImport = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = e.dataTransfer?.files?.[0];
|
const file = e.dataTransfer?.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ function AutoUrlManager() {
|
|||||||
}
|
}
|
||||||
setSuccess('Пример формата скопирован');
|
setSuccess('Пример формата скопирован');
|
||||||
setTimeout(() => setSuccess(''), 2000);
|
setTimeout(() => setSuccess(''), 2000);
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
setError('Не удалось скопировать пример');
|
setError('Не удалось скопировать пример');
|
||||||
setTimeout(() => setError(''), 2000);
|
setTimeout(() => setError(''), 2000);
|
||||||
}
|
}
|
||||||
@@ -619,7 +619,7 @@ function AutoUrlManager() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredAndSortedUrls.map((url, displayIndex) => {
|
{filteredAndSortedUrls.map((url, _displayIndex) => {
|
||||||
const originalIndex = urls.findIndex(u => u === url);
|
const originalIndex = urls.findIndex(u => u === url);
|
||||||
const v = getRowValidity(url);
|
const v = getRowValidity(url);
|
||||||
const isValid = v.url && v.community;
|
const isValid = v.url && v.community;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import FormModal from './components/FormModal.jsx';
|
import FormModal from './components/FormModal.jsx';
|
||||||
@@ -51,7 +51,7 @@ function BillingManager() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [sortField, setSortField] = useState('nextPaymentDate');
|
const [sortField, setSortField] = useState('nextPaymentDate');
|
||||||
const [sortOrder, setSortOrder] = useState('asc');
|
const [sortOrder, setSortOrder] = useState('asc');
|
||||||
const [filterStatus, setFilterStatus] = useState('');
|
const [filterStatus, _setFilterStatus] = useState('');
|
||||||
const [filterUrgency, setFilterUrgency] = useState('');
|
const [filterUrgency, setFilterUrgency] = useState('');
|
||||||
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
|
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
|
||||||
const [activeTab, setActiveTab] = useState('subscriptions'); // 'subscriptions' | 'payments'
|
const [activeTab, setActiveTab] = useState('subscriptions'); // 'subscriptions' | 'payments'
|
||||||
@@ -76,14 +76,14 @@ function BillingManager() {
|
|||||||
const [editingPayment, setEditingPayment] = useState(null);
|
const [editingPayment, setEditingPayment] = useState(null);
|
||||||
const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false);
|
const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false);
|
||||||
const [paymentToDelete, setPaymentToDelete] = useState(null);
|
const [paymentToDelete, setPaymentToDelete] = useState(null);
|
||||||
const [futureDateConfirm, setFutureDateConfirm] = useState(false);
|
const [_futureDateConfirm, _setFutureDateConfirm] = useState(false);
|
||||||
|
|
||||||
// История платежей
|
// История платежей
|
||||||
const [paymentSearchTerm, setPaymentSearchTerm] = useState('');
|
const [paymentSearchTerm, _setPaymentSearchTerm] = useState('');
|
||||||
const [paymentSortField, setPaymentSortField] = useState('date');
|
const [_paymentSortField, _setPaymentSortField] = useState('date');
|
||||||
const [paymentSortOrder, setPaymentSortOrder] = useState('desc');
|
const [_paymentSortOrder, _setPaymentSortOrder] = useState('desc');
|
||||||
const [selectedPayments, setSelectedPayments] = useState(new Set());
|
const [_selectedPayments, _setSelectedPayments] = useState(new Set());
|
||||||
const [paymentPage, setPaymentPage] = useState(1);
|
const [_paymentPage, _setPaymentPage] = useState(1);
|
||||||
const paymentPageSize = 10;
|
const paymentPageSize = 10;
|
||||||
|
|
||||||
// Курсы валют
|
// Курсы валют
|
||||||
@@ -92,7 +92,7 @@ function BillingManager() {
|
|||||||
EUR: 1,
|
EUR: 1,
|
||||||
RUB: 1
|
RUB: 1
|
||||||
});
|
});
|
||||||
const [ratesLoading, setRatesLoading] = useState(false);
|
const [_ratesLoading, setRatesLoading] = useState(false);
|
||||||
|
|
||||||
// Новый элемент
|
// Новый элемент
|
||||||
const [newItem, setNewItem] = useState({
|
const [newItem, setNewItem] = useState({
|
||||||
@@ -273,7 +273,7 @@ function BillingManager() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Убираем служебное поле _external перед сохранением
|
// Убираем служебное поле _external перед сохранением
|
||||||
const payload = billingData.map(({ _external, ...rest }) => rest);
|
const payload = billingData.map(({ _external: _ext, ...rest }) => rest);
|
||||||
await api.post(`/billing`, { domains: payload });
|
await api.post(`/billing`, { domains: payload });
|
||||||
// После сохранения снимаем флаг _external со всех записей
|
// После сохранения снимаем флаг _external со всех записей
|
||||||
setBillingData(prev => prev.map(item => ({ ...item, _external: false })));
|
setBillingData(prev => prev.map(item => ({ ...item, _external: false })));
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ function CommunitiesManager() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// не отправляем служебное поле _external
|
// не отправляем служебное поле _external
|
||||||
const payload = data.map(({ _external, ...rest }) => rest);
|
const payload = data.map(({ _external: _, ...rest }) => rest);
|
||||||
await api.post(`/communities`, { communities: payload });
|
await api.post(`/communities`, { communities: payload });
|
||||||
setSuccess('Справочник сохранён!');
|
setSuccess('Справочник сохранён!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
@@ -302,7 +302,7 @@ function CommunitiesManager() {
|
|||||||
});
|
});
|
||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
setError('Неверный JSON.');
|
setError('Неверный JSON.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ import Tooltip from './components/Tooltip.jsx';
|
|||||||
import Sparkline from './components/Sparkline.jsx';
|
import Sparkline from './components/Sparkline.jsx';
|
||||||
import { useAlerts } from './contexts/AlertsContext.jsx';
|
import { useAlerts } from './contexts/AlertsContext.jsx';
|
||||||
|
|
||||||
function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previousValue }) {
|
function StatCard({ icon: _Icon, color, value, title, subtitle, to, trend, previousValue }) {
|
||||||
return (
|
return (
|
||||||
<div className="card h-100 position-relative card-hover">
|
<div className="card h-100 position-relative card-hover">
|
||||||
<div className="card-body d-flex align-items-center">
|
<div className="card-body d-flex align-items-center">
|
||||||
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
||||||
<Icon size={32} />
|
<_Icon size={32} />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-grow-1">
|
<div className="flex-grow-1">
|
||||||
<div className="d-flex align-items-baseline gap-2 mb-1">
|
<div className="d-flex align-items-baseline gap-2 mb-1">
|
||||||
@@ -64,12 +64,12 @@ function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard({ title, value, icon: Icon, color, description }) {
|
function MetricCard({ title, value, icon: _Icon, color, description }) {
|
||||||
return (
|
return (
|
||||||
<div className="card h-100 position-relative">
|
<div className="card h-100 position-relative">
|
||||||
<div className="card-body d-flex align-items-center">
|
<div className="card-body d-flex align-items-center">
|
||||||
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
||||||
<Icon size={32} />
|
<_Icon size={32} />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-grow-1">
|
<div className="flex-grow-1">
|
||||||
<div className="h3 mb-0 fw-bold">
|
<div className="h3 mb-0 fw-bold">
|
||||||
|
|||||||
@@ -58,13 +58,13 @@ function DomainsNewManager() {
|
|||||||
const [originalItems, setOriginalItems] = useState([]);
|
const [originalItems, setOriginalItems] = useState([]);
|
||||||
const [etag, setEtag] = useState('');
|
const [etag, setEtag] = useState('');
|
||||||
const [lastModified, setLastModified] = useState('');
|
const [lastModified, setLastModified] = useState('');
|
||||||
const [contentLength, setContentLength] = useState(null);
|
const [, setContentLength] = useState(null);
|
||||||
const [newItem, setNewItem] = useState({ domain: '', community: '' });
|
const [newItem, setNewItem] = useState({ domain: '', community: '' });
|
||||||
const [newInvalid, setNewInvalid] = useState({ domain: false, community: false });
|
const [, setNewInvalid] = useState({ domain: false, community: false });
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const searchTimer = useRef(null);
|
const _searchTimer = useRef(null);
|
||||||
const didInit = useRef(false);
|
const didInit = useRef(false);
|
||||||
const [editingValue, setEditingValue] = useState('');
|
const [editingValue, setEditingValue] = useState('');
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
@@ -92,16 +92,16 @@ function DomainsNewManager() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get(`/communities`, { signal });
|
const res = await api.get(`/communities`, { signal });
|
||||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
setCommunities(Array.isArray(res.data) ? res.data : []);
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api.get('/ws/url', { signal });
|
const r = await api.get('/ws/url', { signal });
|
||||||
setWsUrl(String(r.data?.url || ''));
|
setWsUrl(String(r.data?.url || ''));
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
@@ -339,7 +339,7 @@ function DomainsNewManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Drag&Drop импорт
|
// Drag&Drop импорт
|
||||||
const onDropImport = async (e) => {
|
const _onDropImport = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = e.dataTransfer?.files?.[0];
|
const file = e.dataTransfer?.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function EasySwitchManager() {
|
|||||||
|
|
||||||
// Поиск и фильтры
|
// Поиск и фильтры
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedRule, setSelectedRule] = useState('all'); // all | by-community
|
const [_selectedRule, _setSelectedRule] = useState('all'); // all | by-community
|
||||||
const [activeTab, setActiveTab] = useState('proxies'); // proxies | providers
|
const [activeTab, setActiveTab] = useState('proxies'); // proxies | providers
|
||||||
const [showOnlyConfigured, setShowOnlyConfigured] = useState(true); // Показывать только настроенные
|
const [showOnlyConfigured, setShowOnlyConfigured] = useState(true); // Показывать только настроенные
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ function EasySwitchManager() {
|
|||||||
try {
|
try {
|
||||||
console.log('[EasySwitch] serversWithGateways:', serversWithGateways);
|
console.log('[EasySwitch] serversWithGateways:', serversWithGateways);
|
||||||
console.log('[EasySwitch] communitiesDirectory:', communities);
|
console.log('[EasySwitch] communitiesDirectory:', communities);
|
||||||
} catch (_) {}
|
} catch { /* no-op */ }
|
||||||
|
|
||||||
// Автоматически разворачиваем первый сервер
|
// Автоматически разворачиваем первый сервер
|
||||||
if (serversWithGateways.length > 0 && expandedServers.size === 0) {
|
if (serversWithGateways.length > 0 && expandedServers.size === 0) {
|
||||||
@@ -300,7 +300,7 @@ function EasySwitchManager() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[EasySwitch] initialActiveGateways:', initialActive);
|
console.log('[EasySwitch] initialActiveGateways:', initialActive);
|
||||||
} catch (_) {}
|
} catch { /* no-op */ }
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
@@ -381,7 +381,7 @@ function EasySwitchManager() {
|
|||||||
const currentFilters = Array.isArray(response.data) ? response.data : [];
|
const currentFilters = Array.isArray(response.data) ? response.data : [];
|
||||||
|
|
||||||
// Создаем Set существующих communities в фильтрах
|
// Создаем Set существующих communities в фильтрах
|
||||||
const existingCommunitiesSet = new Set(currentFilters.map(f => f.community));
|
const _existingCommunitiesSet = new Set(currentFilters.map(f => f.community));
|
||||||
|
|
||||||
// Обновляем существующие фильтры и добавляем новые
|
// Обновляем существующие фильтры и добавляем новые
|
||||||
const updatedFilters = [];
|
const updatedFilters = [];
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
|
|||||||
// 3. Если ввели "65001:120", ищем "120" в справочнике
|
// 3. Если ввели "65001:120", ищем "120" в справочнике
|
||||||
if (search.includes(':')) {
|
if (search.includes(':')) {
|
||||||
const searchParts = search.split(':');
|
const searchParts = search.split(':');
|
||||||
const searchAS = searchParts[0];
|
const _searchAS = searchParts[0];
|
||||||
const searchNum = searchParts[1];
|
const searchNum = searchParts[1];
|
||||||
|
|
||||||
// Точное совпадение числовой части
|
// Точное совпадение числовой части
|
||||||
@@ -246,7 +246,7 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
|
|||||||
{Object.entries(groupedCommunities).map(([category, items]) => (
|
{Object.entries(groupedCommunities).map(([category, items]) => (
|
||||||
<div key={category}>
|
<div key={category}>
|
||||||
<div className="dropdown-header small text-muted bg-light">{category}</div>
|
<div className="dropdown-header small text-muted bg-light">{category}</div>
|
||||||
{items.map((c, idx) => {
|
{items.map((c, _idx) => {
|
||||||
const globalIdx = filteredCommunities.indexOf(c);
|
const globalIdx = filteredCommunities.indexOf(c);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -990,7 +990,7 @@ function FilterManager() {
|
|||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (legacyError) {
|
} catch (_legacyError) {
|
||||||
// Фильтров по legacyId тоже нет - это нормально
|
// Фильтров по legacyId тоже нет - это нормально
|
||||||
console.log('No legacy filters found');
|
console.log('No legacy filters found');
|
||||||
}
|
}
|
||||||
@@ -1165,7 +1165,7 @@ function FilterManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadServerConfig = async (serverId, legacyId = null) => {
|
const _handleLoadServerConfig = async (serverId, legacyId = null) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/server-configs/${serverId}`);
|
const response = await api.get(`/server-configs/${serverId}`);
|
||||||
const config = response.data?.config;
|
const config = response.data?.config;
|
||||||
@@ -1192,7 +1192,7 @@ function FilterManager() {
|
|||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (legacyError) {
|
} catch (_legacyError) {
|
||||||
console.log('No legacy config found');
|
console.log('No legacy config found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1299,7 +1299,7 @@ function FilterManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeployToAllServers = async () => {
|
const _handleDeployToAllServers = async () => {
|
||||||
if (!selectedServer) {
|
if (!selectedServer) {
|
||||||
setError('Сначала выберите сервер с фильтрами для развертывания.');
|
setError('Сначала выберите сервер с фильтрами для развертывания.');
|
||||||
return;
|
return;
|
||||||
@@ -1521,7 +1521,7 @@ function FilterManager() {
|
|||||||
setEditServerModalOpen(false);
|
setEditServerModalOpen(false);
|
||||||
setSuccess('Сервер успешно обновлён!');
|
setSuccess('Сервер успешно обновлён!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
setEditServerError('Не удалось сохранить изменения.');
|
setEditServerError('Не удалось сохранить изменения.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1576,7 +1576,7 @@ function FilterManager() {
|
|||||||
await navigator.clipboard.writeText(config);
|
await navigator.clipboard.writeText(config);
|
||||||
setSuccess('Конфигурация скопирована в буфер обмена!');
|
setSuccess('Конфигурация скопирована в буфер обмена!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
setError('Не удалось скопировать конфигурацию');
|
setError('Не удалось скопировать конфигурацию');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1590,7 +1590,7 @@ function FilterManager() {
|
|||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
setSuccess('Конфигурация скопирована в буфер обмена!');
|
setSuccess('Конфигурация скопирована в буфер обмена!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
setError('Не удалось скопировать конфигурацию');
|
setError('Не удалось скопировать конфигурацию');
|
||||||
}
|
}
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
@@ -1598,7 +1598,7 @@ function FilterManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Функция для экспорта конфигурации в S3
|
// Функция для экспорта конфигурации в S3
|
||||||
const exportConfigToS3 = async () => {
|
const _exportConfigToS3 = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.post(`/filters/export-config`);
|
const response = await api.post(`/filters/export-config`);
|
||||||
@@ -2561,7 +2561,7 @@ function FilterManager() {
|
|||||||
try {
|
try {
|
||||||
await api.post(`/server-configs`, { servers: updatedServers });
|
await api.post(`/server-configs`, { servers: updatedServers });
|
||||||
setServers(updatedServers);
|
setServers(updatedServers);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
setError('Не удалось обновить статус сервера');
|
setError('Не удалось обновить статус сервера');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export default function FirewallPage() {
|
|||||||
const res = await api.get('/servers');
|
const res = await api.get('/servers');
|
||||||
const list = Array.isArray(res.data) ? res.data : [];
|
const list = Array.isArray(res.data) ? res.data : [];
|
||||||
if (!cancelled) setServers(list);
|
if (!cancelled) setServers(list);
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
if (!cancelled) setError('Не удалось загрузить список серверов.');
|
if (!cancelled) setError('Не удалось загрузить список серверов.');
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoadingServers(false);
|
if (!cancelled) setLoadingServers(false);
|
||||||
@@ -319,7 +319,7 @@ export default function FirewallPage() {
|
|||||||
[s.subnet]: { org: info.org, country: info.country, city: info.city },
|
[s.subnet]: { org: info.org, country: info.country, city: info.city },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch {
|
||||||
if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null }));
|
if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null }));
|
||||||
}
|
}
|
||||||
if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180));
|
if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180));
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
MiniMap,
|
MiniMap,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
useNodesState,
|
useNodesState,
|
||||||
addEdge,
|
|
||||||
MarkerType,
|
MarkerType,
|
||||||
Handle,
|
Handle,
|
||||||
Position,
|
Position,
|
||||||
@@ -224,7 +223,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
|||||||
});
|
});
|
||||||
setSelectedNodeId(nodeId);
|
setSelectedNodeId(nodeId);
|
||||||
setTimeout(() => setSelectedNodeId(null), 2000);
|
setTimeout(() => setSelectedNodeId(null), 2000);
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Сброс раскладки: очищаем localStorage и пересоздаём ноды
|
// Сброс раскладки: очищаем localStorage и пересоздаём ноды
|
||||||
@@ -232,7 +231,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
window.localStorage.removeItem(STORAGE_KEY);
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
setNodes((current) => {
|
setNodes((current) => {
|
||||||
return current.map((n, i) => ({
|
return current.map((n, i) => ({
|
||||||
@@ -244,7 +243,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
|||||||
if (instanceRef.current) {
|
if (instanceRef.current) {
|
||||||
try {
|
try {
|
||||||
instanceRef.current.fitView({ padding: 0.2 });
|
instanceRef.current.fitView({ padding: 0.2 });
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [setNodes, computeGridPosition]);
|
}, [setNodes, computeGridPosition]);
|
||||||
@@ -378,7 +377,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try {
|
try {
|
||||||
inst.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
inst.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -389,7 +388,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
|||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
i.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
i.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}, 50);
|
}, 50);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [servers, connections]);
|
}, [servers, connections]);
|
||||||
@@ -403,7 +402,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
instanceRef.current.fitView({ padding: 0.2 });
|
instanceRef.current.fitView({ padding: 0.2 });
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}, 60);
|
}, 60);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,13 +52,13 @@ function IPRangesManager() {
|
|||||||
const [originalItems, setOriginalItems] = useState([]);
|
const [originalItems, setOriginalItems] = useState([]);
|
||||||
const [etag, setEtag] = useState('');
|
const [etag, setEtag] = useState('');
|
||||||
const [lastModified, setLastModified] = useState('');
|
const [lastModified, setLastModified] = useState('');
|
||||||
const [contentLength, setContentLength] = useState(null);
|
const [, setContentLength] = useState(null);
|
||||||
const [newItem, setNewItem] = useState({ ipRange: '', community: '' });
|
const [newItem, setNewItem] = useState({ ipRange: '', community: '' });
|
||||||
const [newInvalid, setNewInvalid] = useState({ ipRange: false, community: false });
|
const [, setNewInvalid] = useState({ ipRange: false, community: false });
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const searchTimer = useRef(null);
|
const _searchTimer = useRef(null);
|
||||||
const didInit = useRef(false);
|
const didInit = useRef(false);
|
||||||
const [editingValue, setEditingValue] = useState('');
|
const [editingValue, setEditingValue] = useState('');
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
@@ -82,7 +82,7 @@ function IPRangesManager() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get(`/communities`);
|
const res = await api.get(`/communities`);
|
||||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
setCommunities(Array.isArray(res.data) ? res.data : []);
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
// тихо игнорируем
|
// тихо игнорируем
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -268,7 +268,7 @@ function IPRangesManager() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const duplicateItem = (item) => {
|
const _duplicateItem = (item) => {
|
||||||
setItems([...items, { ...item, ipRange: `${item.ipRange.split('/')[0]}/24` }]);
|
setItems([...items, { ...item, ipRange: `${item.ipRange.split('/')[0]}/24` }]);
|
||||||
window.notify?.success?.('Запись продублирована');
|
window.notify?.success?.('Запись продублирована');
|
||||||
};
|
};
|
||||||
@@ -316,19 +316,19 @@ function IPRangesManager() {
|
|||||||
return { added, removed, changed };
|
return { added, removed, changed };
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [_showDiff, setShowDiff] = useState(false);
|
||||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
|
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
|
||||||
|
|
||||||
// Аналитика
|
// Аналитика
|
||||||
const [analyzeOpen, setAnalyzeOpen] = useState(false);
|
const [_analyzeOpen, _setAnalyzeOpen] = useState(false);
|
||||||
const [analyzeConfirmOpen, setAnalyzeConfirmOpen] = useState(false);
|
const [_analyzeConfirmOpen, _setAnalyzeConfirmOpen] = useState(false);
|
||||||
const [analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
|
const [_analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
|
||||||
const [analysisPreview, setAnalysisPreview] = useState(null);
|
const [analysisPreview, setAnalysisPreview] = useState(null);
|
||||||
const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
|
const [_overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
|
||||||
const [analyzeFilters, setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false });
|
const [analyzeFilters, _setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false });
|
||||||
|
|
||||||
const handlePreviewDiff = () => {
|
const handlePreviewDiff = () => {
|
||||||
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
||||||
@@ -426,7 +426,7 @@ function IPRangesManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Drag&Drop импорт (оставим как быстрый путь)
|
// Drag&Drop импорт (оставим как быстрый путь)
|
||||||
const onDropImport = async (e) => {
|
const _onDropImport = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = e.dataTransfer?.files?.[0];
|
const file = e.dataTransfer?.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -582,7 +582,7 @@ function IPRangesManager() {
|
|||||||
return 'public';
|
return 'public';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatBigInt = (n) => {
|
const _formatBigInt = (n) => {
|
||||||
try {
|
try {
|
||||||
const s = (n ?? 0n).toString();
|
const s = (n ?? 0n).toString();
|
||||||
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||||
@@ -591,7 +591,7 @@ function IPRangesManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildAnalysisConfirmText = () => {
|
const _buildAnalysisConfirmText = () => {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
parts.push(`Community: ${analyzeFilters.community || 'не выбрано'}`);
|
parts.push(`Community: ${analyzeFilters.community || 'не выбрано'}`);
|
||||||
parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`);
|
parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`);
|
||||||
@@ -705,7 +705,7 @@ function IPRangesManager() {
|
|||||||
return Array.from(new Set(out));
|
return Array.from(new Set(out));
|
||||||
};
|
};
|
||||||
|
|
||||||
const runAnalysis = () => {
|
const _runAnalysis = () => {
|
||||||
const community = String(analyzeFilters.community || '').trim();
|
const community = String(analyzeFilters.community || '').trim();
|
||||||
if (!community) { window.notify?.error?.('Выберите community'); return; }
|
if (!community) { window.notify?.error?.('Выберите community'); return; }
|
||||||
// исходные для community
|
// исходные для community
|
||||||
@@ -744,7 +744,7 @@ function IPRangesManager() {
|
|||||||
setAnalysisResultsOpen(true);
|
setAnalysisResultsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyAnalysisToItems = () => {
|
const _applyAnalysisToItems = () => {
|
||||||
if (!analysisPreview) return;
|
if (!analysisPreview) return;
|
||||||
const { community, afterCidrs, unchangedCidrs } = analysisPreview;
|
const { community, afterCidrs, unchangedCidrs } = analysisPreview;
|
||||||
const keptOthers = items.filter(i => i != null && i.community !== community);
|
const keptOthers = items.filter(i => i != null && i.community !== community);
|
||||||
@@ -770,10 +770,14 @@ function IPRangesManager() {
|
|||||||
window.notify?.success?.('Диапазоны перезаписаны по результатам анализа. Не забудьте сохранить изменения.');
|
window.notify?.success?.('Диапазоны перезаписаны по результатам анализа. Не забудьте сохранить изменения.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportAnalysisCsv = () => {
|
const _exportAnalysisCsv = () => {
|
||||||
|
if (!analysisPreview) return;
|
||||||
|
const { community, afterCidrs } = analysisPreview;
|
||||||
|
const totalAddr = afterCidrs?.reduce((s, c) => s + countAddresses(parseMask(c)), 0n) ?? 0n;
|
||||||
const header = ['community', 'ranges', 'totalAddresses'];
|
const header = ['community', 'ranges', 'totalAddresses'];
|
||||||
const lines = [header, ...analysisResult.map(r => [r.community, r.ranges, r.totalAddresses.toString()])]
|
const rows = [[community, String(afterCidrs?.length ?? 0), totalAddr.toString()]];
|
||||||
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
const lines = [header, ...rows]
|
||||||
|
.map(r => (Array.isArray(r) ? r : []).map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||||
.join('\n');
|
.join('\n');
|
||||||
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function InterfaceSpeedTest() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [notify]);
|
}, [notify]);
|
||||||
|
|
||||||
const gateways = useMemo(
|
const _gateways = useMemo(
|
||||||
() =>
|
() =>
|
||||||
networkConfig?.gateways && Array.isArray(networkConfig.gateways)
|
networkConfig?.gateways && Array.isArray(networkConfig.gateways)
|
||||||
? networkConfig.gateways
|
? networkConfig.gateways
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function MikrotikTools() {
|
|||||||
[networkConfig]
|
[networkConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectGatewayMeta = (meta) => {
|
const _handleSelectGatewayMeta = (meta) => {
|
||||||
setGatewayMeta(meta);
|
setGatewayMeta(meta);
|
||||||
// Если цель не задана — подставляем IP gateway как target
|
// Если цель не задана — подставляем IP gateway как target
|
||||||
if (!target && meta && meta.ip) {
|
if (!target && meta && meta.ip) {
|
||||||
@@ -214,7 +214,7 @@ function MikrotikTools() {
|
|||||||
[jumphostServers, serverId]
|
[jumphostServers, serverId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const interfacesForServer = useMemo(() => {
|
const _interfacesForServer = useMemo(() => {
|
||||||
if (!serverId || !interfaces.length) return [];
|
if (!serverId || !interfaces.length) return [];
|
||||||
const ids = new Set(
|
const ids = new Set(
|
||||||
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
|
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
|
||||||
@@ -391,7 +391,7 @@ function MikrotikTools() {
|
|||||||
|
|
||||||
{/* Цели — карточки в стиле сервера/gateway */}
|
{/* Цели — карточки в стиле сервера/gateway */}
|
||||||
<div className="row row-cards g-1 mt-2">
|
<div className="row row-cards g-1 mt-2">
|
||||||
{POPULAR_TARGETS.map(({ id: tid, name, target: tgt, Icon, color }) => {
|
{POPULAR_TARGETS.map(({ id: tid, name, target: tgt, Icon: _Icon, color }) => {
|
||||||
const isSelected = target === tgt;
|
const isSelected = target === tgt;
|
||||||
const bgClass = color === 'red' ? 'bg-red-lt' : color === 'blue' ? 'bg-blue-lt' : 'bg-orange-lt';
|
const bgClass = color === 'red' ? 'bg-red-lt' : color === 'blue' ? 'bg-blue-lt' : 'bg-orange-lt';
|
||||||
const textClass = color === 'red' ? 'text-red' : color === 'blue' ? 'text-blue' : 'text-orange';
|
const textClass = color === 'red' ? 'text-red' : color === 'blue' ? 'text-blue' : 'text-orange';
|
||||||
@@ -404,7 +404,7 @@ function MikrotikTools() {
|
|||||||
>
|
>
|
||||||
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
|
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
|
||||||
<span className={`avatar avatar-sm ${bgClass} ${textClass}`}>
|
<span className={`avatar avatar-sm ${bgClass} ${textClass}`}>
|
||||||
<Icon size={20} stroke={1.5} />
|
<_Icon size={20} stroke={1.5} />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-grow-1 min-w-0">
|
<div className="flex-grow-1 min-w-0">
|
||||||
<div className="fw-semibold text-truncate small">{name}</div>
|
<div className="fw-semibold text-truncate small">{name}</div>
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ function NetworkConfigManager() {
|
|||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
const term = searchTerm.toLowerCase();
|
const term = searchTerm.toLowerCase();
|
||||||
result = result.filter(p => {
|
result = result.filter(p => {
|
||||||
const server = getServerInfo(p.serverId);
|
const _server = getServerInfo(p.serverId);
|
||||||
return (
|
return (
|
||||||
p.name?.toLowerCase().includes(term) ||
|
p.name?.toLowerCase().includes(term) ||
|
||||||
p.cidr?.toLowerCase().includes(term) ||
|
p.cidr?.toLowerCase().includes(term) ||
|
||||||
@@ -913,7 +913,7 @@ function NetworkConfigManager() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const invalidPairs = templateServerPairs.filter((pair, index) => {
|
const invalidPairs = templateServerPairs.filter((pair, _index) => {
|
||||||
if (!pair.server1 || !pair.server2) {
|
if (!pair.server1 || !pair.server2) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1274,7 +1274,7 @@ function NetworkConfigManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// === Генерация IP из пула ===
|
// === Генерация IP из пула ===
|
||||||
const generateIpFromPool = (cidr, usedIps, excludeIp = null, forRemote = false, pairedLocalIp = null) => {
|
const generateIpFromPool = (cidr, usedIps, _excludeIp = null, forRemote = false, pairedLocalIp = null) => {
|
||||||
// Парсим CIDR (например, "10.10.0.0/24")
|
// Парсим CIDR (например, "10.10.0.0/24")
|
||||||
const [network, prefixLength] = cidr.split('/');
|
const [network, prefixLength] = cidr.split('/');
|
||||||
if (!network || !prefixLength) {
|
if (!network || !prefixLength) {
|
||||||
@@ -1650,7 +1650,7 @@ function NetworkConfigManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// === CRUD для IPSec Passwords ===
|
// === CRUD для IPSec Passwords ===
|
||||||
const handleAddIpsecPassword = () => {
|
const _handleAddIpsecPassword = () => {
|
||||||
setEditingIpsecPassword({ name: '', password: '', description: '' });
|
setEditingIpsecPassword({ name: '', password: '', description: '' });
|
||||||
setIpsecPasswordModalMode('add');
|
setIpsecPasswordModalMode('add');
|
||||||
setIpsecPasswordModalOpen(true);
|
setIpsecPasswordModalOpen(true);
|
||||||
@@ -2021,7 +2021,7 @@ function NetworkConfigManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// === Генерация кода MikroTik для рекурсивных маршрутов ===
|
// === Генерация кода MikroTik для рекурсивных маршрутов ===
|
||||||
const generateMikrotikRecursiveRoutes = async () => {
|
const _generateMikrotikRecursiveRoutes = async () => {
|
||||||
const recursiveGateways = (config.gateways || []).filter(gw => gw.type === 'recursive');
|
const recursiveGateways = (config.gateways || []).filter(gw => gw.type === 'recursive');
|
||||||
|
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
@@ -2073,7 +2073,7 @@ function NetworkConfigManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Обрабатываем каждый родительский gateway
|
// Обрабатываем каждый родительский gateway
|
||||||
parentGatewaysList.forEach((parentRef, parentIndex) => {
|
parentGatewaysList.forEach((parentRef, _parentIndex) => {
|
||||||
const parent = getParentGateway(parentRef.id);
|
const parent = getParentGateway(parentRef.id);
|
||||||
|
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
@@ -2264,7 +2264,7 @@ function NetworkConfigManager() {
|
|||||||
if (obj.t === 'log') console.log('[MikroTik apply]', obj.msg, obj);
|
if (obj.t === 'log') console.log('[MikroTik apply]', obj.msg, obj);
|
||||||
else if (obj.t === 'result') finalData = obj;
|
else if (obj.t === 'result') finalData = obj;
|
||||||
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
|
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
|
||||||
} catch (_) {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
@@ -2272,7 +2272,7 @@ function NetworkConfigManager() {
|
|||||||
const obj = JSON.parse(buffer);
|
const obj = JSON.parse(buffer);
|
||||||
if (obj.t === 'result') finalData = obj;
|
if (obj.t === 'result') finalData = obj;
|
||||||
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
|
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
|
||||||
} catch (_) {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
data = finalData || {};
|
data = finalData || {};
|
||||||
} else {
|
} else {
|
||||||
@@ -2403,7 +2403,7 @@ function NetworkConfigManager() {
|
|||||||
|
|
||||||
let validRoutesCount = 0;
|
let validRoutesCount = 0;
|
||||||
|
|
||||||
parentGatewaysList.forEach((parentRef, parentIndex) => {
|
parentGatewaysList.forEach((parentRef, _parentIndex) => {
|
||||||
const parent = getParentGateway(parentRef.id);
|
const parent = getParentGateway(parentRef.id);
|
||||||
|
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
@@ -2542,7 +2542,7 @@ function NetworkConfigManager() {
|
|||||||
try {
|
try {
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
notify.success('Код скопирован в буфер обмена!');
|
notify.success('Код скопирован в буфер обмена!');
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
notify.error('Не удалось скопировать код');
|
notify.error('Не удалось скопировать код');
|
||||||
}
|
}
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
@@ -2589,7 +2589,7 @@ function NetworkConfigManager() {
|
|||||||
}, [generatedMikrotikCode]);
|
}, [generatedMikrotikCode]);
|
||||||
|
|
||||||
// === Получение названия типа блока ===
|
// === Получение названия типа блока ===
|
||||||
const getBlockTypeLabel = (type) => {
|
const _getBlockTypeLabel = (type) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'recursive-routes':
|
case 'recursive-routes':
|
||||||
return 'Рекурсивные маршруты';
|
return 'Рекурсивные маршруты';
|
||||||
@@ -2967,11 +2967,11 @@ function NetworkConfigManager() {
|
|||||||
// === Рендер карточки Gateway ===
|
// === Рендер карточки Gateway ===
|
||||||
const renderGatewayCard = (gateway) => {
|
const renderGatewayCard = (gateway) => {
|
||||||
const server = getServerInfo(gateway.serverId);
|
const server = getServerInfo(gateway.serverId);
|
||||||
const provider = server?.provider || '';
|
const _provider = server?.provider || '';
|
||||||
const serverCountry = server?.country || '';
|
const serverCountry = server?.country || '';
|
||||||
const gatewayCountry = gateway.country || '';
|
const gatewayCountry = gateway.country || '';
|
||||||
const displayCountry = gatewayCountry || serverCountry;
|
const displayCountry = gatewayCountry || serverCountry;
|
||||||
const gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
|
const _gatewayType = GATEWAY_TYPES.find(t => t.value === gateway.type) || GATEWAY_TYPES[0];
|
||||||
|
|
||||||
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
|
// Поддержка старого формата (parentGatewayId) и нового (parentGateways)
|
||||||
const parentGatewaysList = gateway.type === 'recursive'
|
const parentGatewaysList = gateway.type === 'recursive'
|
||||||
@@ -3135,7 +3135,7 @@ function NetworkConfigManager() {
|
|||||||
const interfaceTypeColor = getInterfaceTypeColor(iface.type);
|
const interfaceTypeColor = getInterfaceTypeColor(iface.type);
|
||||||
|
|
||||||
// Определяем отображаемый IP для зеленого блока (remote IP другого сервера)
|
// Определяем отображаемый IP для зеленого блока (remote IP другого сервера)
|
||||||
const displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
|
const _displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ borderRadius: '12px', maxWidth: '100%' }}>
|
<div className="card" style={{ borderRadius: '12px', maxWidth: '100%' }}>
|
||||||
@@ -5531,7 +5531,7 @@ function NetworkConfigManager() {
|
|||||||
<div className="row g-2">
|
<div className="row g-2">
|
||||||
{GATEWAY_TEMPLATES.map(template => {
|
{GATEWAY_TEMPLATES.map(template => {
|
||||||
// Генерируем предпросмотр IP адресов если есть базовый IP и сервер
|
// Генерируем предпросмотр IP адресов если есть базовый IP и сервер
|
||||||
const previewIps = template.gateways.map((gw, idx) => {
|
const previewIps = template.gateways.map((gw, _idx) => {
|
||||||
// Для прямых gateways показываем IP сервера, если выбран
|
// Для прямых gateways показываем IP сервера, если выбран
|
||||||
if (gw.type === 'direct' && templateServerId) {
|
if (gw.type === 'direct' && templateServerId) {
|
||||||
const server = getServerInfo(templateServerId);
|
const server = getServerInfo(templateServerId);
|
||||||
@@ -5709,7 +5709,7 @@ function NetworkConfigManager() {
|
|||||||
<label className="form-label small">Родительские gateway/интерфейсы</label>
|
<label className="form-label small">Родительские gateway/интерфейсы</label>
|
||||||
<div className="d-flex flex-column gap-2">
|
<div className="d-flex flex-column gap-2">
|
||||||
{(gw.parentGateways || []).map((parent, parentIndex) => {
|
{(gw.parentGateways || []).map((parent, parentIndex) => {
|
||||||
const parentInfo = getParentGateway(parent.id, templateGateways);
|
const _parentInfo = getParentGateway(parent.id, templateGateways);
|
||||||
return (
|
return (
|
||||||
<div key={parentIndex} className="d-flex align-items-center gap-2">
|
<div key={parentIndex} className="d-flex align-items-center gap-2">
|
||||||
<div className="flex-grow-1">
|
<div className="flex-grow-1">
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ export default function NetworkMapDashboard() {
|
|||||||
* Если передать force=true, принудительно выполняет новый speed-test (игнорируя кеш на бэкенде).
|
* Если передать force=true, принудительно выполняет новый speed-test (игнорируя кеш на бэкенде).
|
||||||
*/
|
*/
|
||||||
const requestSpeeds = useCallback(async (options = {}) => {
|
const requestSpeeds = useCallback(async (options = {}) => {
|
||||||
const { connections: targetConnections, force = false } = options;
|
const { connections: targetConnections, force: _force = false } = options;
|
||||||
const sourceConnections =
|
const sourceConnections =
|
||||||
Array.isArray(targetConnections) && targetConnections.length > 0 ? targetConnections : connections;
|
Array.isArray(targetConnections) && targetConnections.length > 0 ? targetConnections : connections;
|
||||||
const withSpeed = sourceConnections.filter(
|
const withSpeed = sourceConnections.filter(
|
||||||
|
|||||||
@@ -213,10 +213,10 @@ export default function NetworkMapUnifi({
|
|||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
if (raw) saved = JSON.parse(raw) || {};
|
if (raw) saved = JSON.parse(raw) || {};
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
const initial = computeInitialLayout(servers, size);
|
const initial = computeInitialLayout(servers, size);
|
||||||
const merged = {};
|
const merged = {};
|
||||||
servers.forEach((s, i) => {
|
servers.forEach((s, _i) => {
|
||||||
const ip = String(s.ip);
|
const ip = String(s.ip);
|
||||||
if (saved[ip] && typeof saved[ip].x === 'number' && typeof saved[ip].y === 'number') {
|
if (saved[ip] && typeof saved[ip].x === 'number' && typeof saved[ip].y === 'number') {
|
||||||
merged[ip] = { x: saved[ip].x, y: saved[ip].y };
|
merged[ip] = { x: saved[ip].x, y: saved[ip].y };
|
||||||
@@ -227,11 +227,11 @@ export default function NetworkMapUnifi({
|
|||||||
setPositions(merged);
|
setPositions(merged);
|
||||||
}, [servers.length, size.w, size.h]);
|
}, [servers.length, size.w, size.h]);
|
||||||
|
|
||||||
const savePositions = useCallback((next) => {
|
const _savePositions = useCallback((next) => {
|
||||||
setPositions(next);
|
setPositions(next);
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getSvgCoords = useCallback((clientX, clientY) => {
|
const getSvgCoords = useCallback((clientX, clientY) => {
|
||||||
@@ -255,7 +255,7 @@ export default function NetworkMapUnifi({
|
|||||||
const next = { ...prev, [drag.nodeId]: { x: drag.startX + (p.x - drag.mouseX), y: drag.startY + (p.y - drag.mouseY) } };
|
const next = { ...prev, [drag.nodeId]: { x: drag.startX + (p.x - drag.mouseX), y: drag.startY + (p.y - drag.mouseY) } };
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -273,7 +273,7 @@ export default function NetworkMapUnifi({
|
|||||||
setPositions(initial);
|
setPositions(initial);
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial));
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial));
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}, [servers, size]);
|
}, [servers, size]);
|
||||||
|
|
||||||
const onNodeMouseDown = useCallback(
|
const onNodeMouseDown = useCallback(
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function buildOspfTemplatesFromRouterResults(routerResults = [], servers = []) {
|
|||||||
interfaceName,
|
interfaceName,
|
||||||
area,
|
area,
|
||||||
cost,
|
cost,
|
||||||
active: !Boolean(tpl?.disabled),
|
active: !tpl?.disabled,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -116,7 +116,7 @@ function buildOptimizerProbabilityMap(routeOptimizerData, servers = []) {
|
|||||||
uniqueIface.push(candidate);
|
uniqueIface.push(candidate);
|
||||||
});
|
});
|
||||||
|
|
||||||
uniqueIface.forEach((candidate, idx) => {
|
uniqueIface.forEach((candidate, _idx) => {
|
||||||
const iface = String(candidate?.interfaceName || '').trim();
|
const iface = String(candidate?.interfaceName || '').trim();
|
||||||
const hintKey = `${identity.key}::${iface.toUpperCase()}`;
|
const hintKey = `${identity.key}::${iface.toUpperCase()}`;
|
||||||
const probabilityOptimal = Number(candidate?.probabilityOptimal || 0);
|
const probabilityOptimal = Number(candidate?.probabilityOptimal || 0);
|
||||||
|
|||||||
@@ -308,12 +308,12 @@ export default function PingServicesManager() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((item) => {
|
filtered.map((item) => {
|
||||||
const iconInfo = getIconById(item.icon);
|
const { Icon } = getIconById(item.icon);
|
||||||
return (
|
return (
|
||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td>
|
<td>
|
||||||
<span className={`avatar avatar-sm bg-${item.color}-lt text-${item.color} rounded d-inline-flex align-items-center justify-content-center`}>
|
<span className={`avatar avatar-sm bg-${item.color}-lt text-${item.color} rounded d-inline-flex align-items-center justify-content-center`}>
|
||||||
<iconInfo.Icon size={18} stroke={1.5} />
|
<Icon size={18} stroke={1.5} />
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td><code className="small">{item.id}</code></td>
|
<td><code className="small">{item.id}</code></td>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||||
import ConfirmModal from './components/ConfirmModal.jsx';
|
import ConfirmModal from './components/ConfirmModal.jsx';
|
||||||
@@ -501,7 +501,7 @@ function ServerManager() {
|
|||||||
return `${urlSettings.baseUrl}?${params.toString()}`;
|
return `${urlSettings.baseUrl}?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateLink = (server) => {
|
const _handleGenerateLink = (server) => {
|
||||||
setLinkGeneratorServer(server);
|
setLinkGeneratorServer(server);
|
||||||
setLinkGeneratorOpen(true);
|
setLinkGeneratorOpen(true);
|
||||||
};
|
};
|
||||||
@@ -511,7 +511,7 @@ function ServerManager() {
|
|||||||
setLinkGeneratorServer(null);
|
setLinkGeneratorServer(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPreset = (name) => {
|
const _applyPreset = (name) => {
|
||||||
const p = presets[name];
|
const p = presets[name];
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
setUrlSettings(prev => ({ ...prev, ...p }));
|
setUrlSettings(prev => ({ ...prev, ...p }));
|
||||||
@@ -529,7 +529,7 @@ function ServerManager() {
|
|||||||
try {
|
try {
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
notify.success('Скопировано в буфер обмена!');
|
notify.success('Скопировано в буфер обмена!');
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
notify.error('Не удалось скопировать');
|
notify.error('Не удалось скопировать');
|
||||||
}
|
}
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
@@ -541,14 +541,14 @@ function ServerManager() {
|
|||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
notify.success('Скопировано в буфер обмена!');
|
notify.success('Скопировано в буфер обмена!');
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
// fallback
|
// fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fallbackCopyToClipboard(text);
|
fallbackCopyToClipboard(text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const quickCopyLink = async (server) => {
|
const _quickCopyLink = async (server) => {
|
||||||
const url = generateServerUrl(server);
|
const url = generateServerUrl(server);
|
||||||
await copyToClipboard(url);
|
await copyToClipboard(url);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ function loadHistoryFromStorage() {
|
|||||||
if (!raw) return {};
|
if (!raw) return {};
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (parsed && typeof parsed === 'object') return parsed;
|
if (parsed && typeof parsed === 'object') return parsed;
|
||||||
} catch (_) {}
|
} catch { /* no-op */ }
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveHistoryToStorage(historyMap) {
|
function saveHistoryToStorage(historyMap) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap));
|
||||||
} catch (_) {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Группировка истории по дням: массив { day, dayLabel, incidents } */
|
/** Группировка истории по дням: массив { day, dayLabel, incidents } */
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ import FloatingBulkActionsBar from './FloatingBulkActionsBar.jsx';
|
|||||||
*/
|
*/
|
||||||
function BulkActionsBar({
|
function BulkActionsBar({
|
||||||
selectedCount = 0,
|
selectedCount = 0,
|
||||||
totalCount = 0,
|
totalCount: _totalCount = 0,
|
||||||
onSelectAll = () => {},
|
onSelectAll: _onSelectAll = () => {},
|
||||||
onDeselectAll = () => {},
|
onDeselectAll = () => {},
|
||||||
onDelete = null,
|
onDelete = null,
|
||||||
onEdit = null,
|
onEdit = null,
|
||||||
onExport = null,
|
onExport = null,
|
||||||
customActions = [],
|
customActions = [],
|
||||||
className = '',
|
className: _className = '',
|
||||||
}) {
|
}) {
|
||||||
const actions = [];
|
const actions = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import React from 'react';
|
||||||
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy, IconCheck, IconX } from '@tabler/icons-react';
|
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy, IconCheck, IconX } from '@tabler/icons-react';
|
||||||
import TableSkeleton, { TableEmpty } from './TableSkeleton.jsx';
|
import TableSkeleton, { TableEmpty } from './TableSkeleton.jsx';
|
||||||
import EmptyState from './EmptyState.jsx';
|
import EmptyState from './EmptyState.jsx';
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class ErrorBoundary extends Component {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromError(error) {
|
static getDerivedStateFromError(_error) {
|
||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ class ErrorBoundary extends Component {
|
|||||||
{this.state.error?.message || 'Что-то пошло не так. Попробуйте обновить страницу.'}
|
{this.state.error?.message || 'Что-то пошло не так. Попробуйте обновить страницу.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
|
{import.meta.env.DEV && this.state.errorInfo && (
|
||||||
<div className="card mt-3">
|
<div className="card mt-3">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h3 className="card-title">Детали ошибки (только в dev режиме)</h3>
|
<h3 className="card-title">Детали ошибки (только в dev режиме)</h3>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack })
|
|||||||
notify.success('Откат выполнен успешно');
|
notify.success('Откат выполнен успешно');
|
||||||
onRolledBack?.(res.data || {});
|
onRolledBack?.(res.data || {});
|
||||||
onClose?.();
|
onClose?.();
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
notify.error('Не удалось выполнить откат');
|
notify.error('Не удалось выполнить откат');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js';
|
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js';
|
||||||
import { IconSearch, IconMinus } from '@tabler/icons-react';
|
import { IconSearch, IconMinus } from '@tabler/icons-react';
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ function MobileCardView({
|
|||||||
// минимальная дистанция свайпа в px
|
// минимальная дистанция свайпа в px
|
||||||
const minSwipeDistance = 50
|
const minSwipeDistance = 50
|
||||||
|
|
||||||
const onTouchStart = (e, item) => {
|
const onTouchStart = (e, _item) => {
|
||||||
setTouchEnd(null)
|
setTouchEnd(null)
|
||||||
setTouchStart(e.targetTouches[0].clientX)
|
setTouchStart(e.targetTouches[0].clientX)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTouchMove = (e, item) => {
|
const onTouchMove = (e, _item) => {
|
||||||
setTouchEnd(e.targetTouches[0].clientX)
|
setTouchEnd(e.targetTouches[0].clientX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { IconCheck, IconAlertTriangle, IconInfoCircle, IconX } from '@tabler/icons-react';
|
import { IconCheck, IconAlertTriangle, IconInfoCircle, IconX } from '@tabler/icons-react';
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ export function notifyMutationSuccess(message, details) {
|
|||||||
if (forceToast && typeof window !== 'undefined' && window.notify?.success) {
|
if (forceToast && typeof window !== 'undefined' && window.notify?.success) {
|
||||||
window.notify.success(text, details);
|
window.notify.success(text, details);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorByType(type) {
|
function colorByType(type) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function SavedFilters({
|
|||||||
const [savedFilters, setSavedFilters] = useState([])
|
const [savedFilters, setSavedFilters] = useState([])
|
||||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||||
const [filterName, setFilterName] = useState('')
|
const [filterName, setFilterName] = useState('')
|
||||||
const [showList, setShowList] = useState(false)
|
const [_showList, setShowList] = useState(false)
|
||||||
|
|
||||||
const storageKey = `savedFilters_${pageKey}`
|
const storageKey = `savedFilters_${pageKey}`
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function SettingsModal({ open, onClose }) {
|
|||||||
const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
|
const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
|
||||||
setEtag(e ? String(e) : '');
|
setEtag(e ? String(e) : '');
|
||||||
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
|
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
setError('Не удалось загрузить настройки');
|
setError('Не удалось загрузить настройки');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { useState, useEffect, createContext, useContext } from 'react'
|
import { useState, useEffect, createContext, useContext } from 'react'
|
||||||
import {
|
import {
|
||||||
IconCheck,
|
IconCheck,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { IconTrophy, IconMapPin, IconCloud, IconHash, IconChartBar } from '@tabl
|
|||||||
function TopNStats({ data, loading }) {
|
function TopNStats({ data, loading }) {
|
||||||
const [topCountries, setTopCountries] = useState([]);
|
const [topCountries, setTopCountries] = useState([]);
|
||||||
const [topProviders, setTopProviders] = useState([]);
|
const [topProviders, setTopProviders] = useState([]);
|
||||||
const [topCommunities, setTopCommunities] = useState([]);
|
const [_topCommunities, _setTopCommunities] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || !Array.isArray(data.servers)) return;
|
if (!data || !Array.isArray(data.servers)) return;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function ValidatedInput({
|
|||||||
message: validationResult.message || '',
|
message: validationResult.message || '',
|
||||||
isValidating: false
|
isValidating: false
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
setValidationState({
|
setValidationState({
|
||||||
valid: false,
|
valid: false,
|
||||||
message: 'Ошибка валидации',
|
message: 'Ошибка валидации',
|
||||||
@@ -84,7 +84,7 @@ function ValidatedInput({
|
|||||||
// Запускаем валидацию сразу при потере фокуса
|
// Запускаем валидацию сразу при потере фокуса
|
||||||
if (validate && !validateOnChange) {
|
if (validate && !validateOnChange) {
|
||||||
const result = validate(value)
|
const result = validate(value)
|
||||||
const validationResult = result instanceof Promise ? result.then(r => {
|
const _validationResult = result instanceof Promise ? result.then(r => {
|
||||||
setValidationState({
|
setValidationState({
|
||||||
valid: r.valid,
|
valid: r.valid,
|
||||||
message: r.message || '',
|
message: r.message || '',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useMemo } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy } from '@tabler/icons-react';
|
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy } from '@tabler/icons-react';
|
||||||
import TableSkeleton from './TableSkeleton.jsx';
|
import TableSkeleton from './TableSkeleton.jsx';
|
||||||
|
|||||||
@@ -64,12 +64,12 @@ function WsUpdateModal({ show, url, onClose }) {
|
|||||||
|
|
||||||
ws.onerror = () => setStatus('error');
|
ws.onerror = () => setStatus('error');
|
||||||
ws.onclose = () => setStatus('closed');
|
ws.onclose = () => setStatus('closed');
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
try { wsRef.current?.close(); } catch {}
|
try { wsRef.current?.close(); } catch { /* no-op */ }
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
};
|
};
|
||||||
}, [show, url]);
|
}, [show, url]);
|
||||||
@@ -96,7 +96,7 @@ function WsUpdateModal({ show, url, onClose }) {
|
|||||||
const copyLog = async () => {
|
const copyLog = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(plainLog);
|
await navigator.clipboard.writeText(plainLog);
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearLog = () => {
|
const clearLog = () => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function countryToFlag(isoCode) {
|
|||||||
return code.replace(/./g, char => String.fromCodePoint(127397 + char.charCodeAt()));
|
return code.replace(/./g, char => String.fromCodePoint(127397 + char.charCodeAt()));
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterSection({ title, icon: Icon, iconColor, expanded, onToggle, children }) {
|
function FilterSection({ title, icon: _Icon, iconColor, expanded, onToggle, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="filter-section border-bottom">
|
<div className="filter-section border-bottom">
|
||||||
<button
|
<button
|
||||||
@@ -37,7 +37,7 @@ function FilterSection({ title, icon: Icon, iconColor, expanded, onToggle, child
|
|||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
>
|
>
|
||||||
<span className="d-flex align-items-center">
|
<span className="d-flex align-items-center">
|
||||||
<Icon size={16} className={`me-2 ${iconColor || 'text-muted'}`} />
|
<_Icon size={16} className={`me-2 ${iconColor || 'text-muted'}`} />
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
</span>
|
</span>
|
||||||
{expanded ? <IconChevronDown size={16} className="text-muted" /> : <IconChevronRight size={16} className="text-muted" />}
|
{expanded ? <IconChevronDown size={16} className="text-muted" /> : <IconChevronRight size={16} className="text-muted" />}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import api from '../lib/api.js';
|
import api from '../lib/api.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { createContext, useContext, useState, useCallback, useRef } from 'react';
|
import { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||||
import api from '../lib/api.js';
|
import api from '../lib/api.js';
|
||||||
|
|
||||||
@@ -97,11 +98,11 @@ export function PingProvider({ children }) {
|
|||||||
const cancelScope = useCallback((scope) => {
|
const cancelScope = useCallback((scope) => {
|
||||||
const scopeName = String(scope || '');
|
const scopeName = String(scope || '');
|
||||||
if (!scopeName) return;
|
if (!scopeName) return;
|
||||||
let canceled = 0;
|
let _canceled = 0;
|
||||||
controllersRef.current.forEach((controller, key) => {
|
controllersRef.current.forEach((controller, key) => {
|
||||||
if (keyScopesRef.current.get(key) === scopeName) {
|
if (keyScopesRef.current.get(key) === scopeName) {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
canceled += 1;
|
_canceled += 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function SimpleErrorHandlingExample() {
|
|||||||
*/
|
*/
|
||||||
function RetryButtonExample() {
|
function RetryButtonExample() {
|
||||||
const [attempts, setAttempts] = useState(0);
|
const [attempts, setAttempts] = useState(0);
|
||||||
const { handleError, handleSuccess } = useErrorHandler();
|
const { handleError: _handleError, handleSuccess } = useErrorHandler();
|
||||||
|
|
||||||
const unreliableOperation = async () => {
|
const unreliableOperation = async () => {
|
||||||
setAttempts(prev => prev + 1);
|
setAttempts(prev => prev + 1);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ api.interceptors.response.use(
|
|||||||
responseCache.set(key, { etag, data: response.data, headers: response.headers });
|
responseCache.set(key, { etag, data: response.data, headers: response.headers });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
@@ -174,7 +174,7 @@ api.interceptors.request.use((config) => {
|
|||||||
config.headers = config.headers || {};
|
config.headers = config.headers || {};
|
||||||
config.headers['If-Match'] = String(config.data.etag);
|
config.headers['If-Match'] = String(config.data.etag);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function readCache() {
|
|||||||
function writeCache(cache) {
|
function writeCache(cache) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||||
} catch {}
|
} catch { /* no-op */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCached(asn) {
|
function getCached(asn) {
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,9 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\"",
|
"dev": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\"",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"lint": "cd frontend && npm run lint",
|
||||||
|
"test": "cd frontend && npm run build",
|
||||||
|
"test:backend": "cd backend && npm run test"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user