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
|
||||
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
|
||||
@@ -6,8 +6,32 @@ on:
|
||||
- main
|
||||
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-test
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -29,7 +53,7 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./Dockerfile.fast
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
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: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
globals: { ...globals.browser, process: 'readonly' },
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
@@ -23,7 +23,8 @@ export default defineConfig([
|
||||
},
|
||||
},
|
||||
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 [etag, setEtag] = useState('');
|
||||
const [lastModified, setLastModified] = useState('');
|
||||
const [contentLength, setContentLength] = useState(null);
|
||||
const [, setContentLength] = useState(null);
|
||||
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 [success, setSuccess] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const searchTimer = useRef(null);
|
||||
const _searchTimer = useRef(null);
|
||||
const didInit = useRef(false);
|
||||
const [editingValue, setEditingValue] = useState('');
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
@@ -82,7 +82,7 @@ function ASNsNewManager() {
|
||||
try {
|
||||
const res = await api.get(`/communities`);
|
||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// тихо игнорируем
|
||||
}
|
||||
})();
|
||||
@@ -298,7 +298,7 @@ function ASNsNewManager() {
|
||||
return { added, removed, changed };
|
||||
};
|
||||
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
const [_showDiff, setShowDiff] = useState(false);
|
||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
@@ -401,7 +401,7 @@ function ASNsNewManager() {
|
||||
};
|
||||
|
||||
// Drag&Drop импорт (быстрый путь)
|
||||
const onDropImport = async (e) => {
|
||||
const _onDropImport = async (e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
@@ -158,7 +158,7 @@ function AutoUrlManager() {
|
||||
}
|
||||
setSuccess('Пример формата скопирован');
|
||||
setTimeout(() => setSuccess(''), 2000);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
setError('Не удалось скопировать пример');
|
||||
setTimeout(() => setError(''), 2000);
|
||||
}
|
||||
@@ -619,7 +619,7 @@ function AutoUrlManager() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAndSortedUrls.map((url, displayIndex) => {
|
||||
{filteredAndSortedUrls.map((url, _displayIndex) => {
|
||||
const originalIndex = urls.findIndex(u => u === url);
|
||||
const v = getRowValidity(url);
|
||||
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 axios from 'axios';
|
||||
import FormModal from './components/FormModal.jsx';
|
||||
@@ -51,7 +51,7 @@ function BillingManager() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortField, setSortField] = useState('nextPaymentDate');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterStatus, _setFilterStatus] = useState('');
|
||||
const [filterUrgency, setFilterUrgency] = useState('');
|
||||
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
|
||||
const [activeTab, setActiveTab] = useState('subscriptions'); // 'subscriptions' | 'payments'
|
||||
@@ -76,14 +76,14 @@ function BillingManager() {
|
||||
const [editingPayment, setEditingPayment] = useState(null);
|
||||
const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false);
|
||||
const [paymentToDelete, setPaymentToDelete] = useState(null);
|
||||
const [futureDateConfirm, setFutureDateConfirm] = useState(false);
|
||||
const [_futureDateConfirm, _setFutureDateConfirm] = useState(false);
|
||||
|
||||
// История платежей
|
||||
const [paymentSearchTerm, setPaymentSearchTerm] = useState('');
|
||||
const [paymentSortField, setPaymentSortField] = useState('date');
|
||||
const [paymentSortOrder, setPaymentSortOrder] = useState('desc');
|
||||
const [selectedPayments, setSelectedPayments] = useState(new Set());
|
||||
const [paymentPage, setPaymentPage] = useState(1);
|
||||
const [paymentSearchTerm, _setPaymentSearchTerm] = useState('');
|
||||
const [_paymentSortField, _setPaymentSortField] = useState('date');
|
||||
const [_paymentSortOrder, _setPaymentSortOrder] = useState('desc');
|
||||
const [_selectedPayments, _setSelectedPayments] = useState(new Set());
|
||||
const [_paymentPage, _setPaymentPage] = useState(1);
|
||||
const paymentPageSize = 10;
|
||||
|
||||
// Курсы валют
|
||||
@@ -92,7 +92,7 @@ function BillingManager() {
|
||||
EUR: 1,
|
||||
RUB: 1
|
||||
});
|
||||
const [ratesLoading, setRatesLoading] = useState(false);
|
||||
const [_ratesLoading, setRatesLoading] = useState(false);
|
||||
|
||||
// Новый элемент
|
||||
const [newItem, setNewItem] = useState({
|
||||
@@ -273,7 +273,7 @@ function BillingManager() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Убираем служебное поле _external перед сохранением
|
||||
const payload = billingData.map(({ _external, ...rest }) => rest);
|
||||
const payload = billingData.map(({ _external: _ext, ...rest }) => rest);
|
||||
await api.post(`/billing`, { domains: payload });
|
||||
// После сохранения снимаем флаг _external со всех записей
|
||||
setBillingData(prev => prev.map(item => ({ ...item, _external: false })));
|
||||
|
||||
@@ -190,7 +190,7 @@ function CommunitiesManager() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// не отправляем служебное поле _external
|
||||
const payload = data.map(({ _external, ...rest }) => rest);
|
||||
const payload = data.map(({ _external: _, ...rest }) => rest);
|
||||
await api.post(`/communities`, { communities: payload });
|
||||
setSuccess('Справочник сохранён!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
@@ -302,7 +302,7 @@ function CommunitiesManager() {
|
||||
});
|
||||
return Array.from(map.values());
|
||||
});
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
setError('Неверный JSON.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,12 +33,12 @@ import Tooltip from './components/Tooltip.jsx';
|
||||
import Sparkline from './components/Sparkline.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 (
|
||||
<div className="card h-100 position-relative card-hover">
|
||||
<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} />
|
||||
<_Icon size={32} />
|
||||
</span>
|
||||
<div className="flex-grow-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 (
|
||||
<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} />
|
||||
<_Icon size={32} />
|
||||
</span>
|
||||
<div className="flex-grow-1">
|
||||
<div className="h3 mb-0 fw-bold">
|
||||
|
||||
@@ -58,13 +58,13 @@ function DomainsNewManager() {
|
||||
const [originalItems, setOriginalItems] = useState([]);
|
||||
const [etag, setEtag] = useState('');
|
||||
const [lastModified, setLastModified] = useState('');
|
||||
const [contentLength, setContentLength] = useState(null);
|
||||
const [, setContentLength] = useState(null);
|
||||
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 [success, setSuccess] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const searchTimer = useRef(null);
|
||||
const _searchTimer = useRef(null);
|
||||
const didInit = useRef(false);
|
||||
const [editingValue, setEditingValue] = useState('');
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
@@ -92,16 +92,16 @@ function DomainsNewManager() {
|
||||
try {
|
||||
const res = await api.get(`/communities`, { signal });
|
||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
} catch (err) {
|
||||
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||
}
|
||||
})();
|
||||
(async () => {
|
||||
try {
|
||||
const r = await api.get('/ws/url', { signal });
|
||||
setWsUrl(String(r.data?.url || ''));
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
} catch (err) {
|
||||
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
@@ -339,7 +339,7 @@ function DomainsNewManager() {
|
||||
};
|
||||
|
||||
// Drag&Drop импорт
|
||||
const onDropImport = async (e) => {
|
||||
const _onDropImport = async (e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
@@ -42,7 +42,7 @@ function EasySwitchManager() {
|
||||
|
||||
// Поиск и фильтры
|
||||
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 [showOnlyConfigured, setShowOnlyConfigured] = useState(true); // Показывать только настроенные
|
||||
|
||||
@@ -275,7 +275,7 @@ function EasySwitchManager() {
|
||||
try {
|
||||
console.log('[EasySwitch] serversWithGateways:', serversWithGateways);
|
||||
console.log('[EasySwitch] communitiesDirectory:', communities);
|
||||
} catch (_) {}
|
||||
} catch { /* no-op */ }
|
||||
|
||||
// Автоматически разворачиваем первый сервер
|
||||
if (serversWithGateways.length > 0 && expandedServers.size === 0) {
|
||||
@@ -300,7 +300,7 @@ function EasySwitchManager() {
|
||||
|
||||
try {
|
||||
console.log('[EasySwitch] initialActiveGateways:', initialActive);
|
||||
} catch (_) {}
|
||||
} catch { /* no-op */ }
|
||||
|
||||
} catch (err) {
|
||||
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 : [];
|
||||
|
||||
// Создаем Set существующих communities в фильтрах
|
||||
const existingCommunitiesSet = new Set(currentFilters.map(f => f.community));
|
||||
const _existingCommunitiesSet = new Set(currentFilters.map(f => f.community));
|
||||
|
||||
// Обновляем существующие фильтры и добавляем новые
|
||||
const updatedFilters = [];
|
||||
|
||||
@@ -79,7 +79,7 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
|
||||
// 3. Если ввели "65001:120", ищем "120" в справочнике
|
||||
if (search.includes(':')) {
|
||||
const searchParts = search.split(':');
|
||||
const searchAS = searchParts[0];
|
||||
const _searchAS = searchParts[0];
|
||||
const searchNum = searchParts[1];
|
||||
|
||||
// Точное совпадение числовой части
|
||||
@@ -246,7 +246,7 @@ function CommunityAutocomplete({ label, value, onChange, communities = [], requi
|
||||
{Object.entries(groupedCommunities).map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<div className="dropdown-header small text-muted bg-light">{category}</div>
|
||||
{items.map((c, idx) => {
|
||||
{items.map((c, _idx) => {
|
||||
const globalIdx = filteredCommunities.indexOf(c);
|
||||
return (
|
||||
<div
|
||||
@@ -990,7 +990,7 @@ function FilterManager() {
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
return;
|
||||
}
|
||||
} catch (legacyError) {
|
||||
} catch (_legacyError) {
|
||||
// Фильтров по legacyId тоже нет - это нормально
|
||||
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 {
|
||||
const response = await api.get(`/server-configs/${serverId}`);
|
||||
const config = response.data?.config;
|
||||
@@ -1192,7 +1192,7 @@ function FilterManager() {
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
return;
|
||||
}
|
||||
} catch (legacyError) {
|
||||
} catch (_legacyError) {
|
||||
console.log('No legacy config found');
|
||||
}
|
||||
}
|
||||
@@ -1299,7 +1299,7 @@ function FilterManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeployToAllServers = async () => {
|
||||
const _handleDeployToAllServers = async () => {
|
||||
if (!selectedServer) {
|
||||
setError('Сначала выберите сервер с фильтрами для развертывания.');
|
||||
return;
|
||||
@@ -1521,7 +1521,7 @@ function FilterManager() {
|
||||
setEditServerModalOpen(false);
|
||||
setSuccess('Сервер успешно обновлён!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
setEditServerError('Не удалось сохранить изменения.');
|
||||
}
|
||||
};
|
||||
@@ -1576,7 +1576,7 @@ function FilterManager() {
|
||||
await navigator.clipboard.writeText(config);
|
||||
setSuccess('Конфигурация скопирована в буфер обмена!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
setError('Не удалось скопировать конфигурацию');
|
||||
}
|
||||
} else {
|
||||
@@ -1590,7 +1590,7 @@ function FilterManager() {
|
||||
document.execCommand('copy');
|
||||
setSuccess('Конфигурация скопирована в буфер обмена!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
setError('Не удалось скопировать конфигурацию');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
@@ -1598,7 +1598,7 @@ function FilterManager() {
|
||||
};
|
||||
|
||||
// Функция для экспорта конфигурации в S3
|
||||
const exportConfigToS3 = async () => {
|
||||
const _exportConfigToS3 = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.post(`/filters/export-config`);
|
||||
@@ -2561,7 +2561,7 @@ function FilterManager() {
|
||||
try {
|
||||
await api.post(`/server-configs`, { servers: updatedServers });
|
||||
setServers(updatedServers);
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
setError('Не удалось обновить статус сервера');
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -250,7 +250,7 @@ export default function FirewallPage() {
|
||||
const res = await api.get('/servers');
|
||||
const list = Array.isArray(res.data) ? res.data : [];
|
||||
if (!cancelled) setServers(list);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
if (!cancelled) setError('Не удалось загрузить список серверов.');
|
||||
} finally {
|
||||
if (!cancelled) setLoadingServers(false);
|
||||
@@ -319,7 +319,7 @@ export default function FirewallPage() {
|
||||
[s.subnet]: { org: info.org, country: info.country, city: info.city },
|
||||
}));
|
||||
}
|
||||
} catch (_) {
|
||||
} catch {
|
||||
if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null }));
|
||||
}
|
||||
if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180));
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
MiniMap,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
addEdge,
|
||||
MarkerType,
|
||||
Handle,
|
||||
Position,
|
||||
@@ -224,7 +223,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
||||
});
|
||||
setSelectedNodeId(nodeId);
|
||||
setTimeout(() => setSelectedNodeId(null), 2000);
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
}, []);
|
||||
|
||||
// Сброс раскладки: очищаем localStorage и пересоздаём ноды
|
||||
@@ -232,7 +231,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
}
|
||||
setNodes((current) => {
|
||||
return current.map((n, i) => ({
|
||||
@@ -244,7 +243,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
||||
if (instanceRef.current) {
|
||||
try {
|
||||
instanceRef.current.fitView({ padding: 0.2 });
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
}
|
||||
}, 100);
|
||||
}, [setNodes, computeGridPosition]);
|
||||
@@ -378,7 +377,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
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(() => {
|
||||
try {
|
||||
i.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
}, 50);
|
||||
return () => clearTimeout(t);
|
||||
}, [servers, connections]);
|
||||
@@ -403,7 +402,7 @@ function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
instanceRef.current.fitView({ padding: 0.2 });
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
}, 60);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,13 +52,13 @@ function IPRangesManager() {
|
||||
const [originalItems, setOriginalItems] = useState([]);
|
||||
const [etag, setEtag] = useState('');
|
||||
const [lastModified, setLastModified] = useState('');
|
||||
const [contentLength, setContentLength] = useState(null);
|
||||
const [, setContentLength] = useState(null);
|
||||
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 [success, setSuccess] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const searchTimer = useRef(null);
|
||||
const _searchTimer = useRef(null);
|
||||
const didInit = useRef(false);
|
||||
const [editingValue, setEditingValue] = useState('');
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
@@ -82,7 +82,7 @@ function IPRangesManager() {
|
||||
try {
|
||||
const res = await api.get(`/communities`);
|
||||
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` }]);
|
||||
window.notify?.success?.('Запись продублирована');
|
||||
};
|
||||
@@ -316,19 +316,19 @@ function IPRangesManager() {
|
||||
return { added, removed, changed };
|
||||
};
|
||||
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
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 [analyzeOpen, setAnalyzeOpen] = useState(false);
|
||||
const [analyzeConfirmOpen, setAnalyzeConfirmOpen] = useState(false);
|
||||
const [analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
|
||||
const [_analyzeOpen, _setAnalyzeOpen] = useState(false);
|
||||
const [_analyzeConfirmOpen, _setAnalyzeConfirmOpen] = useState(false);
|
||||
const [_analysisResultsOpen, setAnalysisResultsOpen] = useState(false);
|
||||
const [analysisPreview, setAnalysisPreview] = useState(null);
|
||||
const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
|
||||
const [analyzeFilters, setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false });
|
||||
const [_overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
|
||||
const [analyzeFilters, _setAnalyzeFilters] = useState({ community: '', minMask: 0, maxMask: 32, type: 'any', supernet16: false });
|
||||
|
||||
const handlePreviewDiff = () => {
|
||||
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
||||
@@ -426,7 +426,7 @@ function IPRangesManager() {
|
||||
};
|
||||
|
||||
// Drag&Drop импорт (оставим как быстрый путь)
|
||||
const onDropImport = async (e) => {
|
||||
const _onDropImport = async (e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -582,7 +582,7 @@ function IPRangesManager() {
|
||||
return 'public';
|
||||
};
|
||||
|
||||
const formatBigInt = (n) => {
|
||||
const _formatBigInt = (n) => {
|
||||
try {
|
||||
const s = (n ?? 0n).toString();
|
||||
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
@@ -591,7 +591,7 @@ function IPRangesManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const buildAnalysisConfirmText = () => {
|
||||
const _buildAnalysisConfirmText = () => {
|
||||
const parts = [];
|
||||
parts.push(`Community: ${analyzeFilters.community || 'не выбрано'}`);
|
||||
parts.push(`Маска: /${analyzeFilters.minMask}..../${analyzeFilters.maxMask}`);
|
||||
@@ -705,7 +705,7 @@ function IPRangesManager() {
|
||||
return Array.from(new Set(out));
|
||||
};
|
||||
|
||||
const runAnalysis = () => {
|
||||
const _runAnalysis = () => {
|
||||
const community = String(analyzeFilters.community || '').trim();
|
||||
if (!community) { window.notify?.error?.('Выберите community'); return; }
|
||||
// исходные для community
|
||||
@@ -744,7 +744,7 @@ function IPRangesManager() {
|
||||
setAnalysisResultsOpen(true);
|
||||
};
|
||||
|
||||
const applyAnalysisToItems = () => {
|
||||
const _applyAnalysisToItems = () => {
|
||||
if (!analysisPreview) return;
|
||||
const { community, afterCidrs, unchangedCidrs } = analysisPreview;
|
||||
const keptOthers = items.filter(i => i != null && i.community !== community);
|
||||
@@ -770,10 +770,14 @@ function IPRangesManager() {
|
||||
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 lines = [header, ...analysisResult.map(r => [r.community, r.ranges, r.totalAddresses.toString()])]
|
||||
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||
const rows = [[community, String(afterCidrs?.length ?? 0), totalAddr.toString()]];
|
||||
const lines = [header, ...rows]
|
||||
.map(r => (Array.isArray(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);
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function InterfaceSpeedTest() {
|
||||
return () => controller.abort();
|
||||
}, [notify]);
|
||||
|
||||
const gateways = useMemo(
|
||||
const _gateways = useMemo(
|
||||
() =>
|
||||
networkConfig?.gateways && Array.isArray(networkConfig.gateways)
|
||||
? networkConfig.gateways
|
||||
|
||||
@@ -82,7 +82,7 @@ function MikrotikTools() {
|
||||
[networkConfig]
|
||||
);
|
||||
|
||||
const handleSelectGatewayMeta = (meta) => {
|
||||
const _handleSelectGatewayMeta = (meta) => {
|
||||
setGatewayMeta(meta);
|
||||
// Если цель не задана — подставляем IP gateway как target
|
||||
if (!target && meta && meta.ip) {
|
||||
@@ -214,7 +214,7 @@ function MikrotikTools() {
|
||||
[jumphostServers, serverId]
|
||||
);
|
||||
|
||||
const interfacesForServer = useMemo(() => {
|
||||
const _interfacesForServer = useMemo(() => {
|
||||
if (!serverId || !interfaces.length) return [];
|
||||
const ids = new Set(
|
||||
[serverId, currentServer?.id, currentServer?.ip, currentServer?.dns]
|
||||
@@ -391,7 +391,7 @@ function MikrotikTools() {
|
||||
|
||||
{/* Цели — карточки в стиле сервера/gateway */}
|
||||
<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 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';
|
||||
@@ -404,7 +404,7 @@ function MikrotikTools() {
|
||||
>
|
||||
<div className="card-body py-2 px-2 d-flex align-items-center gap-2">
|
||||
<span className={`avatar avatar-sm ${bgClass} ${textClass}`}>
|
||||
<Icon size={20} stroke={1.5} />
|
||||
<_Icon size={20} stroke={1.5} />
|
||||
</span>
|
||||
<div className="flex-grow-1 min-w-0">
|
||||
<div className="fw-semibold text-truncate small">{name}</div>
|
||||
|
||||
@@ -468,7 +468,7 @@ function NetworkConfigManager() {
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(p => {
|
||||
const server = getServerInfo(p.serverId);
|
||||
const _server = getServerInfo(p.serverId);
|
||||
return (
|
||||
p.name?.toLowerCase().includes(term) ||
|
||||
p.cidr?.toLowerCase().includes(term) ||
|
||||
@@ -913,7 +913,7 @@ function NetworkConfigManager() {
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidPairs = templateServerPairs.filter((pair, index) => {
|
||||
const invalidPairs = templateServerPairs.filter((pair, _index) => {
|
||||
if (!pair.server1 || !pair.server2) {
|
||||
return true;
|
||||
}
|
||||
@@ -1274,7 +1274,7 @@ function NetworkConfigManager() {
|
||||
};
|
||||
|
||||
// === Генерация 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")
|
||||
const [network, prefixLength] = cidr.split('/');
|
||||
if (!network || !prefixLength) {
|
||||
@@ -1650,7 +1650,7 @@ function NetworkConfigManager() {
|
||||
};
|
||||
|
||||
// === CRUD для IPSec Passwords ===
|
||||
const handleAddIpsecPassword = () => {
|
||||
const _handleAddIpsecPassword = () => {
|
||||
setEditingIpsecPassword({ name: '', password: '', description: '' });
|
||||
setIpsecPasswordModalMode('add');
|
||||
setIpsecPasswordModalOpen(true);
|
||||
@@ -2021,7 +2021,7 @@ function NetworkConfigManager() {
|
||||
};
|
||||
|
||||
// === Генерация кода MikroTik для рекурсивных маршрутов ===
|
||||
const generateMikrotikRecursiveRoutes = async () => {
|
||||
const _generateMikrotikRecursiveRoutes = async () => {
|
||||
const recursiveGateways = (config.gateways || []).filter(gw => gw.type === 'recursive');
|
||||
|
||||
const blocks = [];
|
||||
@@ -2073,7 +2073,7 @@ function NetworkConfigManager() {
|
||||
}
|
||||
|
||||
// Обрабатываем каждый родительский gateway
|
||||
parentGatewaysList.forEach((parentRef, parentIndex) => {
|
||||
parentGatewaysList.forEach((parentRef, _parentIndex) => {
|
||||
const parent = getParentGateway(parentRef.id);
|
||||
|
||||
if (!parent) {
|
||||
@@ -2264,7 +2264,7 @@ function NetworkConfigManager() {
|
||||
if (obj.t === 'log') console.log('[MikroTik apply]', obj.msg, obj);
|
||||
else if (obj.t === 'result') finalData = obj;
|
||||
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
|
||||
} catch (_) {}
|
||||
} catch { /* no-op */ }
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
@@ -2272,7 +2272,7 @@ function NetworkConfigManager() {
|
||||
const obj = JSON.parse(buffer);
|
||||
if (obj.t === 'result') finalData = obj;
|
||||
else if (obj.t === 'error') finalData = { ok: false, error: obj.error, mikrotikRequests: obj.mikrotikRequests };
|
||||
} catch (_) {}
|
||||
} catch { /* no-op */ }
|
||||
}
|
||||
data = finalData || {};
|
||||
} else {
|
||||
@@ -2403,7 +2403,7 @@ function NetworkConfigManager() {
|
||||
|
||||
let validRoutesCount = 0;
|
||||
|
||||
parentGatewaysList.forEach((parentRef, parentIndex) => {
|
||||
parentGatewaysList.forEach((parentRef, _parentIndex) => {
|
||||
const parent = getParentGateway(parentRef.id);
|
||||
|
||||
if (!parent) {
|
||||
@@ -2542,7 +2542,7 @@ function NetworkConfigManager() {
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
notify.success('Код скопирован в буфер обмена!');
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
notify.error('Не удалось скопировать код');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
@@ -2589,7 +2589,7 @@ function NetworkConfigManager() {
|
||||
}, [generatedMikrotikCode]);
|
||||
|
||||
// === Получение названия типа блока ===
|
||||
const getBlockTypeLabel = (type) => {
|
||||
const _getBlockTypeLabel = (type) => {
|
||||
switch (type) {
|
||||
case 'recursive-routes':
|
||||
return 'Рекурсивные маршруты';
|
||||
@@ -2967,11 +2967,11 @@ function NetworkConfigManager() {
|
||||
// === Рендер карточки Gateway ===
|
||||
const renderGatewayCard = (gateway) => {
|
||||
const server = getServerInfo(gateway.serverId);
|
||||
const provider = server?.provider || '';
|
||||
const _provider = server?.provider || '';
|
||||
const serverCountry = server?.country || '';
|
||||
const gatewayCountry = gateway.country || '';
|
||||
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)
|
||||
const parentGatewaysList = gateway.type === 'recursive'
|
||||
@@ -3135,7 +3135,7 @@ function NetworkConfigManager() {
|
||||
const interfaceTypeColor = getInterfaceTypeColor(iface.type);
|
||||
|
||||
// Определяем отображаемый IP для зеленого блока (remote IP другого сервера)
|
||||
const displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
|
||||
const _displayIp = displayRemoteIp !== '—' ? displayRemoteIp : displayLocalIp;
|
||||
|
||||
return (
|
||||
<div className="card" style={{ borderRadius: '12px', maxWidth: '100%' }}>
|
||||
@@ -5531,7 +5531,7 @@ function NetworkConfigManager() {
|
||||
<div className="row g-2">
|
||||
{GATEWAY_TEMPLATES.map(template => {
|
||||
// Генерируем предпросмотр IP адресов если есть базовый IP и сервер
|
||||
const previewIps = template.gateways.map((gw, idx) => {
|
||||
const previewIps = template.gateways.map((gw, _idx) => {
|
||||
// Для прямых gateways показываем IP сервера, если выбран
|
||||
if (gw.type === 'direct' && templateServerId) {
|
||||
const server = getServerInfo(templateServerId);
|
||||
@@ -5709,7 +5709,7 @@ function NetworkConfigManager() {
|
||||
<label className="form-label small">Родительские gateway/интерфейсы</label>
|
||||
<div className="d-flex flex-column gap-2">
|
||||
{(gw.parentGateways || []).map((parent, parentIndex) => {
|
||||
const parentInfo = getParentGateway(parent.id, templateGateways);
|
||||
const _parentInfo = getParentGateway(parent.id, templateGateways);
|
||||
return (
|
||||
<div key={parentIndex} className="d-flex align-items-center gap-2">
|
||||
<div className="flex-grow-1">
|
||||
|
||||
@@ -309,7 +309,7 @@ export default function NetworkMapDashboard() {
|
||||
* Если передать force=true, принудительно выполняет новый speed-test (игнорируя кеш на бэкенде).
|
||||
*/
|
||||
const requestSpeeds = useCallback(async (options = {}) => {
|
||||
const { connections: targetConnections, force = false } = options;
|
||||
const { connections: targetConnections, force: _force = false } = options;
|
||||
const sourceConnections =
|
||||
Array.isArray(targetConnections) && targetConnections.length > 0 ? targetConnections : connections;
|
||||
const withSpeed = sourceConnections.filter(
|
||||
|
||||
@@ -213,10 +213,10 @@ export default function NetworkMapUnifi({
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) saved = JSON.parse(raw) || {};
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
const initial = computeInitialLayout(servers, size);
|
||||
const merged = {};
|
||||
servers.forEach((s, i) => {
|
||||
servers.forEach((s, _i) => {
|
||||
const ip = String(s.ip);
|
||||
if (saved[ip] && typeof saved[ip].x === 'number' && typeof saved[ip].y === 'number') {
|
||||
merged[ip] = { x: saved[ip].x, y: saved[ip].y };
|
||||
@@ -227,11 +227,11 @@ export default function NetworkMapUnifi({
|
||||
setPositions(merged);
|
||||
}, [servers.length, size.w, size.h]);
|
||||
|
||||
const savePositions = useCallback((next) => {
|
||||
const _savePositions = useCallback((next) => {
|
||||
setPositions(next);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
}, []);
|
||||
|
||||
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) } };
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -273,7 +273,7 @@ export default function NetworkMapUnifi({
|
||||
setPositions(initial);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial));
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
}, [servers, size]);
|
||||
|
||||
const onNodeMouseDown = useCallback(
|
||||
|
||||
@@ -68,7 +68,7 @@ function buildOspfTemplatesFromRouterResults(routerResults = [], servers = []) {
|
||||
interfaceName,
|
||||
area,
|
||||
cost,
|
||||
active: !Boolean(tpl?.disabled),
|
||||
active: !tpl?.disabled,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -116,7 +116,7 @@ function buildOptimizerProbabilityMap(routeOptimizerData, servers = []) {
|
||||
uniqueIface.push(candidate);
|
||||
});
|
||||
|
||||
uniqueIface.forEach((candidate, idx) => {
|
||||
uniqueIface.forEach((candidate, _idx) => {
|
||||
const iface = String(candidate?.interfaceName || '').trim();
|
||||
const hintKey = `${identity.key}::${iface.toUpperCase()}`;
|
||||
const probabilityOptimal = Number(candidate?.probabilityOptimal || 0);
|
||||
|
||||
@@ -308,12 +308,12 @@ export default function PingServicesManager() {
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((item) => {
|
||||
const iconInfo = getIconById(item.icon);
|
||||
const { Icon } = getIconById(item.icon);
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<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>
|
||||
</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 ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||
import ConfirmModal from './components/ConfirmModal.jsx';
|
||||
@@ -501,7 +501,7 @@ function ServerManager() {
|
||||
return `${urlSettings.baseUrl}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const handleGenerateLink = (server) => {
|
||||
const _handleGenerateLink = (server) => {
|
||||
setLinkGeneratorServer(server);
|
||||
setLinkGeneratorOpen(true);
|
||||
};
|
||||
@@ -511,7 +511,7 @@ function ServerManager() {
|
||||
setLinkGeneratorServer(null);
|
||||
};
|
||||
|
||||
const applyPreset = (name) => {
|
||||
const _applyPreset = (name) => {
|
||||
const p = presets[name];
|
||||
if (!p) return;
|
||||
setUrlSettings(prev => ({ ...prev, ...p }));
|
||||
@@ -529,7 +529,7 @@ function ServerManager() {
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
notify.success('Скопировано в буфер обмена!');
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
notify.error('Не удалось скопировать');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
@@ -541,14 +541,14 @@ function ServerManager() {
|
||||
await navigator.clipboard.writeText(text);
|
||||
notify.success('Скопировано в буфер обмена!');
|
||||
return;
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
fallbackCopyToClipboard(text);
|
||||
};
|
||||
|
||||
const quickCopyLink = async (server) => {
|
||||
const _quickCopyLink = async (server) => {
|
||||
const url = generateServerUrl(server);
|
||||
await copyToClipboard(url);
|
||||
};
|
||||
|
||||
@@ -34,14 +34,14 @@ function loadHistoryFromStorage() {
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') return parsed;
|
||||
} catch (_) {}
|
||||
} catch { /* no-op */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
function saveHistoryToStorage(historyMap) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(historyMap));
|
||||
} catch (_) {}
|
||||
} catch { /* no-op */ }
|
||||
}
|
||||
|
||||
/** Группировка истории по дням: массив { day, dayLabel, incidents } */
|
||||
|
||||
@@ -8,14 +8,14 @@ import FloatingBulkActionsBar from './FloatingBulkActionsBar.jsx';
|
||||
*/
|
||||
function BulkActionsBar({
|
||||
selectedCount = 0,
|
||||
totalCount = 0,
|
||||
onSelectAll = () => {},
|
||||
totalCount: _totalCount = 0,
|
||||
onSelectAll: _onSelectAll = () => {},
|
||||
onDeselectAll = () => {},
|
||||
onDelete = null,
|
||||
onEdit = null,
|
||||
onExport = null,
|
||||
customActions = [],
|
||||
className = '',
|
||||
className: _className = '',
|
||||
}) {
|
||||
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 TableSkeleton, { TableEmpty } from './TableSkeleton.jsx';
|
||||
import EmptyState from './EmptyState.jsx';
|
||||
|
||||
@@ -16,7 +16,7 @@ class ErrorBoundary extends Component {
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
static getDerivedStateFromError(_error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ class ErrorBoundary extends Component {
|
||||
{this.state.error?.message || 'Что-то пошло не так. Попробуйте обновить страницу.'}
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
|
||||
{import.meta.env.DEV && this.state.errorInfo && (
|
||||
<div className="card mt-3">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Детали ошибки (только в dev режиме)</h3>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack })
|
||||
notify.success('Откат выполнен успешно');
|
||||
onRolledBack?.(res.data || {});
|
||||
onClose?.();
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
notify.error('Не удалось выполнить откат');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js';
|
||||
import { IconSearch, IconMinus } from '@tabler/icons-react';
|
||||
|
||||
@@ -19,12 +19,12 @@ function MobileCardView({
|
||||
// минимальная дистанция свайпа в px
|
||||
const minSwipeDistance = 50
|
||||
|
||||
const onTouchStart = (e, item) => {
|
||||
const onTouchStart = (e, _item) => {
|
||||
setTouchEnd(null)
|
||||
setTouchStart(e.targetTouches[0].clientX)
|
||||
}
|
||||
|
||||
const onTouchMove = (e, item) => {
|
||||
const onTouchMove = (e, _item) => {
|
||||
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 { 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) {
|
||||
window.notify.success(text, details);
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
}
|
||||
|
||||
function colorByType(type) {
|
||||
|
||||
@@ -14,7 +14,7 @@ function SavedFilters({
|
||||
const [savedFilters, setSavedFilters] = useState([])
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const [filterName, setFilterName] = useState('')
|
||||
const [showList, setShowList] = useState(false)
|
||||
const [_showList, setShowList] = useState(false)
|
||||
|
||||
const storageKey = `savedFilters_${pageKey}`
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function SettingsModal({ open, onClose }) {
|
||||
const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
|
||||
setEtag(e ? String(e) : '');
|
||||
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
setError('Не удалось загрузить настройки');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useState, useEffect, createContext, useContext } from 'react'
|
||||
import {
|
||||
IconCheck,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IconTrophy, IconMapPin, IconCloud, IconHash, IconChartBar } from '@tabl
|
||||
function TopNStats({ data, loading }) {
|
||||
const [topCountries, setTopCountries] = useState([]);
|
||||
const [topProviders, setTopProviders] = useState([]);
|
||||
const [topCommunities, setTopCommunities] = useState([]);
|
||||
const [_topCommunities, _setTopCommunities] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !Array.isArray(data.servers)) return;
|
||||
|
||||
@@ -58,7 +58,7 @@ function ValidatedInput({
|
||||
message: validationResult.message || '',
|
||||
isValidating: false
|
||||
})
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
setValidationState({
|
||||
valid: false,
|
||||
message: 'Ошибка валидации',
|
||||
@@ -84,7 +84,7 @@ function ValidatedInput({
|
||||
// Запускаем валидацию сразу при потере фокуса
|
||||
if (validate && !validateOnChange) {
|
||||
const result = validate(value)
|
||||
const validationResult = result instanceof Promise ? result.then(r => {
|
||||
const _validationResult = result instanceof Promise ? result.then(r => {
|
||||
setValidationState({
|
||||
valid: r.valid,
|
||||
message: r.message || '',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy } from '@tabler/icons-react';
|
||||
import TableSkeleton from './TableSkeleton.jsx';
|
||||
|
||||
@@ -64,12 +64,12 @@ function WsUpdateModal({ show, url, onClose }) {
|
||||
|
||||
ws.onerror = () => setStatus('error');
|
||||
ws.onclose = () => setStatus('closed');
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
setStatus('error');
|
||||
}
|
||||
|
||||
return () => {
|
||||
try { wsRef.current?.close(); } catch {}
|
||||
try { wsRef.current?.close(); } catch { /* no-op */ }
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [show, url]);
|
||||
@@ -96,7 +96,7 @@ function WsUpdateModal({ show, url, onClose }) {
|
||||
const copyLog = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(plainLog);
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
};
|
||||
|
||||
const clearLog = () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ function countryToFlag(isoCode) {
|
||||
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 (
|
||||
<div className="filter-section border-bottom">
|
||||
<button
|
||||
@@ -37,7 +37,7 @@ function FilterSection({ title, icon: Icon, iconColor, expanded, onToggle, child
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<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>
|
||||
{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 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 api from '../lib/api.js';
|
||||
|
||||
@@ -97,11 +98,11 @@ export function PingProvider({ children }) {
|
||||
const cancelScope = useCallback((scope) => {
|
||||
const scopeName = String(scope || '');
|
||||
if (!scopeName) return;
|
||||
let canceled = 0;
|
||||
let _canceled = 0;
|
||||
controllersRef.current.forEach((controller, key) => {
|
||||
if (keyScopesRef.current.get(key) === scopeName) {
|
||||
controller.abort();
|
||||
canceled += 1;
|
||||
_canceled += 1;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -81,7 +81,7 @@ function SimpleErrorHandlingExample() {
|
||||
*/
|
||||
function RetryButtonExample() {
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
const { handleError, handleSuccess } = useErrorHandler();
|
||||
const { handleError: _handleError, handleSuccess } = useErrorHandler();
|
||||
|
||||
const unreliableOperation = async () => {
|
||||
setAttempts(prev => prev + 1);
|
||||
|
||||
@@ -57,7 +57,7 @@ api.interceptors.response.use(
|
||||
responseCache.set(key, { etag, data: response.data, headers: response.headers });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
@@ -174,7 +174,7 @@ api.interceptors.request.use((config) => {
|
||||
config.headers = config.headers || {};
|
||||
config.headers['If-Match'] = String(config.data.etag);
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ function readCache() {
|
||||
function writeCache(cache) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
} catch {}
|
||||
} catch { /* no-op */ }
|
||||
}
|
||||
|
||||
function getCached(asn) {
|
||||
|
||||
+3
-1
@@ -5,7 +5,9 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"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": [],
|
||||
"author": "",
|
||||
|
||||
Reference in New Issue
Block a user