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

This commit is contained in:
2026-03-15 23:29:24 +07:00
parent 164507513c
commit e529aa23ec
46 changed files with 519 additions and 146 deletions
@@ -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 3060 сек
- Уменьшит количество обращений к 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`
+27
View File
@@ -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:
+25 -1
View File
@@ -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
+63
View File
@@ -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
+3 -2
View File
@@ -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 }],
},
},
])
+6 -6
View File
@@ -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;
+2 -2
View File
@@ -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;
+10 -10
View File
@@ -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 })));
+2 -2
View File
@@ -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.');
}
};
+4 -4
View File
@@ -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">
+8 -8
View File
@@ -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;
+4 -4
View File
@@ -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 = [];
+11 -11
View File
@@ -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('Не удалось обновить статус сервера');
}
}}
+2 -2
View File
@@ -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));
+6 -7
View File
@@ -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);
}
};
+23 -19
View File
@@ -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);
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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>
+16 -16
View File
@@ -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">
+1 -1
View File
@@ -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(
+6 -6
View File
@@ -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(
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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>
+6 -6
View File
@@ -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);
};
+2 -2
View File
@@ -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 } */
+3 -3
View File
@@ -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 -1
View File
@@ -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';
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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';
+2 -2
View File
@@ -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)
}
+2 -1
View File
@@ -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) {
+1 -1
View File
@@ -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}`
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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 -1
View File
@@ -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';
+3 -3
View File
@@ -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
View File
@@ -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';
+3 -2
View File
@@ -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);
+2 -2
View File
@@ -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;
});
+1 -1
View File
@@ -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
View File
@@ -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": "",