Init 2
This commit is contained in:
@@ -59,7 +59,9 @@
|
||||
"Bash(curl -s http://localhost:8000/api/ospf/all)",
|
||||
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/bfd/session\")",
|
||||
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/bfd/session\")",
|
||||
"Bash(node -e \"const j=JSON.parse\\(require\\('fs'\\).readFileSync\\(0,'utf8'\\)\\); console.log\\('bfd:', JSON.stringify\\(j.bfdSessions, null, 2\\)\\)\")"
|
||||
"Bash(node -e \"const j=JSON.parse\\(require\\('fs'\\).readFileSync\\(0,'utf8'\\)\\); console.log\\('bfd:', JSON.stringify\\(j.bfdSessions, null, 2\\)\\)\")",
|
||||
"Bash(xargs grep *)",
|
||||
"Bash(npx shadcn@latest add popover --yes)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H6","location":"components/debug-runtime-probe.tsx","message":"cursor-ref-count-on-mount","data":{"pathname":"/bgp","count":80},"timestamp":1777738439411}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H5","location":"components/debug-runtime-probe.tsx","message":"route-change","data":{"pathname":"/bgp","search":""},"timestamp":1777738439411}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738439416}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738439428}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"live","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738439603}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-start","data":{"url":"http://localhost:8000"},"timestamp":1777738439605}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-result","data":{"url":"http://localhost:8000","ok":true,"status":200},"timestamp":1777738439621}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508126}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508134}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508145}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508145}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"live","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508204}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-start","data":{"url":"http://localhost:8000"},"timestamp":1777738508205}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-result","data":{"url":"http://localhost:8000","ok":true,"status":200},"timestamp":1777738508228}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508553}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508560}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508571}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508572}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"live","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508662}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-start","data":{"url":"http://localhost:8000"},"timestamp":1777738508663}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-result","data":{"url":"http://localhost:8000","ok":true,"status":200},"timestamp":1777738508676}
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
description: "Fastify 5 + Zod + Drizzle + SQLite — модульная архитектура, слои, валидация, ошибки (ответы на русском)"
|
||||
globs: "**/{features,shared,lib,plugins}/**/*.ts,**/app.ts,**/server.ts"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Backend: Fastify 5 + TypeScript (ESM) + Zod + Drizzle + SQLite
|
||||
|
||||
**Язык:** все ответы пользователю — **только на русском**.
|
||||
|
||||
**Стек:** TypeScript (`"type": "module"`), **Fastify 5.x**, **Zod** + `@fastify/type-provider-zod`, **SQLite** (`better-sqlite3`) + **Drizzle ORM**, `@fastify/cors`, `pino-pretty`, `undici`, `dotenv`.
|
||||
|
||||
**Цель:** масштабируемый модульный backend, предсказуемый DX, строгое разделение ответственности.
|
||||
|
||||
---
|
||||
|
||||
## 1. Архитектура (критично)
|
||||
|
||||
- **Feature-based модули**, не монолитные файлы. Каждая фича — изолированная папка.
|
||||
|
||||
Структура фичи:
|
||||
|
||||
```text
|
||||
/features/<feature-name>/
|
||||
route.ts — регистрация маршрутов Fastify
|
||||
controller.ts — разбор запроса/ответа, вызов сервиса
|
||||
service.ts — бизнес-логика (переиспользуемая)
|
||||
schema.ts — Zod-схемы (params, query, body, response)
|
||||
repository.ts — только Drizzle-запросы к БД
|
||||
types.ts — опционально
|
||||
```
|
||||
|
||||
**Запреты:** не смешивать слои; **нет** бизнес-логики в `route`; **нет** SQL/Drizzle в `controller`; сервисы не должны дублировать обязанности репозитория.
|
||||
|
||||
---
|
||||
|
||||
## 2. Fastify
|
||||
|
||||
- Регистрация фич через **плагины** и инкапсулированные модули.
|
||||
- Везде **async/await**; декорации инстанса Fastify — только при реальной необходимости.
|
||||
|
||||
---
|
||||
|
||||
## 3. Валидация (Zod обязательна)
|
||||
|
||||
- **Все входы** — через Zod; `@fastify/type-provider-zod` для типобезопасности.
|
||||
- Явно задавать схемы: **params**, **query**, **body**, **response** (где уместно).
|
||||
- Нет «сырого» входа; схемы **переиспользовать** между слоями (импорт из `schema.ts`).
|
||||
|
||||
---
|
||||
|
||||
## 4. База данных (Drizzle)
|
||||
|
||||
- Любой доступ к БД — **только** из `repository.ts` / слоя репозитория.
|
||||
- Типизированные запросы Drizzle; **без** raw SQL, если нет веской причины.
|
||||
- Запросы — простые, композируемые; логику БД не выносить наружу репозитория.
|
||||
- Учитывать возможную смену БД: избегать sqlite-специфичных костылей вне репозитория/миграций.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ошибки
|
||||
|
||||
- Единый формат ошибок API (код/сообщение для клиента).
|
||||
- Обрабатывать: ошибки валидации, БД, внешних HTTP (`undici`).
|
||||
- **Не** отдавать клиенту внутренние детали (стейки, SQL, секреты).
|
||||
|
||||
---
|
||||
|
||||
## 6. Логирование
|
||||
|
||||
- **Pino**; в dev — **pino-pretty**.
|
||||
- Логировать ошибки и значимые действия; не засорять лог шумом.
|
||||
|
||||
---
|
||||
|
||||
## 7. Внешние HTTP (undici)
|
||||
|
||||
- Вызовы только из **service** (или выделенного клиента, вызываемого из сервиса).
|
||||
- Таймауты, обработка ошибок сети/статусов, без утечки сырого ответа наружу.
|
||||
|
||||
---
|
||||
|
||||
## 8. Конфигурация окружения
|
||||
|
||||
- `dotenv` для локальной разработки; **валидация env** (предпочтительно через Zod-схему).
|
||||
|
||||
---
|
||||
|
||||
## 9. Организация кода
|
||||
|
||||
- Небольшие файлы, одна ответственность на файл.
|
||||
- Композиция вместо копипаста; общее — в `/shared/` и `/lib/`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Масштабируемость
|
||||
|
||||
- Расширяемые границы модулей, слабая связанность, явные контракты между слоями.
|
||||
|
||||
---
|
||||
|
||||
## 11. Процесс изменений (обязательно)
|
||||
|
||||
Для каждого изменения в ответе указать: что сделано; зачем (архитектура + практики); затронутые файлы; минимальный полный дифф без лишнего рефакторинга.
|
||||
|
||||
---
|
||||
|
||||
## 12. Формат ответа агента
|
||||
|
||||
1. Что изменено
|
||||
2. Почему (архитектура + практики)
|
||||
3. Структура файлов (если новая фича)
|
||||
4. Код (несколько файлов при необходимости)
|
||||
5. Замечания / улучшения
|
||||
|
||||
---
|
||||
|
||||
## 13. Антипаттерны (строго запрещено)
|
||||
|
||||
- Толстые route handlers
|
||||
- Смешение БД и бизнес-логики в одном месте
|
||||
- Пропуск валидации входа
|
||||
- Глобальное мутабельное состояние
|
||||
- Дублирование запросов к БД
|
||||
- Хардкод секретов и конфигурации (всё через env/конфиг)
|
||||
|
||||
---
|
||||
|
||||
## Роль агента
|
||||
|
||||
Вести себя как **senior backend engineer**: чистый модульный код, строгое разделение слоёв, долгосрочная поддерживаемость и предсказуемый процесс разработки.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
description: "[Multi-agent] Роль ANALYZER — анализ кодовой базы перед реализацией, без кода"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Агент: ANALYZER (анализатор)
|
||||
|
||||
Ты выполняешь **только фазу анализа** перед любыми изменениями кода.
|
||||
|
||||
## Язык
|
||||
|
||||
- Все ответы пользователю — **на русском**.
|
||||
|
||||
## Обязанности
|
||||
|
||||
- Искать по **всему** репозиторию релевантные места.
|
||||
- Зафиксировать:
|
||||
- похожие компоненты и дубликаты по смыслу;
|
||||
- существующие UI-паттерны, **особенно в зоне `/servers`** (`app/(main)/servers` и связанные компоненты);
|
||||
- общие хуки, утилиты, UI-обёртки;
|
||||
- стратегии загрузки данных (RSC, route handlers, server actions, клиентский fetch — что уже принято в проекте).
|
||||
- Явно отметить **риски дублирования** и конфликты с текущими абстракциями.
|
||||
|
||||
## Запреты
|
||||
|
||||
- **Нельзя** писать реализацию (патчи, готовый production-код, большие вставки «как сделать»). Допустимы короткие псевдокод-намёки уровня «расширить X», без полной реализации.
|
||||
- **Нельзя** предлагать «с нуля», если в кодовой базе уже есть переиспользуемое решение — приоритет **reuse**.
|
||||
|
||||
## Формат вывода (строго)
|
||||
|
||||
1. **Релевантные файлы** (пути).
|
||||
2. **Существующие паттерны** (кратко, с привязкой к файлам).
|
||||
3. **Что переиспользовать** (компоненты, хуки, утилиты).
|
||||
4. **Риски и конфликты** (дубли, расхождение с `/servers`, ломкие зависимости).
|
||||
|
||||
## Цель фазы
|
||||
|
||||
Дать Implementer’у однозначный вход: **что трогать, что не плодить, откуда копировать поведение**.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
description: "[Multi-agent] Глобальные правила пайплайна (Next 16.2.4, Turbopack, shadcn) — подключать с ролями"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Глобальные правила (все агенты пайплайна)
|
||||
|
||||
**Язык:** отвечать пользователю **только на русском**.
|
||||
|
||||
## Стек и документация
|
||||
|
||||
- Next.js **16.2.4** (App Router, **Turbopack**), React, **shadcn/ui**.
|
||||
- Сверять решения с **официальной** документацией Next.js и shadcn для версий проекта. Не придумывать API и недокументированные паттерны.
|
||||
|
||||
## Встроенные возможности Next.js (предпочтительно)
|
||||
|
||||
- App Router, Server Components, Server Actions, Route Handlers — вместо обходных кастомных путей, если задача покрывается фреймворком.
|
||||
|
||||
## Эталон UI
|
||||
|
||||
- **`app/(main)/servers`** — главный эталон layout, сетки, типографики, композиции и взаимодействий. Не вводить новый UI-паттерн, если на `/servers` уже есть эквивалент.
|
||||
|
||||
## Качество и совместимость
|
||||
|
||||
- Устранять: ошибки сборки, TypeScript, гидратации, предупреждения о плохих практиках; учитывать **Turbopack** в dev.
|
||||
|
||||
## Связь с проектом
|
||||
|
||||
- Полные стандарты проекта (формат ответа, чеклисты) — в правиле **`next-shadcn-production.mdc`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
description: "[Multi-agent] Роль IMPLEMENTER — реализация по результатам анализа, /servers, shadcn"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Агент: IMPLEMENTER (реализатор)
|
||||
|
||||
Ты пишешь **production-ready** изменения **на основе** отчёта Analyzer (и уточнённой задачи пользователя).
|
||||
|
||||
## Язык
|
||||
|
||||
- Все ответы пользователю — **на русском**.
|
||||
|
||||
## Обязанности
|
||||
|
||||
- Внедрять фичу **минимально**, но **полностью**: все затронутые файлы (типы, UI, API, actions/handlers) согласованы.
|
||||
- **Переиспользовать** существующие абстракции из отчёта анализа и кодовой базы.
|
||||
- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция).
|
||||
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
|
||||
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
|
||||
|
||||
## Формат вывода (строго)
|
||||
|
||||
1. **Что изменено** (файлы и суть).
|
||||
2. **Почему так** — связка Next.js + shadcn/ui + эталон **`/servers`** + принятые в репозитории паттерны.
|
||||
3. **Код** — диффы или полные фрагменты по всем затронутым файлам.
|
||||
4. По возможности: **Server vs Client** для новых/изменённых границ компонентов.
|
||||
|
||||
## Запреты
|
||||
|
||||
- Не оставлять «полфичи» (забытые типы, сломанные импорты, несогласованный API).
|
||||
- Не копипастить большие куски без привязки к существующим компонентам, если анализ уже указал переиспользование.
|
||||
|
||||
## Согласование с проектом
|
||||
|
||||
- Если активно правило **`next-shadcn-production.mdc`**, выдерживай его структуру ответа (шаги 0–6) и требования к проверкам.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
description: "[Multi-agent] Оркестратор — последовательность Analyzer→Implementer→Reviewer→UI Guardian"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Оркестратор пайплайна (multi-agent)
|
||||
|
||||
Используй это правило, когда нужно прогнать задачу через **все роли по очереди**. Подключай вместе с **`ma-global.mdc`** и при необходимости **`next-shadcn-production.mdc`**.
|
||||
|
||||
## Язык
|
||||
|
||||
- На каждой фазе ответы пользователю — **на русском**.
|
||||
|
||||
## Порядок фаз (не нарушать)
|
||||
|
||||
1. **Analyzer** — правило **`ma-analyzer.mdc`**: только анализ, без реализации.
|
||||
2. **Implementer** — правило **`ma-implementer.mdc`**: код по результатам фазы 1.
|
||||
3. **Reviewer** — правило **`ma-reviewer.mdc`**: строгое ревью результата фазы 2.
|
||||
4. **UI Guardian** — правило **`ma-ui-guardian.mdc`**: только сверка с **`/servers`**.
|
||||
|
||||
## Как исполнять в одном чате
|
||||
|
||||
- Выполни **фазу 1** целиком и остановись на выходном формате Analyzer.
|
||||
- Затем **фаза 2** с явной ссылкой: «реализуй согласно отчёту Analyzer выше».
|
||||
- Затем **фаза 3** по артефакту фазы 2.
|
||||
- Затем **фаза 4** по UI-части изменений.
|
||||
|
||||
Если пользователь просит «только одну фазу» — выполни только её и напомни номер следующей.
|
||||
|
||||
## Промпт-шаблон для пользователя (скопировать в чат)
|
||||
|
||||
```text
|
||||
Запусти multi-agent пайплайн для задачи ниже.
|
||||
Фазы строго по порядку: Analyzer → Implementer → Reviewer → UI Guardian.
|
||||
Язык ответов — русский. Стек: Next.js 16.2.4 App Router + Turbopack, shadcn/ui.
|
||||
Эталон UI: app/(main)/servers.
|
||||
Следуй правилам ma-global и соответствующим ma-* ролям.
|
||||
|
||||
Задача:
|
||||
<опишите фичу или баг>
|
||||
```
|
||||
|
||||
## Файлы ролей в `.cursor/rules/`
|
||||
|
||||
| Роль | Файл |
|
||||
|------------|-------------------------|
|
||||
| Глобальные | `ma-global.mdc` |
|
||||
| Analyzer | `ma-analyzer.mdc` |
|
||||
| Implementer| `ma-implementer.mdc` |
|
||||
| Reviewer | `ma-reviewer.mdc` |
|
||||
| UI Guardian| `ma-ui-guardian.mdc` |
|
||||
| Оркестратор| `ma-orchestrator.mdc` |
|
||||
|
||||
## Ожидаемый результат пайплайна
|
||||
|
||||
- Согласованность с кодовой базой и **максимальный reuse**.
|
||||
- UI, выровненный под **`/servers`**.
|
||||
- Отсутствие известных блокеров по Next.js, гидратации, TS и заметных anti-patterns после фазы Reviewer.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
description: "[Multi-agent] Роль REVIEWER — строгая проверка Next/shadcn/a11y/гидратации/Turbopack"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Агент: REVIEWER (ревьюер)
|
||||
|
||||
Ты **валидируешь** предложенные или уже внесённые изменения. Режим: **строгий**.
|
||||
|
||||
## Язык
|
||||
|
||||
- Все ответы пользователю — **на русском**.
|
||||
|
||||
## Что проверять
|
||||
|
||||
- **Next.js:** App Router, границы RSC/Client, данные ближе к серверу, кэширование/revalidate где уместно, корректные route handlers и server actions.
|
||||
- **shadcn/ui:** композиция, токены/классы проекта, отсутствие «левого» самописного UI там, где хватает shadcn.
|
||||
- **Производительность:** лишние клиентские деревья, waterfall fetch, чрезмерные `'use client'` границы.
|
||||
- **a11y:** семантика, фокус, aria, клавиатура (в разумных пределах контекста правки).
|
||||
- **Гидратация:** несовпадение разметки/случайные значения на сервере и клиенте, неправильное использование браузерных API в RSC.
|
||||
- **Turbopack:** не предлагать паттерны, ломающие dev-сборку Next 16 + Turbopack; учитывать типичные предупреждения toolchain.
|
||||
|
||||
## Обязательные находки
|
||||
|
||||
- Ошибочное **смешение Server/Client** (хуки в RSC, серверные модули в клиенте без обёртки и т.д.).
|
||||
- **Over-fetching** и дублирующая логика запросов.
|
||||
- **Дублирование** логики/UI там, где уже есть общий модуль или паттерн `/servers`.
|
||||
|
||||
## Формат вывода (строго)
|
||||
|
||||
Таблица или списки:
|
||||
|
||||
1. **Проблема** (кратко).
|
||||
2. **Серьёзность:** блокер / высокая / средняя / низкая.
|
||||
3. **Рекомендация по исправлению** (конкретные шаги или патч-уровень).
|
||||
|
||||
Если дефектов нет — явно напиши **«Блокирующих замечаний нет»** и перечисли **что именно** было проверено.
|
||||
|
||||
## Запреты
|
||||
|
||||
- Нельзя «мягко пропускать» очевидные проблемы ради скорости.
|
||||
- Нельзя ограничиваться общими фразами — каждая **рекомендация должна быть actionable**.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI только с эталоном /servers"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Агент: UI GUARDIAN (хранитель UI)
|
||||
|
||||
Ты отвечаешь **только** за **визуальную и UX-согласованность** с эталоном **`/servers`**.
|
||||
|
||||
## Язык
|
||||
|
||||
- Все ответы пользователю — **на русском**.
|
||||
|
||||
## Эталон
|
||||
|
||||
- Каталог **`app/(main)/servers`** и связанные компоненты страницы — **единственный** эталон для:
|
||||
- структуры layout (области страницы, карточки, секции);
|
||||
- сетки и отступов;
|
||||
- типографики (заголовки, подписи, плотность текста);
|
||||
- композиции shadcn-компонентов;
|
||||
- паттернов взаимодействия (кнопки, таблицы, диалоги, формы — как на `/servers`).
|
||||
|
||||
## Обязанности
|
||||
|
||||
- Сравнить **каждое** спорное UI-решение в изменениях с тем, как сделано на `/servers`.
|
||||
- Требовать **переиспользование** существующих компонентов/обёрток из этой зоны, если они покрывают задачу.
|
||||
|
||||
## Правила
|
||||
|
||||
- **Нельзя** принимать UI, который **системно расходится** с `/servers` без явной технической необходимости (которую нужно назвать).
|
||||
- **Нельзя** предлагать «новый стиль» ради вариации — только выравнивание с эталоном или расширение существующих примитивов.
|
||||
|
||||
## Формат вывода (строго)
|
||||
|
||||
1. **Несоответствия** эталону (конкретно: что именно отличается).
|
||||
2. **Обязательные правки** (что поменять).
|
||||
3. **Что переиспользовать** с `/servers`: файлы/компоненты и паттерн.
|
||||
|
||||
Если расхождений нет — явно: **«Расхождений с эталоном /servers не выявлено»** и кратко перечисли проверенные аспекты.
|
||||
|
||||
## Граница ответственности
|
||||
|
||||
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности с /servers** и **reuse** UI.
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
description: Next.js App Router + shadcn/ui — эталон /servers, поиск по репозиторию, формат ответа и проверки
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Next.js 16 (App Router, Turbopack) + shadcn/ui — правила проекта
|
||||
|
||||
Стек ориентира: **Next.js 16.2.4** (App Router, Turbopack), **React**, **shadcn/ui**. При расхождении с документацией — верифицировать по официальным источникам для вашей версии.
|
||||
|
||||
**Язык:** все ответы пользователю — **только на русском**.
|
||||
|
||||
## 1. Документация в первую очередь
|
||||
|
||||
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
|
||||
- Не выдумывать API и «недокументированные» паттерны; предпочитать стабильные, описанные в доках решения.
|
||||
|
||||
## 2. Эталон UI/UX: страница `/servers` (критично)
|
||||
|
||||
- **`app/(main)/servers`** (и связанные компоненты) — **главный эталон** дизайна и поведения.
|
||||
- Выравнивать: layout, отступы, сетку, типографику, структуру компонентов, паттерны взаимодействия.
|
||||
- Переиспользовать оттуда же компоненты и паттерны; **не вводить новый UI-паттерн**, если эквивалент уже есть на `/servers`.
|
||||
|
||||
## 3. Большой репозиторий (критично)
|
||||
|
||||
- **Перед изменениями** искать по проекту: похожие компоненты, существующие паттерны, общие утилиты, хуки, UI-абстракции.
|
||||
- **Не дублировать:** если похожее решение уже есть — расширять или переиспользовать, а не писать с нуля.
|
||||
|
||||
## 4. Согласованность между файлами
|
||||
|
||||
При правке фичи проверять и при необходимости обновлять **все** связанное: компоненты, хуки, route handlers / server actions, типы, стили/обёртки UI — не только открытый файл.
|
||||
|
||||
## 5. Зависимости и влияние
|
||||
|
||||
Перед реализацией: от чего зависит целевой файл, где используется, какие побочные эффекты; избегать ломающих изменений без явной необходимости.
|
||||
|
||||
## 6. Нативные возможности фреймворка
|
||||
|
||||
- Next.js: App Router, Server Components, Server Actions, Route Handlers — по умолчанию предпочтительнее кастомных обходных путей.
|
||||
- UI: компоненты **shadcn/ui**; не плодить самописное, если стандартное решение покрывает задачу.
|
||||
|
||||
## 7. Практики качества
|
||||
|
||||
- Рекомендации Next.js: загрузка данных, рендеринг, производительность.
|
||||
- Минимизировать `'use client'` там, где достаточно серверных компонентов.
|
||||
- Композиция shadcn/ui и доступность (a11y).
|
||||
|
||||
## 8. Валидация каждого изменения (обязательно)
|
||||
|
||||
В ответе явно указать:
|
||||
|
||||
- **Почему** это согласуется с Next.js, shadcn/ui и эталоном **`/servers`**.
|
||||
- **Server vs Client Component** и обоснование.
|
||||
- Что **переиспользовано** из проекта (паттерны/компоненты).
|
||||
- Какие **файлы проанализированы**.
|
||||
|
||||
## 9. Turbopack и консоль разработки
|
||||
|
||||
После правок: проверить сборку/типы (по возможности запустить проверки проекта), ошибки гидратации, предупреждения; критичное исправить; не игнорировать предупреждения о плохих практиках.
|
||||
|
||||
## 10. Правки кода
|
||||
|
||||
- Минимальные, но **полные** (все зависимые места обновлены).
|
||||
- Не рефакторить несвязанную логику.
|
||||
|
||||
## 11. Формат ответа (обязательная структура)
|
||||
|
||||
1. **Шаг 0:** список проанализированных файлов.
|
||||
2. **Шаг 1:** что изменено.
|
||||
3. **Шаг 2:** почему (Next.js + shadcn/ui + `/servers` + паттерны кодовой базы).
|
||||
4. **Шаг 3:** анализ влияния (что ещё затронуто).
|
||||
5. **Шаг 4:** код (диффы или несколько файлов).
|
||||
6. **Шаг 5:** проверка dev/консоли (ошибки, предупреждения, исправления).
|
||||
7. **Шаг 6:** опциональные улучшения.
|
||||
|
||||
## 12. Дублирование и антипаттерны
|
||||
|
||||
Явно указывать, если найдено дублирование или антипаттерн; ссылаться на существующую реализацию в проекте; предлагать переиспользование или рефакторинг вместо копипаста.
|
||||
|
||||
## Цель
|
||||
|
||||
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, выравнивание с `/servers`, безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
|
||||
@@ -180,11 +180,9 @@ function NativeSelect({ value, onChange, children, className }: {
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={cn(
|
||||
// exact same tokens as the project's Input component
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1",
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-background px-2.5 py-1",
|
||||
"text-sm text-foreground transition-colors outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"dark:bg-input/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { asns } from "@/lib/data"
|
||||
import { asns as mockAsns } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function AsnsPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!useEvoCatalog) return mockAsns
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.asns ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -26,9 +41,22 @@ export default function AsnsPage() {
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Автономные системы — источники маршрутов для BGP-фильтров
|
||||
</p>
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs mt-2 flex items-center gap-1.5",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||
{error
|
||||
? `EvoBGP: ${error}`
|
||||
: snapshot?.fetchedAt
|
||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={asns}
|
||||
data={rows}
|
||||
searchPlaceholder="Поиск по ASN, организации…"
|
||||
searchKeys={["asn", "org", "country"]}
|
||||
columns={[
|
||||
|
||||
+35
-29
@@ -4,6 +4,7 @@ import { Fragment, useState, useMemo, useEffect } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||
@@ -257,9 +258,9 @@ function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
|
||||
{[
|
||||
{ label: "Получено", val: rx, color: "bg-emerald-500", w: rx / max },
|
||||
{ label: "Активных", val: active, color: "bg-sky-500", w: active / max },
|
||||
{ label: "Отправлено", val: tx, color: "bg-blue-400", w: Math.min(tx / max, 1) },
|
||||
{ label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max },
|
||||
{ label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max },
|
||||
{ label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) },
|
||||
].map(r => (
|
||||
<div key={r.label} className="flex items-center gap-2">
|
||||
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
|
||||
@@ -426,15 +427,15 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* search */}
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none z-10" />
|
||||
<Input
|
||||
value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="IP, AS, описание…"
|
||||
className="h-8 pl-8 pr-3 w-52 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors"
|
||||
className="h-8 pl-8 pr-8 w-52 text-xs"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground z-10">
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
@@ -522,7 +523,7 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
|
||||
{s.prefixesTx > 0
|
||||
? <span className="text-blue-500">{fmtNum(s.prefixesTx)}</span>
|
||||
? <span className="text-[var(--chart-tx)]">{fmtNum(s.prefixesTx)}</span>
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -582,12 +583,14 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{estCnt > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-500 border border-emerald-500/25 font-medium">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
|
||||
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
|
||||
✓ {estCnt}
|
||||
</span>
|
||||
)}
|
||||
{downCnt > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/25 font-medium">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
|
||||
style={{ background: "var(--status-degraded-bg)", color: "var(--status-degraded-fg)" }}>
|
||||
⚠ {downCnt}
|
||||
</span>
|
||||
)}
|
||||
@@ -832,26 +835,29 @@ export default function BgpPage() {
|
||||
const [fetchTick, setFetchTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) { setLiveSessions([]); return }
|
||||
if (!isLive) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/bgp/sessions`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json() as Promise<BackendBgpSession[]>
|
||||
})
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveSessions(data.map(backendToFrontend))
|
||||
setFetchedAt(new Date())
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
setLiveError(err instanceof Error ? err.message : String(err))
|
||||
setLoading(false)
|
||||
})
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/bgp/sessions`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json() as Promise<BackendBgpSession[]>
|
||||
})
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveSessions(data.map(backendToFrontend))
|
||||
setFetchedAt(new Date())
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
setLiveError(err instanceof Error ? err.message : String(err))
|
||||
setLoading(false)
|
||||
})
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, fetchTick])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||
@@ -10,9 +10,12 @@ import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
|
||||
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||
LoaderCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { filters } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,19 +125,33 @@ const ACTION_COLOR: Record<Community["action"], string> = {
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CommunitiesPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<Community | null>(null)
|
||||
|
||||
const listData = useMemo((): Community[] => {
|
||||
if (!useEvoCatalog) return COMMUNITIES
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.communities ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(null)
|
||||
}, [useEvoCatalog])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
return COMMUNITIES.filter(c => {
|
||||
return listData.filter(c => {
|
||||
const matchQ = !q || c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q)
|
||||
const matchT = typeFilter === "all" || c.type === typeFilter
|
||||
return matchQ && matchT
|
||||
})
|
||||
}, [search, typeFilter])
|
||||
}, [search, typeFilter, listData])
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
navigator.clipboard.writeText(value).catch(() => {})
|
||||
@@ -157,14 +174,27 @@ export default function CommunitiesPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs flex items-center gap-1.5 -mt-1",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||
{error
|
||||
? `EvoBGP: ${error}`
|
||||
: snapshot?.fetchedAt
|
||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── summary ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Всего communities", value: String(COMMUNITIES.length) },
|
||||
{ label: "Активных", value: String(COMMUNITIES.filter(c => c.enabled).length) },
|
||||
{ label: "Стандартных", value: String(COMMUNITIES.filter(c => c.type === "standard").length) },
|
||||
{ label: "Использует фильтры",value: String(new Set(COMMUNITIES.flatMap(c => c.filterIds)).size) },
|
||||
{ label: "Всего communities", value: String(listData.length) },
|
||||
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) },
|
||||
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
|
||||
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
|
||||
].map(({ label, value }) => (
|
||||
<Card key={label}>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
|
||||
+654
-43
@@ -1,3 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
@@ -5,10 +10,43 @@ import { StatusBadge } from "@/components/status-badge"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
||||
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
||||
import { servers, pingProbes, systemEvents, dashLatency, traffic } from "@/lib/data"
|
||||
import {
|
||||
servers as mockServers,
|
||||
pingProbes,
|
||||
systemEvents,
|
||||
dashLatency,
|
||||
traffic,
|
||||
serverFilterRulesets,
|
||||
} from "@/lib/data"
|
||||
import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const err = await res.json() as { error?: string }
|
||||
message = err.error ?? message
|
||||
} catch {
|
||||
const text = await res.text().catch(() => "")
|
||||
if (text) message = text
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label, value, unit, delta, deltaDir, spark, sparkColor,
|
||||
@@ -25,7 +63,7 @@ function StatCard({
|
||||
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
{delta && (
|
||||
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-emerald-600 dark:text-emerald-400" : deltaDir === "down" ? "text-red-500 dark:text-red-400" : "text-muted-foreground"}`}>
|
||||
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
|
||||
{delta}
|
||||
</p>
|
||||
)}
|
||||
@@ -39,7 +77,477 @@ function StatCard({
|
||||
)
|
||||
}
|
||||
|
||||
function formatLossPct(loss: number): string {
|
||||
if (!Number.isFinite(loss)) return "—"
|
||||
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function fmtIntRu(n: number): string {
|
||||
return n.toLocaleString("ru-RU")
|
||||
}
|
||||
|
||||
interface LiveKpiSnapshot {
|
||||
filters: { ruleTotal: number; serversWithRules: number } | null
|
||||
bgp: { prefixSum: number; establishedCount: number } | null
|
||||
}
|
||||
|
||||
/** Синхронизируется со страницей мониторинга (mock) */
|
||||
const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids"
|
||||
const UPTIME_PROBES_CHANGED = "mm:uptime-probes-changed"
|
||||
|
||||
function readMockDashboardStarIds(): Set<string> {
|
||||
if (typeof window === "undefined") return new Set()
|
||||
try {
|
||||
const raw = localStorage.getItem(MOCK_DASH_STARS_LS)
|
||||
const arr = raw ? (JSON.parse(raw) as unknown) : []
|
||||
return new Set(Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [])
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
/** Совпадает с эталоном uptime / servers */
|
||||
function TypeChip({ type }: { type: ServerType }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||
type === "home-router"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: type === "jump-host"
|
||||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}>
|
||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface BackendServerRow {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
site: string
|
||||
country: string
|
||||
asn: string
|
||||
type: ServerType
|
||||
enabled: boolean
|
||||
status: "online" | "offline" | null
|
||||
latency: number | null
|
||||
os: string | null
|
||||
model: string | null
|
||||
sessions?: number
|
||||
}
|
||||
|
||||
function mapBackendToServer(s: BackendServerRow): Server {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: s.model ?? "—",
|
||||
os: s.os ?? "—",
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
asn: s.asn,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
status: (s.status ?? "offline") as ServerStatus,
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: s.sessions ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
|
||||
const srv = catalog.find(s => s.id === probe.srcServerId)
|
||||
const iface = (probe.srcInterface ?? "").trim() || "auto"
|
||||
|
||||
if (!srv) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status="offline" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium text-muted-foreground truncate">
|
||||
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
|
||||
</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
|
||||
{iface}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status={srv.status} pulse={srv.status === "online"} />
|
||||
</span>
|
||||
<div className="flex gap-2 min-w-0 flex-1">
|
||||
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
|
||||
<span className="font-mono tabular-nums">{iface}</span>
|
||||
{srv.site && srv.site !== "—" && (
|
||||
<span className="text-muted-foreground/90"> · {srv.site}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const pathname = usePathname()
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
|
||||
const [liveServersResolved, setLiveServersResolved] = useState<Server[] | null>(null)
|
||||
const [liveKpi, setLiveKpi] = useState<LiveKpiSnapshot | null>(null)
|
||||
const [probesLoading, setProbesLoading] = useState(false)
|
||||
const [probesError, setProbesError] = useState<string | null>(null)
|
||||
|
||||
const probeServerCatalog = useMemo(() => {
|
||||
if (!isLive) return mockServers
|
||||
return liveServersResolved ?? []
|
||||
}, [isLive, liveServersResolved])
|
||||
|
||||
const fetchProbes = useCallback(async (silent: boolean) => {
|
||||
if (!isLive) return
|
||||
if (!silent) setProbesLoading(true)
|
||||
try {
|
||||
const overview = await apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h")
|
||||
setLiveProbes(overview.probes)
|
||||
setProbesError(null)
|
||||
try {
|
||||
const backendServers = await apiFetch<BackendServerRow[]>("/api/servers")
|
||||
setLiveServersResolved(backendServers.map(mapBackendToServer))
|
||||
} catch {
|
||||
setLiveServersResolved([])
|
||||
}
|
||||
|
||||
const [fr, br] = await Promise.allSettled([
|
||||
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
|
||||
apiFetch<Array<{ state?: string; prefixesRx?: number }>>("/api/bgp/sessions"),
|
||||
])
|
||||
|
||||
let filtersPart: LiveKpiSnapshot["filters"] = null
|
||||
if (fr.status === "fulfilled") {
|
||||
const rs = fr.value.rulesets ?? []
|
||||
const ruleTotal = rs.reduce((n, x) => n + (Array.isArray(x.rules) ? x.rules.length : 0), 0)
|
||||
const serversWithRules = rs.filter((x) => Array.isArray(x.rules) && x.rules.length > 0).length
|
||||
filtersPart = { ruleTotal, serversWithRules }
|
||||
}
|
||||
|
||||
let bgpPart: LiveKpiSnapshot["bgp"] = null
|
||||
if (br.status === "fulfilled") {
|
||||
let prefixSum = 0
|
||||
let establishedCount = 0
|
||||
for (const s of br.value) {
|
||||
const st = String(s.state ?? "")
|
||||
if (/established/i.test(st)) {
|
||||
establishedCount += 1
|
||||
prefixSum += Number(s.prefixesRx ?? 0)
|
||||
}
|
||||
}
|
||||
bgpPart = { prefixSum, establishedCount }
|
||||
}
|
||||
|
||||
setLiveKpi({ filters: filtersPart, bgp: bgpPart })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Не удалось загрузить пробы"
|
||||
setProbesError(msg)
|
||||
setLiveKpi(null)
|
||||
if (!silent) {
|
||||
setLiveProbes(null)
|
||||
setLiveServersResolved(null)
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setProbesLoading(false)
|
||||
}
|
||||
}, [apiFetch, isLive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => {
|
||||
setLiveProbes(null)
|
||||
setLiveServersResolved(null)
|
||||
setLiveKpi(null)
|
||||
setProbesError(null)
|
||||
})
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
void fetchProbes(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, fetchProbes])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
const id = setInterval(() => {
|
||||
queueMicrotask(() => { void fetchProbes(true) })
|
||||
}, 60_000)
|
||||
return () => clearInterval(id)
|
||||
}, [isLive, fetchProbes])
|
||||
|
||||
/** Live: после звезды на мониторинге данные на дашборде должны подтянуться сразу (раньше только при монтировании и раз в 60 с). */
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
queueMicrotask(() => { void fetchProbes(true) })
|
||||
}, [pathname, isLive, fetchProbes])
|
||||
|
||||
const [mockDashEpoch, setMockDashEpoch] = useState(0)
|
||||
useEffect(() => {
|
||||
const bumpMock = () => setMockDashEpoch((x) => x + 1)
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === MOCK_DASH_STARS_LS) bumpMock()
|
||||
}
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === "visible") bumpMock()
|
||||
}
|
||||
const onUptimeChanged = () => {
|
||||
bumpMock()
|
||||
if (isLive) void fetchProbes(true)
|
||||
}
|
||||
window.addEventListener(UPTIME_PROBES_CHANGED, onUptimeChanged)
|
||||
window.addEventListener("storage", onStorage)
|
||||
document.addEventListener("visibilitychange", onVis)
|
||||
return () => {
|
||||
window.removeEventListener(UPTIME_PROBES_CHANGED, onUptimeChanged)
|
||||
window.removeEventListener("storage", onStorage)
|
||||
document.removeEventListener("visibilitychange", onVis)
|
||||
}
|
||||
}, [isLive, fetchProbes])
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setMockDashEpoch((x) => x + 1))
|
||||
}, [pathname])
|
||||
|
||||
const mockActiveProbes = useMemo(() => {
|
||||
void mockDashEpoch
|
||||
const stars = readMockDashboardStarIds()
|
||||
return pingProbes.filter(p => p.enabled && stars.has(p.id))
|
||||
}, [mockDashEpoch])
|
||||
|
||||
const activeProbesTable = useMemo(() => {
|
||||
if (!isLive) return mockActiveProbes
|
||||
if (liveProbes === null && probesLoading) return []
|
||||
if (liveProbes === null) return []
|
||||
return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true)
|
||||
}, [isLive, liveProbes, probesLoading, mockActiveProbes])
|
||||
|
||||
const probesSubtitle = useMemo(() => {
|
||||
if (!isLive) {
|
||||
const starred = mockActiveProbes.length
|
||||
return starred > 0
|
||||
? `${starred} на дашборде · мок-данные · отметьте звезды в мониторинге`
|
||||
: "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга"
|
||||
}
|
||||
if (probesError && liveProbes === null) return probesError
|
||||
const n = activeProbesTable.length
|
||||
return n > 0
|
||||
? `${n} на дашборде · последний час (API)`
|
||||
: "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга"
|
||||
}, [isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
|
||||
|
||||
/** Карточка «Состояние серверов»: в Live — `/api/servers` (тот же запрос, что и для каталога проб). */
|
||||
const serverStatusModel = useMemo(() => {
|
||||
if (!isLive) {
|
||||
return {
|
||||
kind: "mock" as const,
|
||||
servers: mockServers.slice(0, 5),
|
||||
subtitle: `${mockServers.length} узлов MikroTik · демо`,
|
||||
}
|
||||
}
|
||||
if (liveServersResolved === null) {
|
||||
if (probesLoading) {
|
||||
return { kind: "loading" as const, subtitle: "Загрузка…" }
|
||||
}
|
||||
return {
|
||||
kind: "unavailable" as const,
|
||||
subtitle: probesError ? "Нет данных · проверьте backend" : "Нет данных о серверах",
|
||||
}
|
||||
}
|
||||
const servers = [...liveServersResolved].sort((a, b) => {
|
||||
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1
|
||||
return a.name.localeCompare(b.name, "ru")
|
||||
})
|
||||
const onlineN = servers.filter((s) => s.status === "online").length
|
||||
return {
|
||||
kind: "live" as const,
|
||||
servers,
|
||||
subtitle: `${servers.length} узлов · ${onlineN} онлайн · API`,
|
||||
}
|
||||
}, [isLive, liveServersResolved, probesLoading, probesError])
|
||||
|
||||
const latencyChartBlock = useMemo(() => {
|
||||
if (!isLive) {
|
||||
return {
|
||||
kind: "mock" as const,
|
||||
series: dashLatency,
|
||||
labels: undefined as Record<string, string> | undefined,
|
||||
subtitle:
|
||||
"Последние 60 минут · ping от монитора → серверы MikroTik · демо",
|
||||
}
|
||||
}
|
||||
if (liveProbes === null && probesLoading) {
|
||||
return { kind: "loading" as const }
|
||||
}
|
||||
if (!liveProbes?.length) {
|
||||
return {
|
||||
kind: "empty" as const,
|
||||
message: "Нет данных проб. Откройте мониторинг и проверьте сборщик uptime.",
|
||||
}
|
||||
}
|
||||
const catalog = liveServersResolved ?? []
|
||||
const { series, labels } = buildLatencySeriesByProbeSource(
|
||||
liveProbes,
|
||||
catalog,
|
||||
{ maxServers: 8, points: 60 },
|
||||
)
|
||||
if (Object.keys(series).length === 0) {
|
||||
return {
|
||||
kind: "empty" as const,
|
||||
message:
|
||||
"Нет включённых проб с историей RTT. Включите пробы на странице «Мониторинг».",
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "live" as const,
|
||||
series,
|
||||
labels,
|
||||
subtitle:
|
||||
"Средний RTT по источникам проб · окно 1 ч · до 8 узлов · API",
|
||||
}
|
||||
}, [isLive, liveProbes, probesLoading, liveServersResolved])
|
||||
|
||||
const dashboardKpi = useMemo(() => {
|
||||
const sparkSrv = [5, 5, 6, 6, 5, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 6]
|
||||
const sparkFlt = [3, 4, 4, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5]
|
||||
const sparkBgp = [7800, 7900, 8000, 8100, 8050, 8120, 8200, 8240, 8300, 8350, 8380, 8400, 8420, 8430, 8432]
|
||||
const sparkAlt = [1, 2, 2, 3, 3, 4, 5, 4, 4, 4, 3, 3, 4, 4, 4]
|
||||
|
||||
if (!isLive) {
|
||||
const totalSrv = mockServers.length
|
||||
const onlineSrv = mockServers.filter((s) => s.status === "online").length
|
||||
const ruleTotal = serverFilterRulesets.reduce((n, rs) => n + rs.rules.length, 0)
|
||||
const serversWithRules = serverFilterRulesets.filter((rs) => rs.rules.length > 0).length
|
||||
const en = pingProbes.filter((p) => p.enabled)
|
||||
const down = en.filter((p) => p.status === "down").length
|
||||
const warn = en.filter((p) => p.status === "warn").length
|
||||
return {
|
||||
servers: {
|
||||
value: String(onlineSrv),
|
||||
unit: `/ ${totalSrv}`,
|
||||
delta: `${totalSrv - onlineSrv} offline`,
|
||||
deltaDir: onlineSrv < totalSrv ? ("down" as const) : ("up" as const),
|
||||
spark: sparkSrv,
|
||||
sparkColor: "var(--chart-line-1)",
|
||||
},
|
||||
filters: {
|
||||
value: String(ruleTotal),
|
||||
unit: `/ ${serverFilterRulesets.length}`,
|
||||
delta: `${serversWithRules} серверов с правилами · демо`,
|
||||
deltaDir: "up" as const,
|
||||
spark: sparkFlt,
|
||||
sparkColor: "var(--chart-line-2)",
|
||||
},
|
||||
bgp: {
|
||||
value: "8 432",
|
||||
unit: undefined as string | undefined,
|
||||
delta: "демо · не из API",
|
||||
deltaDir: "up" as const,
|
||||
spark: sparkBgp,
|
||||
sparkColor: "var(--chart-line-4)",
|
||||
},
|
||||
alerts: {
|
||||
value: String(down + warn),
|
||||
unit: undefined as string | undefined,
|
||||
delta: `${down} down · ${warn} warn`,
|
||||
deltaDir: down + warn > 0 ? ("down" as const) : ("up" as const),
|
||||
spark: sparkAlt,
|
||||
sparkColor: "var(--chart-5)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const loadingBlock = probesLoading && liveProbes === null
|
||||
const srvList = liveServersResolved ?? []
|
||||
const totalSrv = srvList.length
|
||||
const onlineSrv = srvList.filter((s) => s.status === "online").length
|
||||
const offlineSrv = Math.max(0, totalSrv - onlineSrv)
|
||||
|
||||
const en = liveProbes?.filter((p) => p.enabled) ?? []
|
||||
const down = en.filter((p) => p.status === "down").length
|
||||
const warn = en.filter((p) => p.status === "warn").length
|
||||
|
||||
const filters = liveKpi?.filters
|
||||
const bgp = liveKpi?.bgp
|
||||
|
||||
return {
|
||||
servers: {
|
||||
value: loadingBlock ? "—" : String(onlineSrv),
|
||||
unit: loadingBlock ? undefined : `/ ${totalSrv}`,
|
||||
delta: loadingBlock
|
||||
? "Загрузка…"
|
||||
: offlineSrv > 0
|
||||
? `${offlineSrv} offline · опрос API`
|
||||
: totalSrv > 0
|
||||
? "Все узлы online в последнем опросе"
|
||||
: "Нет серверов в базе",
|
||||
deltaDir: offlineSrv > 0 ? ("down" as const) : ("up" as const),
|
||||
spark: undefined as number[] | undefined,
|
||||
sparkColor: "var(--chart-line-1)",
|
||||
},
|
||||
filters: {
|
||||
value: loadingBlock ? "—" : filters ? String(filters.ruleTotal) : "—",
|
||||
unit:
|
||||
filters && filters.serversWithRules > 0
|
||||
? `на ${filters.serversWithRules} серв.`
|
||||
: undefined,
|
||||
delta: loadingBlock
|
||||
? "Загрузка…"
|
||||
: filters
|
||||
? `${filters.serversWithRules} серверов с правилами · /api/filters/rules`
|
||||
: "Не удалось загрузить правила",
|
||||
deltaDir: filters ? ("up" as const) : ("down" as const),
|
||||
spark: undefined as number[] | undefined,
|
||||
sparkColor: "var(--chart-line-2)",
|
||||
},
|
||||
bgp: {
|
||||
value: loadingBlock ? "—" : bgp ? fmtIntRu(bgp.prefixSum) : "—",
|
||||
unit: undefined as string | undefined,
|
||||
delta: loadingBlock
|
||||
? "Загрузка…"
|
||||
: bgp
|
||||
? `Σ prefixes Rx · ${bgp.establishedCount} Established · /api/bgp/sessions`
|
||||
: "Не удалось загрузить BGP",
|
||||
deltaDir: (bgp ? "up" : "down") as "up" | "down",
|
||||
spark: undefined as number[] | undefined,
|
||||
sparkColor: "var(--chart-line-4)",
|
||||
},
|
||||
alerts: {
|
||||
value: loadingBlock ? "—" : String(down + warn),
|
||||
unit: undefined as string | undefined,
|
||||
delta: loadingBlock
|
||||
? "Загрузка…"
|
||||
: `${down} down · ${warn} warn · включённые пробы`,
|
||||
deltaDir: down + warn > 0 ? ("down" as const) : ("up" as const),
|
||||
spark: undefined as number[] | undefined,
|
||||
sparkColor: "var(--chart-5)",
|
||||
},
|
||||
}
|
||||
}, [isLive, liveServersResolved, liveKpi, liveProbes, probesLoading])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -57,28 +565,40 @@ export default function DashboardPage() {
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Серверы онлайн" value="6" unit="/ 7"
|
||||
delta="+1 со вчерашнего" deltaDir="up"
|
||||
spark={[5,5,6,6,5,6,6,6,7,6,6,6,6,6,6,6,6]}
|
||||
sparkColor="var(--chart-line-1)"
|
||||
label="Серверы онлайн"
|
||||
value={dashboardKpi.servers.value}
|
||||
unit={dashboardKpi.servers.unit}
|
||||
delta={dashboardKpi.servers.delta}
|
||||
deltaDir={dashboardKpi.servers.deltaDir}
|
||||
spark={dashboardKpi.servers.spark}
|
||||
sparkColor={dashboardKpi.servers.sparkColor}
|
||||
/>
|
||||
<StatCard
|
||||
label="Активные фильтры" value="5" unit="/ 6"
|
||||
delta="2.4к доменов синхронизировано" deltaDir="up"
|
||||
spark={[3,4,4,5,4,5,5,5,5,5,5,5,5,5,5]}
|
||||
sparkColor="var(--chart-line-2)"
|
||||
label="Активные фильтры"
|
||||
value={dashboardKpi.filters.value}
|
||||
unit={dashboardKpi.filters.unit}
|
||||
delta={dashboardKpi.filters.delta}
|
||||
deltaDir={dashboardKpi.filters.deltaDir}
|
||||
spark={dashboardKpi.filters.spark}
|
||||
sparkColor={dashboardKpi.filters.sparkColor}
|
||||
/>
|
||||
<StatCard
|
||||
label="BGP-префиксы" value="8 432"
|
||||
delta="+412 в последнем обновлении" deltaDir="up"
|
||||
spark={[7800,7900,8000,8100,8050,8120,8200,8240,8300,8350,8380,8400,8420,8430,8432]}
|
||||
sparkColor="var(--chart-line-4)"
|
||||
label="BGP-префиксы"
|
||||
value={dashboardKpi.bgp.value}
|
||||
unit={dashboardKpi.bgp.unit}
|
||||
delta={dashboardKpi.bgp.delta}
|
||||
deltaDir={dashboardKpi.bgp.deltaDir}
|
||||
spark={dashboardKpi.bgp.spark}
|
||||
sparkColor={dashboardKpi.bgp.sparkColor}
|
||||
/>
|
||||
<StatCard
|
||||
label="Активные алерты" value="4"
|
||||
delta="2 критических · 2 предупреждения" deltaDir="down"
|
||||
spark={[1,2,2,3,3,4,5,4,4,4,3,3,4,4,4]}
|
||||
sparkColor="var(--chart-5)"
|
||||
label="Активные алерты"
|
||||
value={dashboardKpi.alerts.value}
|
||||
unit={dashboardKpi.alerts.unit}
|
||||
delta={dashboardKpi.alerts.delta}
|
||||
deltaDir={dashboardKpi.alerts.deltaDir}
|
||||
spark={dashboardKpi.alerts.spark}
|
||||
sparkColor={dashboardKpi.alerts.sparkColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -89,12 +609,33 @@ export default function DashboardPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Задержка до серверов</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Последние 60 минут · ping от монитора → серверы MikroTik</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{latencyChartBlock.kind === "mock" && latencyChartBlock.subtitle}
|
||||
{latencyChartBlock.kind === "live" && latencyChartBlock.subtitle}
|
||||
{latencyChartBlock.kind === "loading" && "Загрузка…"}
|
||||
{latencyChartBlock.kind === "empty" && "Нет серии RTT для графика"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-3">
|
||||
<LatencyChart series={dashLatency} />
|
||||
{latencyChartBlock.kind === "loading" && (
|
||||
<div
|
||||
className="w-full rounded-md bg-muted/50 animate-pulse"
|
||||
style={{ height: 220 }}
|
||||
/>
|
||||
)}
|
||||
{latencyChartBlock.kind === "empty" && (
|
||||
<p className="text-sm text-muted-foreground py-10 text-center px-4">
|
||||
{latencyChartBlock.message}
|
||||
</p>
|
||||
)}
|
||||
{(latencyChartBlock.kind === "mock" || latencyChartBlock.kind === "live") && (
|
||||
<LatencyChart
|
||||
series={latencyChartBlock.series}
|
||||
labels={latencyChartBlock.labels}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -111,9 +652,9 @@ export default function DashboardPage() {
|
||||
{systemEvents.map((e) => (
|
||||
<div key={e.id} className="grid grid-cols-[20px_1fr_auto] gap-3 px-5 py-3 items-start">
|
||||
<div className="mt-0.5">
|
||||
{e.sev === "destructive" && <AlertCircleIcon className="size-4 text-red-500" />}
|
||||
{e.sev === "warning" && <AlertTriangleIcon className="size-4 text-amber-500" />}
|
||||
{e.sev === "info" && <InfoIcon className="size-4 text-blue-500" />}
|
||||
{e.sev === "destructive" && <AlertCircleIcon className="size-4 text-destructive" />}
|
||||
{e.sev === "warning" && <AlertTriangleIcon className="size-4 text-[var(--status-degraded)]" />}
|
||||
{e.sev === "info" && <InfoIcon className="size-4 text-[var(--chart-1)]" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium leading-tight">{e.title}</p>
|
||||
@@ -138,7 +679,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-foreground/80 rounded inline-block" />RX 318 Мбит/с</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-blue-500 rounded inline-block" />TX 244 Мбит/с</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-[var(--chart-tx)] rounded inline-block" />TX 244 Мбит/с</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -149,26 +690,65 @@ export default function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base">Состояние серверов</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">7 узлов MikroTik</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 truncate" title={serverStatusModel.subtitle}>
|
||||
{serverStatusModel.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">Управление →</Button>
|
||||
<Link
|
||||
href="/servers"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs shrink-0")}
|
||||
>
|
||||
Управление →
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-0">
|
||||
<div className="divide-y divide-border">
|
||||
{servers.slice(0, 5).map((s) => (
|
||||
<div key={s.id} className="grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center">
|
||||
<StatusDot status={s.status} pulse />
|
||||
<div>
|
||||
<p className="text-[13px] font-medium leading-tight">{s.name}</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground flex items-center gap-1">
|
||||
<Flag code={s.country} className="not-mono" />{s.host} · {s.site} · {s.asn}
|
||||
{serverStatusModel.kind === "loading" && (
|
||||
<>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} className="grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center">
|
||||
<div className="size-2 rounded-full bg-muted animate-pulse" />
|
||||
<div className="space-y-2 min-w-0">
|
||||
<div className="h-4 rounded bg-muted/80 animate-pulse max-w-[180px]" />
|
||||
<div className="h-3 rounded bg-muted/60 animate-pulse max-w-[240px]" />
|
||||
</div>
|
||||
<div className="h-4 w-12 rounded bg-muted/60 animate-pulse" />
|
||||
<div className="h-5 w-16 rounded bg-muted/60 animate-pulse justify-self-end" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{serverStatusModel.kind === "unavailable" && (
|
||||
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
{serverStatusModel.subtitle}
|
||||
</div>
|
||||
)}
|
||||
{(serverStatusModel.kind === "mock" || serverStatusModel.kind === "live") && serverStatusModel.servers.length === 0 && (
|
||||
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
Нет серверов в базе. Добавьте узел на странице «Серверы».
|
||||
</div>
|
||||
)}
|
||||
{(serverStatusModel.kind === "mock" || serverStatusModel.kind === "live") && serverStatusModel.servers.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center",
|
||||
!s.enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<StatusDot status={s.status} pulse={s.status === "online"} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium leading-tight truncate">{s.name}</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground flex items-center gap-1 truncate">
|
||||
<Flag code={s.country} className="not-mono shrink-0" />
|
||||
<span className="truncate">{s.host} · {s.site} · {s.asn}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs font-mono ${s.latency == null ? "text-red-500" : s.latency > 60 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||||
<span className={`text-xs font-mono tabular-nums shrink-0 ${s.latency == null ? "text-[var(--status-offline-fg)]" : s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground"}`}>
|
||||
{s.latency == null ? "недоступен" : `${s.latency}мс`}
|
||||
</span>
|
||||
<StatusBadge status={s.status} />
|
||||
@@ -185,9 +765,20 @@ export default function DashboardPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Активные пробы</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">8 ping-зондов · опрос раз в секунду</p>
|
||||
<p className={cn(
|
||||
"text-sm mt-0.5",
|
||||
probesError && isLive ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{probesSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">Открыть монитор →</Button>
|
||||
<Link
|
||||
href="/uptime"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}
|
||||
>
|
||||
Открыть монитор →
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-0">
|
||||
@@ -195,7 +786,8 @@ export default function DashboardPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-2.5">Проба</th>
|
||||
<th className="text-left font-medium px-5 py-2.5 w-[min(280px,32vw)]">Источник</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Проба</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Цель</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Фильтр</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">RTT</th>
|
||||
@@ -205,11 +797,21 @@ export default function DashboardPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{pingProbes.map((p) => {
|
||||
{isLive && probesLoading && liveProbes === null && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-5 py-6">
|
||||
<div className="h-10 rounded-md bg-muted/50 animate-pulse max-w-md mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.map((p) => {
|
||||
const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)"
|
||||
return (
|
||||
<tr key={p.id} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-2.5 font-medium">{p.name}</td>
|
||||
<td className="px-5 py-2.5 align-top">
|
||||
<ProbeSourceCell probe={p} catalog={probeServerCatalog} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium">{p.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{p.target}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
|
||||
@@ -218,7 +820,7 @@ export default function DashboardPage() {
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-right">{p.rtt == null ? "—" : `${p.rtt} мс`}</td>
|
||||
<td className={`px-4 py-2.5 font-mono text-right ${p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||||
{p.loss}%
|
||||
{formatLossPct(p.loss)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||||
@@ -229,6 +831,15 @@ export default function DashboardPage() {
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
{isLive && probesError
|
||||
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
|
||||
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { domains } from "@/lib/data"
|
||||
import { domains as mockDomains } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function DomainsPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!useEvoCatalog) return mockDomains
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.domains ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -26,9 +41,22 @@ export default function DomainsPage() {
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik
|
||||
</p>
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs mt-2 flex items-center gap-1.5",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||
{error
|
||||
? `EvoBGP: ${error}`
|
||||
: snapshot?.fetchedAt
|
||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={domains}
|
||||
data={rows}
|
||||
searchPlaceholder="Поиск по домену…"
|
||||
searchKeys={["domain", "asn", "filter"]}
|
||||
columns={[
|
||||
|
||||
+701
-86
File diff suppressed because it is too large
Load Diff
@@ -404,9 +404,8 @@ function NativeSelect({ value, onChange, children, className }: {
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(
|
||||
"h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none",
|
||||
"h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"dark:bg-input/30",
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
@@ -647,7 +646,7 @@ function ExportSheet({ open, onClose, rules }: {
|
||||
// Derived from open — new timestamp each time modal opens, undefined when closed
|
||||
const timestamp = useMemo(
|
||||
() => open ? new Date().toLocaleString("ru") : undefined,
|
||||
[open], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
[open],
|
||||
)
|
||||
|
||||
const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp])
|
||||
@@ -973,16 +972,16 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
||||
const [addForm, setAddForm]= useState<Omit<ScenarioRule, "id">>(DEFAULT_SRULE)
|
||||
|
||||
// Reset form fields each time the sheet opens — standard modal initialization pattern
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setName(initial?.name ?? ""); setDesc(initial?.description ?? "")
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
|
||||
setPkt2(initial?.packet ?? DEFAULT_PKT); setRules(initial?.rules ?? [])
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
|
||||
setAddForm(DEFAULT_SRULE); setAddOpen(false)
|
||||
}, [open])
|
||||
}, [open, initial])
|
||||
|
||||
const setP = <K extends keyof PacketDef>(k: K, v: PacketDef[K]) =>
|
||||
setPkt2(p => ({ ...p, [k]: v }))
|
||||
|
||||
+228
-36
@@ -1,9 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { greTunnels, grePools, servers } from "@/lib/data"
|
||||
import type { GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion } from "@/lib/data"
|
||||
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
|
||||
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -21,6 +23,8 @@ import {
|
||||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
||||
DatabaseIcon,
|
||||
AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||
@@ -42,12 +46,9 @@ const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
|
||||
down: { label: "Down", dot: "bg-red-500" },
|
||||
}
|
||||
|
||||
const serverById = Object.fromEntries(servers.map((s) => [s.id, s]))
|
||||
const poolById = Object.fromEntries(grePools.map((p) => [p.id, p]))
|
||||
|
||||
// ─── RouterOS code generator ─────────────────────────────────────────────────
|
||||
|
||||
function generateRosCommands(t: GreTunnel): string {
|
||||
function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>): string {
|
||||
const lines: string[] = []
|
||||
const srv = serverById[t.serverId]
|
||||
|
||||
@@ -188,6 +189,74 @@ function SegmentedControl<T extends string>({ value, onChange, options }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Live API (как на /filters) ─────────────────────────────────────────────
|
||||
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
type: "jump-host" | "exit-node" | "home-router"
|
||||
site: string
|
||||
country: string
|
||||
asn: string
|
||||
enabled: boolean
|
||||
status: "online" | "offline" | null
|
||||
latency: number | null
|
||||
}
|
||||
|
||||
interface GreTunnelsApiResponse {
|
||||
tunnels: GreTunnel[]
|
||||
}
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const finalRes = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!finalRes.ok) {
|
||||
const err = await finalRes.json().catch(() => ({ error: finalRes.statusText })) as { error?: string }
|
||||
throw new Error(err.error ?? finalRes.statusText)
|
||||
}
|
||||
return finalRes.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
function mapBackendToServer(s: BackendServer): Server {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: "—",
|
||||
os: "—",
|
||||
site: s.site,
|
||||
country: s.country || "UN",
|
||||
asn: s.asn,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
status: (s.status ?? "offline") as Server["status"],
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function derivePoolsFromTunnels(tunnels: GreTunnel[]): GrePool[] {
|
||||
const ids = [...new Set(tunnels.map((t) => t.poolId).filter(Boolean))]
|
||||
return ids.map((id) => {
|
||||
const list = tunnels.filter((t) => t.poolId === id)
|
||||
const allocated = list.length
|
||||
return {
|
||||
id,
|
||||
name: id === "live" ? "С устройств (опрос)" : id,
|
||||
cidr: "—",
|
||||
allocated,
|
||||
total: Math.max(allocated, 1),
|
||||
comment: "По poolId из данных GRE",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── default form states ─────────────────────────────────────────────────────
|
||||
|
||||
const defaultTunnelForm = {
|
||||
@@ -211,6 +280,17 @@ type PageTab = "tunnels" | "pools"
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function GrePage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
|
||||
const [dataLoading, setDataLoading] = useState(false)
|
||||
const [dataError, setDataError] = useState<string | null>(null)
|
||||
const [syncJhBusy, setSyncJhBusy] = useState(false)
|
||||
const [syncJhMessage, setSyncJhMessage] = useState<string | null>(null)
|
||||
|
||||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||
const [search, setSearch] = useState("")
|
||||
@@ -226,8 +306,94 @@ export default function GrePage() {
|
||||
const setT = <K extends keyof typeof defaultTunnelForm>(k: K, v: (typeof defaultTunnelForm)[K]) =>
|
||||
setTForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
setDataLoading(true)
|
||||
setDataError(null)
|
||||
setSyncJhMessage(null)
|
||||
try {
|
||||
const [backendServers, greRes] = await Promise.all([
|
||||
apiFetch<BackendServer[]>("/api/servers"),
|
||||
apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels"),
|
||||
])
|
||||
setLiveServers(backendServers.map(mapBackendToServer))
|
||||
setLiveTunnels(greRes.tunnels)
|
||||
} catch (e) {
|
||||
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||
setLiveServers([])
|
||||
setLiveTunnels([])
|
||||
} finally {
|
||||
setDataLoading(false)
|
||||
}
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => {
|
||||
setLiveServers([])
|
||||
setLiveTunnels([])
|
||||
setDataError(null)
|
||||
})
|
||||
return
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void loadLive()
|
||||
})
|
||||
}, [isLive, loadLive])
|
||||
|
||||
const displayTunnels = isLive ? liveTunnels : mockGreTunnels
|
||||
const displayServers = isLive ? liveServers : mockServers
|
||||
const displayPools = useMemo(
|
||||
() => (isLive ? derivePoolsFromTunnels(displayTunnels) : mockGrePools),
|
||||
[isLive, displayTunnels],
|
||||
)
|
||||
|
||||
const serverById = useMemo(
|
||||
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||
[displayServers],
|
||||
)
|
||||
const poolById = useMemo(
|
||||
() => Object.fromEntries(displayPools.map((p) => [p.id, p])),
|
||||
[displayPools],
|
||||
)
|
||||
|
||||
const syncJhToDb = useCallback(async () => {
|
||||
if (!isLive || syncJhBusy) return
|
||||
const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled)
|
||||
if (jh.length === 0) {
|
||||
setSyncJhMessage("Нет включённых Jump Host в списке серверов")
|
||||
return
|
||||
}
|
||||
setSyncJhBusy(true)
|
||||
setSyncJhMessage(null)
|
||||
const errors: string[] = []
|
||||
try {
|
||||
for (const s of jh) {
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ serverId: s.id }),
|
||||
})
|
||||
} catch (e) {
|
||||
errors.push(`${s.name}: ${e instanceof Error ? e.message : "ошибка"}`)
|
||||
}
|
||||
}
|
||||
const fresh = await apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels")
|
||||
setLiveTunnels(fresh.tunnels)
|
||||
if (errors.length) {
|
||||
setSyncJhMessage(`Синхронизировано JH: ${jh.length - errors.length}/${jh.length}. Ошибки: ${errors.join("; ")}`)
|
||||
} else {
|
||||
setSyncJhMessage(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`)
|
||||
}
|
||||
} catch (e) {
|
||||
setSyncJhMessage(e instanceof Error ? e.message : "Ошибка после синхронизации")
|
||||
} finally {
|
||||
setSyncJhBusy(false)
|
||||
}
|
||||
}, [isLive, syncJhBusy, apiFetch, displayServers])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return greTunnels.filter((t) => {
|
||||
return displayTunnels.filter((t) => {
|
||||
if (tabFilter === "up" && t.status !== "up") return false
|
||||
if (tabFilter === "ipsec" && !t.ipsec) return false
|
||||
if (tabFilter === "plain" && t.ipsec) return false
|
||||
@@ -240,16 +406,16 @@ export default function GrePage() {
|
||||
serverById[t.serverId]?.name.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [tabFilter, search])
|
||||
}, [tabFilter, search, displayTunnels, serverById])
|
||||
|
||||
const upCount = greTunnels.filter((t) => t.status === "up").length
|
||||
const ipsecCount = greTunnels.filter((t) => t.ipsec).length
|
||||
const upCount = displayTunnels.filter((t) => t.status === "up").length
|
||||
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
|
||||
|
||||
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
|
||||
{ value: "all", label: "Все", count: greTunnels.length },
|
||||
{ value: "all", label: "Все", count: displayTunnels.length },
|
||||
{ value: "up", label: "Активные", count: upCount },
|
||||
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: greTunnels.length - ipsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
|
||||
]
|
||||
|
||||
function handleCopy(code: string) {
|
||||
@@ -265,7 +431,26 @@ export default function GrePage() {
|
||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm"><RefreshCwIcon className="size-4" />Обновить статус</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLive() }}
|
||||
disabled={!isLive || dataLoading}
|
||||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void syncJhToDb() }}
|
||||
disabled={!isLive || syncJhBusy || dataLoading}
|
||||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
||||
>
|
||||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
||||
JH → БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить туннель
|
||||
</Button>
|
||||
@@ -276,6 +461,13 @@ export default function GrePage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{(dataError || syncJhMessage) && (
|
||||
<div className={`flex items-start gap-3 rounded-lg border px-4 py-3 text-sm ${dataError ? "bg-destructive/5 border-destructive/30 text-destructive" : "bg-muted/40 border-border text-muted-foreground"}`}>
|
||||
<AlertCircleIcon className="size-5 shrink-0 mt-0.5" />
|
||||
<div>{dataError ?? syncJhMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legacy banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
|
||||
<ShieldCheckIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
@@ -293,10 +485,10 @@ export default function GrePage() {
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего туннелей", value: greTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "IP-пулов", value: grePools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
||||
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||||
@@ -358,11 +550,11 @@ export default function GrePage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((t) => {
|
||||
{filtered.map((t, index) => {
|
||||
const srv = serverById[t.serverId]
|
||||
const pool = poolById[t.poolId]
|
||||
return (
|
||||
<tr key={t.id} className="hover:bg-muted/40 transition-colors">
|
||||
<tr key={`${t.id}:${t.serverId}:${t.name}:${index}`} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<p className="font-medium font-mono text-[13px]">{t.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
@@ -456,7 +648,7 @@ export default function GrePage() {
|
||||
{pageTab === "pools" && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b">
|
||||
<span className="text-sm text-muted-foreground">{grePools.length} пула</span>
|
||||
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
|
||||
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить пул
|
||||
</Button>
|
||||
@@ -475,8 +667,8 @@ export default function GrePage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{grePools.map((pool) => {
|
||||
const pct = Math.round((pool.allocated / pool.total) * 100)
|
||||
{displayPools.map((pool) => {
|
||||
const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0
|
||||
return (
|
||||
<tr key={pool.id} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-3 font-mono text-[13px] font-medium">{pool.name}</td>
|
||||
@@ -516,14 +708,14 @@ export default function GrePage() {
|
||||
<div className="border-t px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{grePools.map((pool) => {
|
||||
const poolTunnels = greTunnels.filter((t) => t.poolId === pool.id)
|
||||
{displayPools.map((pool) => {
|
||||
const poolTunnels = displayTunnels.filter((t) => t.poolId === pool.id)
|
||||
return (
|
||||
<div key={pool.id}>
|
||||
<p className="text-xs font-mono font-medium mb-1.5">{pool.name}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{poolTunnels.map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
|
||||
{poolTunnels.map((t, tunnelIndex) => (
|
||||
<div key={`${t.id}:${t.serverId}:${t.name}:${tunnelIndex}`} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
|
||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[t.status].dot}`} />
|
||||
<span className="font-mono font-medium">{t.name}</span>
|
||||
<span className="text-muted-foreground">{t.localInnerIp} ↔ {t.remoteInnerIp}</span>
|
||||
@@ -574,7 +766,7 @@ export default function GrePage() {
|
||||
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
|
||||
{codePreviewTunnel && (() => {
|
||||
const code = generateRosCommands(codePreviewTunnel)
|
||||
const code = generateRosCommands(codePreviewTunnel, serverById)
|
||||
return (
|
||||
<>
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
@@ -664,9 +856,9 @@ export default function GrePage() {
|
||||
</Field>
|
||||
<Field label="Сервер (MikroTik)" required>
|
||||
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<option value="" disabled>Выбрать сервер…</option>
|
||||
{servers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Комментарий">
|
||||
@@ -692,9 +884,9 @@ export default function GrePage() {
|
||||
<SectionTitle>Внутренний IP</SectionTitle>
|
||||
<Field label="IP-пул" required hint="Из какого пула выделяется /30-блок">
|
||||
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<option value="" disabled>Выбрать пул…</option>
|
||||
{grePools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||||
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -736,20 +928,20 @@ export default function GrePage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Шифрование">
|
||||
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Хеш-алгоритм">
|
||||
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
||||
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
@@ -788,7 +980,7 @@ export default function GrePage() {
|
||||
</div>
|
||||
<Field label="DSCP">
|
||||
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<option value="inherit">inherit</option>
|
||||
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { ipRanges } from "@/lib/data"
|
||||
import { ipRanges as mockIpRanges } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function IpRangesPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
|
||||
/** При включённом EvoBGP в live локальные моки не показываем — только каталог API (или пусто при загрузке/ошибке). */
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!useEvoCatalog) return mockIpRanges
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.ipRanges ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -26,9 +42,22 @@ export default function IpRangesPage() {
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам
|
||||
</p>
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs mt-2 flex items-center gap-1.5",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||
{error
|
||||
? `EvoBGP: ${error}`
|
||||
: snapshot?.fetchedAt
|
||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={ipRanges}
|
||||
data={rows}
|
||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||
searchKeys={["cidr", "asn", "country", "filter"]}
|
||||
columns={[
|
||||
|
||||
@@ -2,15 +2,18 @@ import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { CommandPalette } from "@/components/command-palette"
|
||||
import { DataSourceProvider } from "@/lib/data-source"
|
||||
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DataSourceProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
<EvoBGPProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
</EvoBGPProvider>
|
||||
</DataSourceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
+893
-172
File diff suppressed because it is too large
Load Diff
+42
-28
@@ -345,10 +345,10 @@ function moveInArray<T>(arr: T[], from: number, to: number): T[] {
|
||||
|
||||
function stateClass(state: OspfNeighbor["state"] | BfdSession["state"]) {
|
||||
if (state === "Full" || state === "Up")
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
|
||||
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
|
||||
if (state === "2-Way" || state === "Init")
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25"
|
||||
return "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25"
|
||||
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
|
||||
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
|
||||
}
|
||||
|
||||
function routeTypeClass(type: OspfRoute["type"]) {
|
||||
@@ -573,8 +573,10 @@ function NodeDetailPanel({
|
||||
<span className="size-2 rounded-full shrink-0" style={{ background: theme.stroke }} />
|
||||
<span className="text-xs font-mono font-semibold" style={{ color: theme.stroke }}>{panelState}</span>
|
||||
<div className="flex gap-1.5 ml-auto">
|
||||
{fullCnt > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded-full border border-emerald-500/25 bg-emerald-500/10 text-emerald-400 font-medium">Full ×{fullCnt}</span>}
|
||||
{partCnt > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded-full border border-amber-500/25 bg-amber-500/10 text-amber-400 font-medium">2-Way ×{partCnt}</span>}
|
||||
{fullCnt > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
|
||||
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>Full ×{fullCnt}</span>}
|
||||
{partCnt > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
|
||||
style={{ background: "var(--status-degraded-bg)", color: "var(--status-degraded-fg)" }}>2-Way ×{partCnt}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -630,7 +632,9 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
// Sync with live data when it changes
|
||||
useEffect(() => { setItems(initialItems) }, [initialItems])
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setItems(initialItems))
|
||||
}, [initialItems])
|
||||
|
||||
function showToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 2500) }
|
||||
|
||||
@@ -708,7 +712,8 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-2.5 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-current/20 px-4 py-2.5 text-sm"
|
||||
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
|
||||
<CheckIcon className="size-4 shrink-0" />{toast}
|
||||
</div>
|
||||
)}
|
||||
@@ -760,7 +765,7 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
|
||||
dragging !== item.key && dragOver !== item.key && "hover:bg-muted/40",
|
||||
)}>
|
||||
<GripVerticalIcon className={cn("size-3.5 shrink-0", isLive ? "text-muted-foreground/10" : "text-muted-foreground/30")} />
|
||||
<span className={cn("inline-block size-1.5 rounded-full shrink-0", item.active ? "bg-emerald-500" : "bg-muted-foreground/40")} />
|
||||
<span className={cn("inline-block size-1.5 rounded-full shrink-0", item.active ? "bg-[var(--status-online)]" : "bg-muted-foreground/40")} />
|
||||
<code className="text-xs font-mono flex-1 min-w-0 truncate">{item.interfaceName}</code>
|
||||
{item.type && item.type !== "broadcast" && (
|
||||
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">{item.type}</Chip>
|
||||
@@ -828,8 +833,8 @@ function NeighborsTab({
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Всего соседей", value: neighbors.length, color: "" },
|
||||
{ label: "Full", value: fullCount, color: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||||
{ label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" },
|
||||
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
||||
].map(s => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
@@ -861,7 +866,7 @@ function NeighborsTab({
|
||||
? "bg-black/60 border-white/15 text-white/70 hover:text-white"
|
||||
: "bg-black/60 border-white/10 text-white/30 hover:text-white/60",
|
||||
)}>
|
||||
<span className={cn("size-1.5 rounded-full", showDots ? "bg-emerald-400" : "bg-white/20")} />
|
||||
<span className={cn("size-1.5 rounded-full", showDots ? "bg-[var(--status-online)]" : "bg-white/20")} />
|
||||
Анимация
|
||||
</button>
|
||||
{!selectedId && (
|
||||
@@ -1024,9 +1029,9 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Сессий BFD", value: sessions.length, color: "" },
|
||||
{ label: "Up", value: upCount, color: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-red-500" : "text-muted-foreground" },
|
||||
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||||
{ label: "Up", value: upCount, color: "text-[var(--status-online-fg)]" },
|
||||
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
|
||||
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
||||
].map(s => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
@@ -1094,7 +1099,7 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
||||
{b.packetsTx.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-center">
|
||||
<span className={cn(b.stateChanges > 3 ? "text-amber-500" : "text-muted-foreground")}>
|
||||
<span className={cn(b.stateChanges > 3 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground")}>
|
||||
{b.stateChanges}
|
||||
</span>
|
||||
</td>
|
||||
@@ -1142,22 +1147,31 @@ export default function OspfPage() {
|
||||
const [fetchTick, setFetchTick] = useState(0)
|
||||
|
||||
// Reset server filter when switching live ↔ mock
|
||||
useEffect(() => { setFilterServerId("all") }, [isLive])
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setFilterServerId("all"))
|
||||
}, [isLive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) { setLiveData(null); return }
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => setLiveData(null))
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true); setLiveError(null)
|
||||
fetch(`${backendUrl}/api/ospf/all`)
|
||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
setLiveError(err instanceof Error ? err.message : String(err)); setLoading(false)
|
||||
})
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/ospf/all`)
|
||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
setLiveError(err instanceof Error ? err.message : String(err)); setLoading(false)
|
||||
})
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, fetchTick])
|
||||
|
||||
|
||||
+544
-53
@@ -5,11 +5,13 @@ import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { servers, greTunnels } from "@/lib/data"
|
||||
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
|
||||
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
|
||||
ZapIcon, ClockIcon, CheckIcon, TerminalIcon,
|
||||
LoaderCircleIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -25,7 +27,7 @@ interface OutputLine { text: string; kind: "normal" | "ok" | "err" | "dim" | "he
|
||||
interface DiagTest {
|
||||
id: string
|
||||
tool: DiagTool
|
||||
status: RunStatus
|
||||
status: RunStatus | "error"
|
||||
srcServerId: string
|
||||
srcServerName: string
|
||||
target: string
|
||||
@@ -33,6 +35,8 @@ interface DiagTest {
|
||||
startedAt: number
|
||||
lines: OutputLine[]
|
||||
totalLines: number // final line count — reveals progressively
|
||||
/** demo: симуляция в браузере; live: ответ MikroTik через бекенд */
|
||||
source?: "demo" | "live"
|
||||
}
|
||||
|
||||
type SchedType = "ping" | "bandwidth" | "both"
|
||||
@@ -60,6 +64,64 @@ const TOOL_META: Record<DiagTool, {
|
||||
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-orange-500", description: "Определение MTU пути" },
|
||||
}
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const finalRes = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!finalRes.ok) {
|
||||
const err = await finalRes.json().catch(() => ({ error: finalRes.statusText })) as { error?: string }
|
||||
throw new Error(err.error ?? finalRes.statusText)
|
||||
}
|
||||
return finalRes.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
function inferProbeLineKind(line: string): OutputLine["kind"] {
|
||||
const l = line.toLowerCase()
|
||||
if (l.includes("error:") || (l.includes("timeout") && l.includes("seq="))) return "err"
|
||||
if (l.includes("sent=") || l.includes("received=") || l.includes("packet-loss") || l.includes("✓")) return "ok"
|
||||
if (l.startsWith(";;") || l.trim() === "") return "dim"
|
||||
if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header"
|
||||
return "normal"
|
||||
}
|
||||
|
||||
function parseProbeOutput(text: string): OutputLine[] {
|
||||
return text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) }))
|
||||
}
|
||||
|
||||
interface BackendServerRow {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
site: string
|
||||
country: string
|
||||
asn: string
|
||||
type: Server["type"]
|
||||
enabled: boolean
|
||||
status?: Server["status"]
|
||||
latency?: number | null
|
||||
}
|
||||
|
||||
interface SpeedProbeApiRow {
|
||||
id: string
|
||||
srcServerId: string
|
||||
dstServerId: string
|
||||
srcInterface: string
|
||||
dstInterface: string
|
||||
protocol: string
|
||||
direction: string
|
||||
durationSec: string
|
||||
enabled: boolean
|
||||
lastRunAt: string | null
|
||||
lastTxAvgMbps: number | null
|
||||
lastRxAvgMbps: number | null
|
||||
lastStatus: string | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function jitter(base: number, pct = 0.15) {
|
||||
@@ -239,16 +301,37 @@ function genBwOutput(target: string, srcId: string, proto: string, duration: num
|
||||
|
||||
// ─── command builder ──────────────────────────────────────────────────────────
|
||||
|
||||
/** RouterOS принимает в src-address только IPv4; FQDN подставлять нельзя. */
|
||||
function isIpv4Literal(s: string): boolean {
|
||||
const t = s.trim()
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(t)) return false
|
||||
return t.split(".").every(p => {
|
||||
const n = Number.parseInt(p, 10)
|
||||
return Number.isFinite(n) && n >= 0 && n <= 255
|
||||
})
|
||||
}
|
||||
|
||||
/** Превью: IPv4 из API, иначе литерал host если уже IP, иначе плейсхолдер пока DNS не готов. */
|
||||
function pickSrcAddressRos(host: string, resolvedLiveIpv4: string | null | undefined): string {
|
||||
if (isIpv4Literal(host)) return host.trim()
|
||||
if (resolvedLiveIpv4 === undefined) return "…"
|
||||
if (resolvedLiveIpv4 === null) return "?"
|
||||
return resolvedLiveIpv4
|
||||
}
|
||||
|
||||
function buildCommand(
|
||||
serversList: Server[],
|
||||
tool: DiagTool, src: string, target: string,
|
||||
opts: { pingCount?: number; pingSize?: number; pingTtl?: number; traceProto?: TraceProto; traceMaxHops?: number; dnsType?: DnsType; mtuStart?: number; bwProto?: string; bwDuration?: number; bwTarget?: string },
|
||||
opts: { pingCount?: number; pingSize?: number; pingTtl?: number; traceProto?: TraceProto; traceMaxHops?: number; traceUseDns?: boolean; dnsType?: DnsType; mtuStart?: number; bwProto?: string; bwDuration?: number; bwTarget?: string },
|
||||
resolvedSrcIpv4?: string | null,
|
||||
): string {
|
||||
const host = servers.find(s => s.id === src)?.host ?? "?"
|
||||
const host = serversList.find(s => s.id === src)?.host ?? "?"
|
||||
const srcRos = pickSrcAddressRos(host, resolvedSrcIpv4)
|
||||
switch (tool) {
|
||||
case "ping":
|
||||
return `/tool ping address=${target} count=${opts.pingCount ?? 5} size=${opts.pingSize ?? 64} ttl=${opts.pingTtl ?? 64} src-address=${host}`
|
||||
return `/tool ping address=${target} count=${opts.pingCount ?? 5} size=${opts.pingSize ?? 64} ttl=${opts.pingTtl ?? 64} src-address=${srcRos}`
|
||||
case "traceroute":
|
||||
return `/tool traceroute address=${target} max-hops=${opts.traceMaxHops ?? 30} protocol=${opts.traceProto ?? "icmp"} src-address=${host}`
|
||||
return `/tool traceroute address=${target} max-hops=${opts.traceMaxHops ?? 30} protocol=${opts.traceProto ?? "icmp"} timeout=00:00:01 count=1 use-dns=${opts.traceUseDns ? "yes" : "no"} src-address=${srcRos}`
|
||||
case "bandwidth":
|
||||
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
|
||||
case "dns":
|
||||
@@ -256,7 +339,7 @@ function buildCommand(
|
||||
case "route":
|
||||
return `/ip route lookup ip=${target}`
|
||||
case "mtu":
|
||||
return `/tool ping address=${target} do-not-fragment count=1 size=${opts.mtuStart ?? 1500} src-address=${host}`
|
||||
return `/tool ping address=${target} do-not-fragment count=1 size=${opts.mtuStart ?? 1500} src-address=${srcRos}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,9 +351,9 @@ function NativeSelect({ value, onChange, children, className }: {
|
||||
return (
|
||||
<select value={value} onChange={e => onChange(e.target.value)}
|
||||
className={cn(
|
||||
"h-8 min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm",
|
||||
"text-foreground transition-colors outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30",
|
||||
"h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground",
|
||||
"transition-colors outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
@@ -350,6 +433,111 @@ function TerminalOutput({ test }: { test: DiagTest }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── uptime speed probes (live) ───────────────────────────────────────────────
|
||||
|
||||
function ScheduleSpeedProbesLive({
|
||||
apiFetch,
|
||||
serversForName,
|
||||
}: {
|
||||
apiFetch: ReturnType<typeof makeApiFetch>
|
||||
serversForName: Server[]
|
||||
}) {
|
||||
const [rows, setRows] = useState<SpeedProbeApiRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setErr(null)
|
||||
void apiFetch<{ probes: SpeedProbeApiRow[] }>("/api/uptime/speed-probes")
|
||||
.then(d => {
|
||||
if (!cancelled) setRows(d.probes ?? [])
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) setErr(e instanceof Error ? e.message : String(e))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [apiFetch])
|
||||
|
||||
const name = (id: string) => serversForName.find(s => s.id === id)?.name ?? id
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
||||
<LoaderCircleIcon className="size-5 animate-spin" />
|
||||
Загрузка расписания из БД…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (err) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<AlertCircleIcon className="size-4 shrink-0" />
|
||||
{err}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Card className="overflow-hidden">
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
|
||||
<ClockIcon className="size-7 opacity-20" />
|
||||
<p className="text-sm">Нет записей speed-test в мониторинге</p>
|
||||
<p className="text-xs text-muted-foreground/70 max-w-md text-center">
|
||||
Настраиваются через API <code className="text-[11px]">PUT /api/uptime/speed-probes</code> или связанный UI.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span>Источник</span>
|
||||
<span>Назначение</span>
|
||||
<span>Протокол</span>
|
||||
<span>Сек</span>
|
||||
<span>Вкл</span>
|
||||
<span>Последний запуск</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{rows.map(r => (
|
||||
<div key={r.id} className={cn(
|
||||
"grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2.5 text-xs",
|
||||
!r.enabled && "opacity-50",
|
||||
)}>
|
||||
<span className="truncate font-mono">{name(r.srcServerId)}{r.srcInterface ? ` · ${r.srcInterface}` : ""}</span>
|
||||
<span className="truncate font-mono">{name(r.dstServerId)}{r.dstInterface ? ` · ${r.dstInterface}` : ""}</span>
|
||||
<span>{r.protocol.toUpperCase()}</span>
|
||||
<span className="font-mono">{r.durationSec}s</span>
|
||||
<span>{r.enabled ? "да" : "нет"}</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{r.lastRunAt ?? "—"}
|
||||
{r.lastStatus === "done" && r.lastTxAvgMbps != null && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
|
||||
TX≈{r.lastTxAvgMbps.toFixed(1)} RX≈{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s
|
||||
</span>
|
||||
)}
|
||||
{r.lastStatus === "error" && r.lastError && (
|
||||
<span className="text-destructive ml-1 truncate">{r.lastError}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── schedule tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
const INIT_RULES: SchedRule[] = [
|
||||
@@ -358,15 +546,30 @@ const INIT_RULES: SchedRule[] = [
|
||||
{ id: "r3", srcId: "srv7", tunnelId: "gre5", type: "bandwidth", intervalMin: 60, enabled: false, lastRun: "2ч назад", nextRunMin: null },
|
||||
]
|
||||
|
||||
function ScheduleTab({ rules, setRules }: {
|
||||
rules: SchedRule[]; setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
|
||||
function ScheduleTab({
|
||||
rules,
|
||||
setRules,
|
||||
serverOptions,
|
||||
tunnelsForServer,
|
||||
}: {
|
||||
rules: SchedRule[]
|
||||
setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
|
||||
serverOptions: Server[]
|
||||
tunnelsForServer: (serverId: string) => GreTunnel[]
|
||||
}) {
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [addSrc, setAddSrc] = useState("srv1")
|
||||
const [addTun, setAddTun] = useState("gre1")
|
||||
const [addSrc, setAddSrc] = useState(serverOptions[0]?.id ?? "srv1")
|
||||
const [addTun, setAddTun] = useState("")
|
||||
const [addType, setAddType] = useState<SchedType>("ping")
|
||||
const [addMin, setAddMin] = useState(10)
|
||||
const addTunnels = useMemo(() => greTunnels.filter(t => t.serverId === addSrc), [addSrc])
|
||||
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
||||
|
||||
useEffect(() => {
|
||||
const list = tunnelsForServer(addSrc)
|
||||
if (list.length && !list.some(t => t.id === addTun)) {
|
||||
setAddTun(list[0]?.id ?? "")
|
||||
}
|
||||
}, [addSrc, addTun, tunnelsForServer])
|
||||
|
||||
const typeLabel: Record<SchedType, string> = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" }
|
||||
|
||||
@@ -391,8 +594,8 @@ function ScheduleTab({ rules, setRules }: {
|
||||
</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{rules.map(rule => {
|
||||
const src = servers.find(s => s.id === rule.srcId)
|
||||
const tun = greTunnels.find(t => t.id === rule.tunnelId)
|
||||
const src = serverOptions.find(s => s.id === rule.srcId)
|
||||
const tun = tunnelsForServer(rule.srcId).find(t => t.id === rule.tunnelId)
|
||||
return (
|
||||
<div key={rule.id} className={cn(
|
||||
"grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||||
@@ -433,7 +636,7 @@ function ScheduleTab({ rules, setRules }: {
|
||||
<div className="px-4 py-4 flex flex-wrap items-end gap-3">
|
||||
<div><OptionLabel>Сервер</OptionLabel>
|
||||
<NativeSelect value={addSrc} onChange={setAddSrc} className="min-w-[160px]">
|
||||
{servers.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
{serverOptions.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div><OptionLabel>GRE-туннель</OptionLabel>
|
||||
@@ -475,7 +678,14 @@ function ScheduleTab({ rules, setRules }: {
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProbesPage() {
|
||||
// ── tool config ──
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle"))
|
||||
const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({})
|
||||
|
||||
const [tool, setTool] = useState<DiagTool>("ping")
|
||||
const [srcId, setSrcId] = useState(servers[0]?.id ?? "srv1")
|
||||
const [target, setTarget] = useState("8.8.8.8")
|
||||
@@ -483,11 +693,109 @@ export default function ProbesPage() {
|
||||
const [pingSize, setPingSize] = useState(64)
|
||||
const [pingTtl] = useState(64)
|
||||
const [traceProto, setTraceProto] = useState<TraceProto>("icmp")
|
||||
const [traceUseDns, setTraceUseDns] = useState(false)
|
||||
const [traceHops] = useState(30)
|
||||
const [dnsType, setDnsType] = useState<DnsType>("A")
|
||||
const [bwTunId, setBwTunId] = useState(greTunnels[0]?.id ?? "gre1")
|
||||
const [bwTunId, setBwTunId] = useState("")
|
||||
const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp")
|
||||
const [bwDuration, setBwDuration] = useState(10)
|
||||
const [runBusy, setRunBusy] = useState(false)
|
||||
/** В live: IPv4 для src-address (DNS A-запись к host API); undefined = грузим, null = не удалось */
|
||||
const [rosSrcV4, setRosSrcV4] = useState<string | null | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => setLiveLoad("idle"))
|
||||
return
|
||||
}
|
||||
setLiveLoad("loading")
|
||||
void apiFetch<BackendServerRow[]>("/api/servers")
|
||||
.then(rows => {
|
||||
const mapped: Server[] = rows.map(s => ({
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: "—",
|
||||
os: "—",
|
||||
site: s.site,
|
||||
country: s.country || "UN",
|
||||
asn: s.asn,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
status: (s.status ?? "offline") as Server["status"],
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: 0,
|
||||
}))
|
||||
setLiveServers(mapped)
|
||||
setLiveLoad("idle")
|
||||
})
|
||||
.catch(() => {
|
||||
setLiveServers([])
|
||||
setLiveLoad("error")
|
||||
})
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
const allServers = useMemo(() => {
|
||||
if (!isLive) return servers
|
||||
if (liveServers.length > 0) return liveServers
|
||||
if (liveLoad === "error") return servers
|
||||
return []
|
||||
}, [isLive, liveServers, liveLoad])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
setRosSrcV4(undefined)
|
||||
return
|
||||
}
|
||||
const n = Number.parseInt(srcId, 10)
|
||||
if (!Number.isFinite(n)) {
|
||||
setRosSrcV4(undefined)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setRosSrcV4(undefined)
|
||||
void apiFetch<{ ipv4: string | null }>(`/api/servers/${n}/ros-src-address`)
|
||||
.then((r) => {
|
||||
if (!cancelled) setRosSrcV4(r.ipv4)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRosSrcV4(null)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, srcId, apiFetch])
|
||||
|
||||
const ensureGre = useCallback(async (serverId: string) => {
|
||||
if (!isLive) return
|
||||
try {
|
||||
const d = await apiFetch<{ tunnels: GreTunnel[] }>(
|
||||
`/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`,
|
||||
)
|
||||
setGreByServer(prev => ({ ...prev, [serverId]: d.tunnels }))
|
||||
} catch {
|
||||
setGreByServer(prev => ({ ...prev, [serverId]: [] }))
|
||||
}
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive || !srcId) return
|
||||
void ensureGre(srcId)
|
||||
}, [isLive, srcId, ensureGre])
|
||||
|
||||
const tunnelsForSrc = useMemo(() => {
|
||||
if (isLive) return greByServer[srcId] ?? []
|
||||
return greTunnels.filter(t => t.serverId === srcId)
|
||||
}, [isLive, greByServer, srcId])
|
||||
|
||||
useEffect(() => {
|
||||
const first = allServers[0]?.id
|
||||
if (!first) return
|
||||
if (!allServers.some(s => s.id === srcId)) setSrcId(first)
|
||||
}, [allServers, srcId])
|
||||
|
||||
useEffect(() => {
|
||||
const list = isLive ? (greByServer[srcId] ?? []) : greTunnels.filter(t => t.serverId === srcId)
|
||||
if (list.length && !list.some(t => t.id === bwTunId)) setBwTunId(list[0]!.id)
|
||||
}, [isLive, greByServer, srcId, bwTunId])
|
||||
|
||||
// ── run state ──
|
||||
const [tests, setTests] = useState<DiagTest[]>([])
|
||||
@@ -495,15 +803,18 @@ export default function ProbesPage() {
|
||||
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
|
||||
const nextId = useRef(1)
|
||||
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
/** Живой POST /probes/run — отмена через fetch AbortSignal */
|
||||
const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null)
|
||||
|
||||
const bwTunnels = useMemo(() => greTunnels.filter(t => t.serverId === srcId), [srcId])
|
||||
const srcServer = useMemo(() => servers.find(s => s.id === srcId), [srcId])
|
||||
const bwTunnels = tunnelsForSrc
|
||||
const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId])
|
||||
const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId])
|
||||
|
||||
// current command preview
|
||||
const cmdPreview = useMemo(() => buildCommand(tool, srcId, target, {
|
||||
pingCount, pingSize, pingTtl, traceProto, traceMaxHops: traceHops, dnsType,
|
||||
bwTarget: greTunnels.find(t => t.id === bwTunId)?.remoteAddress, bwProto, bwDuration,
|
||||
}), [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration])
|
||||
const cmdPreview = useMemo(() => buildCommand(allServers, tool, srcId, target, {
|
||||
pingCount, pingSize, pingTtl, traceProto, traceMaxHops: traceHops, traceUseDns, dnsType,
|
||||
bwTarget: bwTun?.remoteAddress, bwProto, bwDuration,
|
||||
}, isLive ? rosSrcV4 : undefined), [allServers, tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, traceUseDns, dnsType, bwTun, bwProto, bwDuration, isLive, rosSrcV4])
|
||||
|
||||
// ── progressive reveal tick ──
|
||||
useEffect(() => {
|
||||
@@ -512,6 +823,7 @@ export default function ProbesPage() {
|
||||
const hasRunning = prev.some(t => t.status === "running")
|
||||
if (!hasRunning) return prev
|
||||
return prev.map(t => {
|
||||
if (t.source === "live") return t
|
||||
if (t.status !== "running") return t
|
||||
const speed = t.tool === "dns" || t.tool === "route" ? t.lines.length : 1
|
||||
const nextTotal = Math.min(t.totalLines + speed, t.lines.length)
|
||||
@@ -524,47 +836,180 @@ export default function ProbesPage() {
|
||||
}, [])
|
||||
|
||||
// ── run test ──
|
||||
const runTest = useCallback(() => {
|
||||
const srv = servers.find(s => s.id === srcId)
|
||||
const runTest = useCallback(async () => {
|
||||
const srv = allServers.find(s => s.id === srcId)
|
||||
if (!srv) return
|
||||
const id = String(nextId.current++)
|
||||
const id = String(nextId.current++)
|
||||
const numericServerId = Number.parseInt(srcId, 10)
|
||||
|
||||
if (isLive) {
|
||||
if (!Number.isFinite(numericServerId)) return
|
||||
setRunBusy(true)
|
||||
const abortCtrl = new AbortController()
|
||||
liveProbeRunRef.current = { testId: id, ctrl: abortCtrl }
|
||||
setTests(p => [{
|
||||
id,
|
||||
tool,
|
||||
status: "running",
|
||||
srcServerId: srcId,
|
||||
srcServerName: srv.name,
|
||||
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
|
||||
command: cmdPreview,
|
||||
startedAt: Date.now(),
|
||||
lines: [{ text: "Выполняется запрос к MikroTik через API…", kind: "dim" }],
|
||||
totalLines: 1,
|
||||
source: "live",
|
||||
}, ...p.slice(0, 9)])
|
||||
setTab("history")
|
||||
try {
|
||||
const peer =
|
||||
bwTun?.remoteAddress
|
||||
? allServers.find(s => s.host.trim().toLowerCase() === bwTun.remoteAddress.trim().toLowerCase())
|
||||
: undefined
|
||||
const body = {
|
||||
tool,
|
||||
target: tool === "bandwidth" ? undefined : target.trim(),
|
||||
pingCount,
|
||||
pingSize,
|
||||
pingTtl,
|
||||
traceProto,
|
||||
traceMaxHops: traceHops,
|
||||
traceHopTimeout: tool === "traceroute" ? "1s" : undefined,
|
||||
traceProbeCount: tool === "traceroute" ? 1 : undefined,
|
||||
traceUseDns: tool === "traceroute" ? traceUseDns : undefined,
|
||||
dnsType,
|
||||
bwRemoteAddress: tool === "bandwidth" ? bwTun?.remoteAddress : undefined,
|
||||
dstServerId: peer ? Number.parseInt(peer.id, 10) : undefined,
|
||||
bwProto,
|
||||
bwDuration,
|
||||
}
|
||||
const res = await apiFetch<{ output: string }>(`/api/servers/${numericServerId}/probes/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
signal: abortCtrl.signal,
|
||||
})
|
||||
const lines = parseProbeOutput(res.output ?? "")
|
||||
const looksErr = (res.output ?? "").trim().toLowerCase().startsWith("error:")
|
||||
setTests(p => p.map(t => (t.id === id
|
||||
? {
|
||||
...t,
|
||||
lines,
|
||||
totalLines: lines.length,
|
||||
status: looksErr ? "error" : "done",
|
||||
}
|
||||
: t)))
|
||||
} catch (e) {
|
||||
const aborted =
|
||||
(typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError")
|
||||
|| (e instanceof Error && e.name === "AbortError")
|
||||
if (aborted) {
|
||||
setTests(p => p.map(t => (t.id === id
|
||||
? {
|
||||
...t,
|
||||
lines: [{ text: "Запрос отменён (стоп или закрытие запроса).", kind: "dim" }],
|
||||
totalLines: 1,
|
||||
status: "done",
|
||||
}
|
||||
: t)))
|
||||
} else {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setTests(p => p.map(t => (t.id === id
|
||||
? {
|
||||
...t,
|
||||
lines: [{ text: msg, kind: "err" }],
|
||||
totalLines: 1,
|
||||
status: "error",
|
||||
}
|
||||
: t)))
|
||||
}
|
||||
} finally {
|
||||
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null
|
||||
setRunBusy(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let lines: OutputLine[] = []
|
||||
const bwTun = greTunnels.find(t => t.id === bwTunId)
|
||||
|
||||
switch (tool) {
|
||||
case "ping": lines = genPingOutput(target, srcId, pingCount, pingSize, pingTtl); break
|
||||
case "traceroute":lines = genTraceOutput(target, srcId, traceProto, traceHops); break
|
||||
case "dns": lines = genDnsOutput(target, srcId, dnsType); break
|
||||
case "route": lines = genRouteOutput(target, srcId); break
|
||||
case "mtu": lines = genMtuOutput(target, srcId); break
|
||||
case "bandwidth": lines = genBwOutput(bwTun?.remoteAddress ?? target, srcId, bwProto, bwDuration); break
|
||||
case "ping": lines = genPingOutput(target, srcId, pingCount, pingSize, pingTtl); break
|
||||
case "traceroute": lines = genTraceOutput(target, srcId, traceProto, traceHops); break
|
||||
case "dns": lines = genDnsOutput(target, srcId, dnsType); break
|
||||
case "route": lines = genRouteOutput(target, srcId); break
|
||||
case "mtu": lines = genMtuOutput(target, srcId); break
|
||||
case "bandwidth": lines = genBwOutput(bwTun?.remoteAddress ?? target, srcId, bwProto, bwDuration); break
|
||||
}
|
||||
|
||||
const test: DiagTest = {
|
||||
id, tool, status: "running",
|
||||
srcServerId: srcId, srcServerName: srv.name,
|
||||
id,
|
||||
tool,
|
||||
status: "done",
|
||||
srcServerId: srcId,
|
||||
srcServerName: srv.name,
|
||||
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
|
||||
command: cmdPreview,
|
||||
startedAt: Date.now(),
|
||||
lines,
|
||||
totalLines: 0,
|
||||
totalLines: lines.length,
|
||||
source: "demo",
|
||||
}
|
||||
setTests(p => [test, ...p.slice(0, 9)]) // keep last 10
|
||||
setTests(p => [test, ...p.slice(0, 9)])
|
||||
setTab("history")
|
||||
}, [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration, cmdPreview])
|
||||
}, [
|
||||
isLive,
|
||||
allServers,
|
||||
srcId,
|
||||
tool,
|
||||
target,
|
||||
pingCount,
|
||||
pingSize,
|
||||
pingTtl,
|
||||
traceProto,
|
||||
traceUseDns,
|
||||
traceHops,
|
||||
dnsType,
|
||||
bwTun,
|
||||
bwTunId,
|
||||
bwProto,
|
||||
bwDuration,
|
||||
cmdPreview,
|
||||
apiFetch,
|
||||
])
|
||||
|
||||
const stopTest = (id: string) => setTests(p => p.map(t => t.id === id ? { ...t, status: "done", totalLines: t.lines.length } : t))
|
||||
const stopTest = (id: string) => {
|
||||
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
|
||||
setTests(p => p.map(t => (t.id === id && t.status === "running"
|
||||
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
||||
: t)))
|
||||
}
|
||||
const clearTest = (id: string) => setTests(p => p.filter(t => t.id !== id))
|
||||
|
||||
const running = tests.filter(t => t.status === "running")
|
||||
|
||||
if (isLive && liveLoad === "loading") {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} />
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<LoaderCircleIcon className="size-8 animate-spin opacity-50" />
|
||||
<p className="text-sm">Загрузка серверов из бекенда…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
|
||||
actions={
|
||||
running.length > 0
|
||||
? <Button variant="outline" size="sm" onClick={() => setTests(p => p.map(t => ({ ...t, status: "done" as const, totalLines: t.lines.length })))}>
|
||||
? <Button variant="outline" size="sm" onClick={() => {
|
||||
liveProbeRunRef.current?.ctrl.abort()
|
||||
setTests(p => p.map(t => (t.status === "running"
|
||||
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
||||
: t)))
|
||||
}}>
|
||||
<SquareIcon className="size-4" />Остановить все
|
||||
</Button>
|
||||
: undefined
|
||||
@@ -574,6 +1019,19 @@ export default function ProbesPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{isLive && liveLoad === "error" && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-xs text-destructive flex items-center gap-2">
|
||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||
Бекенд недоступен — переключитесь в режим «Демо» в настройках источника данных или проверьте URL API.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLive && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для «Источника» поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── tool selector + config ── */}
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-4 flex flex-col gap-4">
|
||||
@@ -604,7 +1062,7 @@ export default function ProbesPage() {
|
||||
<div>
|
||||
<OptionLabel>Источник</OptionLabel>
|
||||
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
|
||||
{servers.filter(s => s.enabled).map(s => (
|
||||
{allServers.filter(s => s.enabled).map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
@@ -653,12 +1111,23 @@ export default function ProbesPage() {
|
||||
)}
|
||||
|
||||
{tool === "traceroute" && (
|
||||
<div>
|
||||
<OptionLabel>Протокол</OptionLabel>
|
||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||
{(["icmp", "udp", "tcp"] as TraceProto[]).map(p => <SegBtn key={p} value={p} current={traceProto} onClick={setTraceProto}>{p.toUpperCase()}</SegBtn>)}
|
||||
<>
|
||||
<div>
|
||||
<OptionLabel>Протокол</OptionLabel>
|
||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||
{(["icmp", "udp", "tcp"] as TraceProto[]).map(p => <SegBtn key={p} value={p} current={traceProto} onClick={setTraceProto}>{p.toUpperCase()}</SegBtn>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end gap-3 pb-0.5">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<OptionLabel>Имена хопов (use-dns)</OptionLabel>
|
||||
<p className="text-[10px] text-muted-foreground leading-snug max-w-[220px]">
|
||||
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle checked={traceUseDns} onChange={setTraceUseDns} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tool === "dns" && (
|
||||
@@ -687,8 +1156,13 @@ export default function ProbesPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button onClick={runTest} className="h-8 shrink-0 gap-1.5 self-end">
|
||||
<PlayIcon className="size-3.5" />Запустить
|
||||
<Button
|
||||
onClick={() => void runTest()}
|
||||
disabled={runBusy || !srcServer || (tool === "bandwidth" && !bwTunnels.length)}
|
||||
className="h-8 shrink-0 gap-1.5 self-end"
|
||||
>
|
||||
{runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />}
|
||||
Запустить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -765,11 +1239,17 @@ export default function ProbesPage() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{test.status === "done" && (
|
||||
{test.status === "done" && test.source !== "live" && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{((test.lines.length * 0.4)).toFixed(1)}с
|
||||
</span>
|
||||
)}
|
||||
{test.status === "done" && test.source === "live" && (
|
||||
<span className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">live</span>
|
||||
)}
|
||||
{test.status === "error" && (
|
||||
<span className="text-[11px] text-destructive">ошибка</span>
|
||||
)}
|
||||
<button onClick={() => navigator.clipboard.writeText(test.lines.map(l => l.text).join("\n"))}
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-colors">
|
||||
<CopyIcon className="size-3.5" />
|
||||
@@ -792,7 +1272,18 @@ export default function ProbesPage() {
|
||||
)}
|
||||
|
||||
{/* schedule */}
|
||||
{tab === "schedule" && <ScheduleTab rules={rules} setRules={setRules} />}
|
||||
{tab === "schedule" && (
|
||||
isLive
|
||||
? <ScheduleSpeedProbesLive apiFetch={apiFetch} serversForName={allServers} />
|
||||
: (
|
||||
<ScheduleTab
|
||||
rules={rules}
|
||||
setRules={setRules}
|
||||
serverOptions={allServers}
|
||||
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+222
-171
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState, useMemo } from "react"
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -8,6 +9,25 @@ import { Input } from "@/components/ui/input"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { servers } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
type HomeRouter,
|
||||
type JumpHost,
|
||||
type ExitNode,
|
||||
type WanJhLeg,
|
||||
type JhExLeg,
|
||||
type FullRoute,
|
||||
type CommRec,
|
||||
type HomeEntry,
|
||||
type OptimizerData,
|
||||
type OptimizerSettings,
|
||||
type OptimizerApiServer,
|
||||
buildLiveOptimizerData,
|
||||
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
||||
mapApiServersToTopology,
|
||||
readStoredRouteOptimizerSettings,
|
||||
ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY,
|
||||
} from "@/lib/route-optimizer-data"
|
||||
import {
|
||||
RefreshCwIcon, AlertCircleIcon, ArrowRightIcon,
|
||||
SettingsIcon, ChevronDownIcon, ChevronUpIcon, PinIcon,
|
||||
@@ -16,111 +36,29 @@ import {
|
||||
InfoIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface WanUplink {
|
||||
id: string
|
||||
name: string // "WAN1-RT"
|
||||
isp: string // "Rostelecom"
|
||||
iface: string // "ether1"
|
||||
ip: string // external IP
|
||||
maxDl: number // Mbps
|
||||
maxUl: number // Mbps
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const err = await res.json() as { error?: string }
|
||||
message = err.error ?? message
|
||||
} catch {
|
||||
const text = await res.text().catch(() => "")
|
||||
if (text) message = text
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
interface HomeRouter {
|
||||
id: string
|
||||
label: string // "home-msk-01"
|
||||
site: string // "MSK"
|
||||
country: string
|
||||
model: string
|
||||
ip: string // LAN management IP
|
||||
wans: WanUplink[]
|
||||
}
|
||||
|
||||
interface JumpHost {
|
||||
id: string
|
||||
label: string
|
||||
site: string
|
||||
country: string
|
||||
ip: string
|
||||
}
|
||||
|
||||
interface ExitNode {
|
||||
id: string
|
||||
label: string
|
||||
site: string
|
||||
country: string
|
||||
ip: string
|
||||
}
|
||||
|
||||
// WAN→JH measurement (per uplink)
|
||||
interface WanJhLeg {
|
||||
wanId: string
|
||||
jhId: string
|
||||
pingMs: number
|
||||
dlMbps: number
|
||||
ulMbps: number
|
||||
score: number
|
||||
loss: number
|
||||
}
|
||||
|
||||
// JH→Exit measurement
|
||||
interface JhExLeg {
|
||||
jhId: string
|
||||
exitId: string
|
||||
pingMs: number
|
||||
dlMbps: number
|
||||
ulMbps: number
|
||||
}
|
||||
|
||||
// Full route: HomeRouter + WAN → JH → Exit
|
||||
interface FullRoute {
|
||||
id: string
|
||||
homeId: string
|
||||
wan: WanUplink
|
||||
jh: JumpHost
|
||||
exit: ExitNode
|
||||
hw: WanJhLeg
|
||||
je: JhExLeg
|
||||
score: number
|
||||
confidence: "HIGH" | "MEDIUM" | "LOW"
|
||||
probabilityOptimal: number
|
||||
}
|
||||
|
||||
// BGP community recommendation
|
||||
interface CommRec {
|
||||
community: string
|
||||
communityName: string
|
||||
current: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null
|
||||
recommended: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null
|
||||
shouldSwitch: boolean
|
||||
pinnedBySettings: boolean
|
||||
}
|
||||
|
||||
interface HomeEntry {
|
||||
home: HomeRouter
|
||||
wanJhLegs: WanJhLeg[] // all (WAN × JH) measurements
|
||||
fullRoutes: FullRoute[] // sorted by probabilityOptimal
|
||||
bestRoute: FullRoute | null
|
||||
commRecs: CommRec[]
|
||||
}
|
||||
|
||||
interface OptimizerData {
|
||||
updatedAt: string
|
||||
homes: HomeEntry[]
|
||||
}
|
||||
|
||||
interface OptimizerSettings {
|
||||
switchThreshold: number
|
||||
hysteresisThreshold: number
|
||||
pingWeight: number
|
||||
probeIntervalMin: number
|
||||
autoApply: boolean
|
||||
autoApplyIntervalMin: number
|
||||
}
|
||||
|
||||
// ─── Topology derived from lib/data servers ───────────────────────────────────
|
||||
// ─── Topology derived from lib/data servers (mock) ───────────────────────────
|
||||
|
||||
const HOME_ROUTERS: HomeRouter[] = servers
|
||||
.filter(s => s.type === "home-router" && s.enabled)
|
||||
@@ -361,13 +299,14 @@ function SettingRow({ label, unit, children }: { label: string; unit?: string; c
|
||||
// ─── WAN Matrix table ─────────────────────────────────────────────────────────
|
||||
// Rows = WANs, Columns = JHs, cells show ping / bw / score
|
||||
|
||||
function WanMatrix({ home, legs, pw: _pw }: {
|
||||
function WanMatrix({ home, legs, jumpHosts, pw: _pw }: {
|
||||
home: HomeRouter
|
||||
legs: WanJhLeg[]
|
||||
jumpHosts: JumpHost[]
|
||||
pw: number
|
||||
}) {
|
||||
// find best leg overall
|
||||
const bestScore = Math.max(...legs.map(l => l.score))
|
||||
const bestScore = legs.length ? Math.max(...legs.map(l => l.score)) : 0
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -377,7 +316,7 @@ function WanMatrix({ home, legs, pw: _pw }: {
|
||||
<th className="text-left font-medium px-4 py-2 w-[160px]">WAN-аплинк</th>
|
||||
<th className="text-left font-medium px-3 py-2">ISP / IP</th>
|
||||
<th className="text-right font-medium px-3 py-2">Макс. полоса</th>
|
||||
{JUMPHOSTS.map(jh => (
|
||||
{jumpHosts.map(jh => (
|
||||
<th key={jh.id} className="text-center font-medium px-3 py-2 min-w-[130px]">
|
||||
<div>{jh.label}</div>
|
||||
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
|
||||
@@ -412,7 +351,7 @@ function WanMatrix({ home, legs, pw: _pw }: {
|
||||
<p className="font-mono text-[10px] text-muted-foreground">↑{wan.maxUl} Мбит</p>
|
||||
</td>
|
||||
{/* Per-JH cells */}
|
||||
{JUMPHOSTS.map(jh => {
|
||||
{jumpHosts.map(jh => {
|
||||
const leg = legs.find(l => l.wanId === wan.id && l.jhId === jh.id)
|
||||
if (!leg) return <td key={jh.id} className="px-3 py-3 text-center text-muted-foreground text-xs">—</td>
|
||||
const isBest = leg.score === bestScore
|
||||
@@ -682,8 +621,9 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply
|
||||
|
||||
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
|
||||
|
||||
function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onApply }: {
|
||||
function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying, onPin, onApply }: {
|
||||
entry: HomeEntry
|
||||
jumpHosts: JumpHost[]
|
||||
settings: OptimizerSettings
|
||||
pinned: Set<string>
|
||||
applied: Set<string>
|
||||
@@ -741,7 +681,7 @@ function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onA
|
||||
{/* Sub-tabs */}
|
||||
<div className="flex items-center gap-0 border-b bg-muted/20 px-1">
|
||||
{([
|
||||
{ id: "wan-matrix", label: `WAN × JH (${home.wans.length}×${JUMPHOSTS.length})` },
|
||||
{ id: "wan-matrix", label: `WAN × JH (${home.wans.length}×${jumpHosts.length})` },
|
||||
{ id: "full-routes", label: `Маршруты (${fullRoutes.length})` },
|
||||
{ id: "bgp-community", label: `BGP community (${commRecs.length})` },
|
||||
] as { id: HomeTab; label: string }[]).map(t => (
|
||||
@@ -759,7 +699,7 @@ function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onA
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === "wan-matrix" && (
|
||||
<WanMatrix home={home} legs={wanJhLegs} pw={settings.pingWeight} />
|
||||
<WanMatrix home={home} legs={wanJhLegs} jumpHosts={jumpHosts} pw={settings.pingWeight} />
|
||||
)}
|
||||
{tab === "full-routes" && (
|
||||
<FullRoutesTable routes={fullRoutes} bestId={bestRoute?.id} />
|
||||
@@ -807,49 +747,111 @@ function TopologyBar() {
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const DEFAULT_SETTINGS: OptimizerSettings = {
|
||||
switchThreshold: 15,
|
||||
hysteresisThreshold: 10,
|
||||
pingWeight: 60,
|
||||
probeIntervalMin: 15,
|
||||
autoApply: false,
|
||||
autoApplyIntervalMin: 60,
|
||||
}
|
||||
|
||||
export default function RouteOptimizerPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<OptimizerData | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const [settings, setSettings] = useState<OptimizerSettings>(DEFAULT_SETTINGS)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [pinned, setPinned] = useState<Set<string>>(new Set())
|
||||
const [applied, setApplied] = useState<Set<string>>(new Set())
|
||||
const [applying, setApplying] = useState<Set<string>>(new Set())
|
||||
// ECMP / RPF / VRF
|
||||
const [ecmpEnabled, setEcmpEnabled] = useState(false)
|
||||
const [ecmpMaxPaths, setEcmpMaxPaths] = useState(4)
|
||||
const [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst")
|
||||
const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled")
|
||||
const [selectedVrf, setSelectedVrf] = useState("main")
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
/** Данные из API/БД при режиме «Живые»; не ждём /health — иначе до ответа показывались моки. */
|
||||
const useLiveData = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const load = useCallback((s: OptimizerSettings = settings) => {
|
||||
setLoading(true); setError("")
|
||||
setTimeout(() => {
|
||||
try { setData(buildMockData(s)) }
|
||||
catch { setError("Ошибка расчёта маршрутов") }
|
||||
finally { setLoading(false) }
|
||||
}, 600)
|
||||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<OptimizerData | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const [settings, setSettings] = useState<OptimizerSettings>(DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS)
|
||||
const settingsRef = useRef(settings)
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings
|
||||
}, [settings])
|
||||
|
||||
// Initial load + polling every 30 s — load() calls setState internally (standard data-fetching pattern)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load()
|
||||
const id = setInterval(() => load(), 30_000)
|
||||
return () => clearInterval(id)
|
||||
queueMicrotask(() => {
|
||||
setSettings(readStoredRouteOptimizerSettings())
|
||||
setPrefsLoaded(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsLoaded) return
|
||||
try {
|
||||
localStorage.setItem(ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY, JSON.stringify(settings))
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}, [settings, prefsLoaded])
|
||||
|
||||
const [liveJumpHosts, setLiveJumpHosts] = useState<JumpHost[]>([])
|
||||
const [liveExitNodes, setLiveExitNodes] = useState<ExitNode[]>([])
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [pinned, setPinned] = useState<Set<string>>(new Set())
|
||||
const [applied, setApplied] = useState<Set<string>>(new Set())
|
||||
const [applying, setApplying] = useState<Set<string>>(new Set())
|
||||
const [ecmpEnabled, setEcmpEnabled] = useState(false)
|
||||
const [ecmpMaxPaths, setEcmpMaxPaths] = useState(4)
|
||||
const [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst")
|
||||
const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled")
|
||||
const [selectedVrf, setSelectedVrf] = useState("main")
|
||||
|
||||
const load = useCallback(async (override?: OptimizerSettings) => {
|
||||
const s = override ?? settingsRef.current
|
||||
setLoading(true)
|
||||
setError("")
|
||||
try {
|
||||
if (!useLiveData) {
|
||||
await new Promise((r) => setTimeout(r, 450))
|
||||
setData(buildMockData(s))
|
||||
setLiveJumpHosts([])
|
||||
setLiveExitNodes([])
|
||||
return
|
||||
}
|
||||
const rows = await apiFetch<OptimizerApiServer[]>("/api/servers")
|
||||
const { jumpHosts, exitNodes } = mapApiServersToTopology(rows)
|
||||
setLiveJumpHosts(jumpHosts)
|
||||
setLiveExitNodes(exitNodes)
|
||||
|
||||
let rulesets: Array<{ serverId: string; rules: import("@/lib/data").FilterRule[] }> | null = null
|
||||
try {
|
||||
const fr = await apiFetch<{ rulesets: Array<{ serverId: string; rules: import("@/lib/data").FilterRule[] }> }>(
|
||||
"/api/filters/rules",
|
||||
)
|
||||
rulesets = fr.rulesets ?? null
|
||||
} catch {
|
||||
rulesets = null
|
||||
}
|
||||
|
||||
setData(buildLiveOptimizerData(rows, rulesets, s))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||||
setData(null)
|
||||
setLiveJumpHosts([])
|
||||
setLiveExitNodes([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [apiFetch, useLiveData])
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsLoaded) return
|
||||
queueMicrotask(() => {
|
||||
void load()
|
||||
})
|
||||
}, [load, useLiveData, prefsLoaded])
|
||||
|
||||
const pollMs = useMemo(() => {
|
||||
if (!useLiveData) return 30_000
|
||||
const m = Math.min(Math.max(settings.probeIntervalMin, 1), 30)
|
||||
return m * 60_000
|
||||
}, [useLiveData, settings.probeIntervalMin])
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsLoaded) return
|
||||
const id = setInterval(() => {
|
||||
void load()
|
||||
}, pollMs)
|
||||
return () => clearInterval(id)
|
||||
}, [load, pollMs, prefsLoaded])
|
||||
|
||||
function togglePin(key: string) {
|
||||
setPinned(prev => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n })
|
||||
}
|
||||
@@ -863,10 +865,61 @@ export default function RouteOptimizerPage() {
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const totalSwitches = useMemo(() =>
|
||||
data?.homes.flatMap(h => h.commRecs)
|
||||
.filter(r => r.shouldSwitch && !pinned.has(r.community)).length ?? 0
|
||||
, [data, pinned])
|
||||
const totalSwitches = useMemo(
|
||||
() =>
|
||||
data?.homes.flatMap((h) =>
|
||||
h.commRecs.filter(
|
||||
(r) => r.shouldSwitch && !pinned.has(`${h.home.id}::${r.community}`),
|
||||
),
|
||||
).length ?? 0,
|
||||
[data, pinned],
|
||||
)
|
||||
|
||||
const jumpHostsForCards = useLiveData ? liveJumpHosts : JUMPHOSTS
|
||||
|
||||
const statsChips = useMemo(() => {
|
||||
const homeCount = useLiveData ? (data?.homes.length ?? 0) : HOME_ROUTERS.length
|
||||
const wanCount = useLiveData
|
||||
? (data?.homes.reduce((s, h) => s + h.home.wans.length, 0) ?? 0)
|
||||
: HOME_ROUTERS.reduce((s, h) => s + h.wans.length, 0)
|
||||
const jh = useLiveData ? liveJumpHosts : JUMPHOSTS
|
||||
const ex = useLiveData ? liveExitNodes : EXIT_NODES
|
||||
const jhSub = jh.length ? jh.map((j) => j.site).join(" · ") : "—"
|
||||
const exSub = ex.length ? ex.map((e) => e.site).join(" · ") : "—"
|
||||
return [
|
||||
{
|
||||
label: "Home роутеров",
|
||||
value: homeCount,
|
||||
sub: `${wanCount} WAN-аплинков`,
|
||||
icon: <MonitorIcon className="size-4 text-muted-foreground" />,
|
||||
},
|
||||
{
|
||||
label: "JumpHost",
|
||||
value: jh.length,
|
||||
sub: jhSub,
|
||||
icon: <ServerIcon className="size-4 text-violet-400" />,
|
||||
},
|
||||
{
|
||||
label: "Exit Node",
|
||||
value: ex.length,
|
||||
sub: exSub,
|
||||
icon: <NetworkIcon className="size-4 text-emerald-500" />,
|
||||
},
|
||||
{
|
||||
label: "Переключений",
|
||||
value: totalSwitches,
|
||||
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
||||
icon: (
|
||||
<ZapIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
|
||||
|
||||
function applyAll() {
|
||||
data?.homes.forEach(h =>
|
||||
@@ -890,7 +943,7 @@ export default function RouteOptimizerPage() {
|
||||
<ZapIcon className="size-4" />Применить все ({totalSwitches})
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => load()} disabled={loading}>
|
||||
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
{loading ? "Расчёт…" : "Обновить"}
|
||||
</Button>
|
||||
@@ -907,26 +960,18 @@ export default function RouteOptimizerPage() {
|
||||
<span className="text-border">·</span>
|
||||
<span>Обновлено: {data?.updatedAt ?? "—"}</span>
|
||||
<span className="text-border">·</span>
|
||||
<span>Авто 30 с</span>
|
||||
<Chip>симуляция</Chip>
|
||||
<span>Авто {useLiveData ? `${settings.probeIntervalMin} мин` : "30 с"}</span>
|
||||
<Chip>{useLiveData ? "Живые данные · API" : "Демо · mock"}</Chip>
|
||||
{useLiveData && backendStatus === false && (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
бекенд не отвечает на /health — проверьте URL в настройках
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats chips */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Home роутеров", value: HOME_ROUTERS.length,
|
||||
sub: `${HOME_ROUTERS.reduce((s, h) => s + h.wans.length, 0)} WAN-аплинков`,
|
||||
icon: <MonitorIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "JumpHost", value: JUMPHOSTS.length,
|
||||
sub: JUMPHOSTS.map(j => j.site).join(" · "),
|
||||
icon: <ServerIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "Exit Node", value: EXIT_NODES.length,
|
||||
sub: EXIT_NODES.map(e => e.site).join(" · "),
|
||||
icon: <NetworkIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Переключений", value: totalSwitches,
|
||||
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
||||
icon: <ZapIcon className={cn("size-4", totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground")} /> },
|
||||
].map(s => (
|
||||
{statsChips.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-4 py-3 flex items-start justify-between">
|
||||
<div>
|
||||
@@ -1022,11 +1067,16 @@ export default function RouteOptimizerPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-5 pt-4 border-t">
|
||||
<Button variant="outline" size="sm" onClick={() => setSettings(DEFAULT_SETTINGS)}>Сбросить</Button>
|
||||
<Button size="sm" onClick={() => load(settings)}>
|
||||
<Button variant="outline" size="sm" onClick={() => setSettings({ ...DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS })}>Сбросить</Button>
|
||||
<Button size="sm" onClick={() => void load(settings)}>
|
||||
<CheckCircleIcon className="size-4" />Применить
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-3">
|
||||
Базовые значения совпадают с разделом{" "}
|
||||
<Link href="/settings#route-ai" className="text-primary underline-offset-2 hover:underline">Настройки → Route AI</Link>
|
||||
.
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
@@ -1173,10 +1223,11 @@ export default function RouteOptimizerPage() {
|
||||
</div>
|
||||
|
||||
{/* Per-home-router cards */}
|
||||
{data?.homes.map(entry => (
|
||||
{data?.homes.map((entry) => (
|
||||
<HomeRouterCard
|
||||
key={entry.home.id}
|
||||
entry={entry}
|
||||
jumpHosts={jumpHostsForCards}
|
||||
settings={settings}
|
||||
pinned={pinned}
|
||||
applied={applied}
|
||||
|
||||
+43
-14
@@ -15,6 +15,8 @@ interface BackendServer {
|
||||
useSsl: boolean; verifySsl: boolean; username: string; password: string
|
||||
type: ServerType; site: string; country: string; asn: string
|
||||
comment: string; enabled: boolean
|
||||
lanSubnet: string
|
||||
wanUplinks: WanUplink[]
|
||||
status: "online" | "offline" | null; latency: number | null
|
||||
os: string | null; model: string | null; uptime: string | null
|
||||
cpuLoad: number | null; freeMemory: number | null; totalMemory: number | null
|
||||
@@ -39,10 +41,14 @@ function toFrontend(s: BackendServer): Server {
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: s.sessions ?? 0,
|
||||
comment: s.comment || undefined,
|
||||
lanSubnet: s.lanSubnet || undefined,
|
||||
wanUplinks: Array.isArray(s.wanUplinks) && s.wanUplinks.length ? s.wanUplinks : undefined,
|
||||
// carry extra fields needed for expanded view
|
||||
uptime: s.uptime ?? undefined,
|
||||
cpuLoad: s.cpuLoad ?? undefined,
|
||||
polledAt: s.polledAt ?? undefined,
|
||||
uptime: s.uptime ?? undefined,
|
||||
cpuLoad: s.cpuLoad ?? undefined,
|
||||
freeMemory: s.freeMemory ?? undefined,
|
||||
totalMemory: s.totalMemory ?? undefined,
|
||||
polledAt: s.polledAt ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +119,10 @@ const ROS_FEATURES: RosFeature[] = [
|
||||
function RosBadge({ os }: { os: string }) {
|
||||
const v = rosVer(os)
|
||||
const cls = v >= 715
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
|
||||
: v >= 710
|
||||
? "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
|
||||
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
|
||||
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
|
||||
return (
|
||||
<span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>
|
||||
{os}
|
||||
@@ -390,8 +396,10 @@ export default function ServersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "live") {
|
||||
setServerList(initialServers)
|
||||
setBackendOk(false)
|
||||
queueMicrotask(() => {
|
||||
setServerList(initialServers)
|
||||
setBackendOk(false)
|
||||
})
|
||||
return
|
||||
}
|
||||
apiFetch<BackendServer[]>("/api/servers")
|
||||
@@ -437,6 +445,11 @@ export default function ServersPage() {
|
||||
port: String(full.port ?? 443),
|
||||
proto: full.useSsl ? "https" : "http",
|
||||
verifySsl: full.verifySsl ?? false,
|
||||
lanSubnet: full.lanSubnet ?? prev.lanSubnet,
|
||||
wanUplinks:
|
||||
Array.isArray(full.wanUplinks) && full.wanUplinks.length
|
||||
? JSON.parse(JSON.stringify(full.wanUplinks))
|
||||
: prev.wanUplinks,
|
||||
}))
|
||||
} catch {
|
||||
// ignore — user can fill in manually
|
||||
@@ -459,6 +472,8 @@ export default function ServersPage() {
|
||||
asn: form.asn,
|
||||
comment: form.comment,
|
||||
enabled: form.enabled,
|
||||
lanSubnet: form.lanSubnet.trim(),
|
||||
wanUplinks: form.type === "home-router" ? form.wanUplinks : [],
|
||||
}
|
||||
|
||||
if (isLive) {
|
||||
@@ -484,9 +499,19 @@ export default function ServersPage() {
|
||||
if (sheetMode === "edit" && editingId) {
|
||||
setServerList(list => list.map(s =>
|
||||
s.id === editingId
|
||||
? { ...s, name: form.name, type: form.type, site: form.site, country: form.country,
|
||||
host: form.host, asn: form.asn, comment: form.comment,
|
||||
status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline" }
|
||||
? {
|
||||
...s,
|
||||
name: form.name,
|
||||
type: form.type,
|
||||
site: form.site,
|
||||
country: form.country,
|
||||
host: form.host,
|
||||
asn: form.asn,
|
||||
comment: form.comment,
|
||||
lanSubnet: form.lanSubnet || undefined,
|
||||
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
||||
status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline",
|
||||
}
|
||||
: s
|
||||
))
|
||||
} else {
|
||||
@@ -497,6 +522,8 @@ export default function ServersPage() {
|
||||
status: form.enabled ? "online" : "offline",
|
||||
enabled: form.enabled, latency: null, sessions: 0,
|
||||
comment: form.comment || undefined,
|
||||
lanSubnet: form.lanSubnet || undefined,
|
||||
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
||||
}])
|
||||
}
|
||||
}
|
||||
@@ -764,7 +791,7 @@ export default function ServersPage() {
|
||||
</td>
|
||||
<td className={cn("px-4 py-3 font-mono text-right text-sm",
|
||||
s.latency == null ? "text-muted-foreground"
|
||||
: s.latency > 60 ? "text-amber-500" : "")}>
|
||||
: s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "")}>
|
||||
{s.latency == null ? "—" : `${s.latency} мс`}
|
||||
</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={s.status} /></td>
|
||||
@@ -1068,12 +1095,14 @@ export default function ServersPage() {
|
||||
: <><WifiIcon className="size-4" />Проверить подключение</>}
|
||||
</Button>
|
||||
{testState === "ok" && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-400">
|
||||
<div className="flex items-start gap-2 rounded-md border border-current/25 px-3 py-2 text-xs"
|
||||
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
|
||||
<CheckCircleIcon className="size-3.5 shrink-0 mt-0.5" /><span>{testMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
{testState === "error" && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-400">
|
||||
<div className="flex items-start gap-2 rounded-md border border-current/25 px-3 py-2 text-xs"
|
||||
style={{ background: "var(--status-offline-bg)", color: "var(--status-offline-fg)" }}>
|
||||
<XCircleIcon className="size-3.5 shrink-0 mt-0.5" /><span>{testMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+525
-11
@@ -1,13 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||
} from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP, type EvoBgpSavePayload, type EvoBgpTestDraft } from "@/lib/evobgp-context"
|
||||
import { DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } from "@/lib/route-optimizer-data"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
|
||||
@@ -18,7 +21,7 @@ import { servers } from "@/lib/data"
|
||||
import {
|
||||
SaveIcon, PencilIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
|
||||
XIcon, AlertCircleIcon, ShieldIcon, EyeIcon, EyeOffIcon, WrenchIcon, UserIcon,
|
||||
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon,
|
||||
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -144,9 +147,43 @@ const PERM_COLOR: Record<PermLevel, string> = {
|
||||
write: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
}
|
||||
|
||||
const SECTIONS_NAV = ["Общие", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
|
||||
const SECTIONS_NAV = ["Общие", "Сбор данных", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
|
||||
type NavSection = typeof SECTIONS_NAV[number]
|
||||
|
||||
interface CollectorSettingsDto {
|
||||
enabled: boolean
|
||||
intervalSec: number
|
||||
probeIntervalSec?: number
|
||||
speedIntervalSec?: number
|
||||
retentionDays: number
|
||||
lastCollectedAt: string | null
|
||||
lastDurationMs: number | null
|
||||
lastError: string | null
|
||||
collectorRunning?: boolean
|
||||
}
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const err = await res.json() as { error?: string }
|
||||
message = err.error ?? message
|
||||
} catch {
|
||||
const text = await res.text().catch(() => "")
|
||||
if (text) message = text
|
||||
}
|
||||
throw new Error(message || "Ошибка запроса")
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── small components ─────────────────────────────────────────────────────────
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
@@ -815,7 +852,28 @@ export default function SettingsPage() {
|
||||
|
||||
// data source
|
||||
const { mode, setMode, backendUrl, setBackendUrl, backendStatus, checkBackend } = useDataSource()
|
||||
const evo = useEvoBGP()
|
||||
const [evoBaseDraft, setEvoBaseDraft] = useState("")
|
||||
const [evoEnabledDraft, setEvoEnabledDraft] = useState(false)
|
||||
const [evoKeyDraft, setEvoKeyDraft] = useState("")
|
||||
const [evoSaveBusy, setEvoSaveBusy] = useState(false)
|
||||
const [evoSaveErr, setEvoSaveErr] = useState<string | null>(null)
|
||||
const [urlDraft, setUrlDraft] = useState(backendUrl)
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
const [evoTestResult, setEvoTestResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||||
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
|
||||
const [showEvoKey, setShowEvoKey] = useState(false)
|
||||
|
||||
// collectors
|
||||
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
||||
const [uptimeCollector, setUptimeCollector] = useState<CollectorSettingsDto | null>(null)
|
||||
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
|
||||
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
|
||||
const [uptimeIntervalDraft, setUptimeIntervalDraft] = useState("15")
|
||||
const [uptimeSpeedIntervalDraft, setUptimeSpeedIntervalDraft] = useState("60")
|
||||
const [uptimeRetentionDraft, setUptimeRetentionDraft] = useState("14")
|
||||
const [collectorBusy, setCollectorBusy] = useState<"traffic" | "uptime" | null>(null)
|
||||
const [collectorError, setCollectorError] = useState<string | null>(null)
|
||||
|
||||
// general
|
||||
const [lang, setLang] = useState("ru")
|
||||
@@ -848,8 +906,6 @@ export default function SettingsPage() {
|
||||
const [ipAllow, setIpAllow] = useState("10.0.0.0/8\n192.168.0.0/16")
|
||||
const [auditLog, setAuditLog] = useState(true)
|
||||
|
||||
const handleSave = () => { setSaved(true); setTimeout(() => setSaved(false), 2000) }
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
setCopied(text); setTimeout(() => setCopied(null), 1500)
|
||||
@@ -871,7 +927,84 @@ export default function SettingsPage() {
|
||||
// total sub-users count for summary
|
||||
const totalSubUsers = users.reduce((s, u) => s + u.subUsers.length, 0)
|
||||
|
||||
const loadCollectors = useCallback(async () => {
|
||||
if (mode !== "live" || backendStatus !== true) return
|
||||
setCollectorError(null)
|
||||
try {
|
||||
const [traffic, uptime] = await Promise.all([
|
||||
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
|
||||
apiFetch<CollectorSettingsDto>("/api/uptime/settings"),
|
||||
])
|
||||
setTrafficCollector(traffic)
|
||||
setUptimeCollector(uptime)
|
||||
setTrafficIntervalDraft(String(traffic.intervalSec))
|
||||
setTrafficRetentionDraft(String(traffic.retentionDays))
|
||||
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? uptime.intervalSec))
|
||||
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
|
||||
setUptimeRetentionDraft(String(uptime.retentionDays))
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить настройки сборщиков")
|
||||
}
|
||||
}, [apiFetch, backendStatus, mode])
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "Сбор данных") return
|
||||
queueMicrotask(() => { void loadCollectors() })
|
||||
}, [section, loadCollectors])
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "EvoBGP") return
|
||||
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
|
||||
}, [section, mode, backendStatus, evo.loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "EvoBGP" || !evo.settingsLoaded) return
|
||||
setEvoBaseDraft(evo.baseUrl)
|
||||
setEvoEnabledDraft(evo.enabled)
|
||||
setEvoKeyDraft("")
|
||||
setEvoSaveErr(null)
|
||||
}, [section, evo.settingsLoaded, evo.baseUrl, evo.enabled])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (section === "EvoBGP") {
|
||||
if (mode !== "live" || backendStatus !== true) {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
return
|
||||
}
|
||||
setEvoSaveBusy(true)
|
||||
setEvoSaveErr(null)
|
||||
try {
|
||||
const patch: EvoBgpSavePayload = {
|
||||
baseUrl: evoBaseDraft.trim(),
|
||||
enabled: evoEnabledDraft,
|
||||
}
|
||||
if (evoKeyDraft.trim()) patch.apiKey = evoKeyDraft.trim()
|
||||
await evo.saveSettings(patch)
|
||||
setEvoKeyDraft("")
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (e) {
|
||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
|
||||
} finally {
|
||||
setEvoSaveBusy(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}, [
|
||||
section,
|
||||
mode,
|
||||
backendStatus,
|
||||
evoBaseDraft,
|
||||
evoEnabledDraft,
|
||||
evoKeyDraft,
|
||||
evo.saveSettings,
|
||||
])
|
||||
|
||||
const renderContent = () => {
|
||||
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||||
|
||||
// ── Общие ──
|
||||
if (section === "Общие") return (
|
||||
@@ -966,7 +1099,7 @@ export default function SettingsPage() {
|
||||
<CardHeader><CardTitle className="text-base">Основные настройки</CardTitle></CardHeader>
|
||||
<CardContent className="divide-y px-5">
|
||||
<SettingRow label="Язык интерфейса">
|
||||
<select className="text-sm bg-background border rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={lang} onChange={e => setLang(e.target.value)}>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="en">English</option>
|
||||
@@ -984,7 +1117,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="Часовой пояс">
|
||||
<select className="text-sm bg-background border rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={timezone} onChange={e => setTimezone(e.target.value)}>
|
||||
{["Europe/Moscow","Europe/Berlin","Europe/Amsterdam","Asia/Singapore","UTC"].map(z => (
|
||||
<option key={z} value={z}>{z}</option>
|
||||
@@ -1002,6 +1135,370 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card id="route-ai" className="scroll-mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Route AI</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и
|
||||
веса настраиваются в инструменте, не через отдельный API.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-5 pb-5">
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||
<dt className="text-muted-foreground">Мин. выигрыш (переключение)</dt>
|
||||
<dd className="font-mono tabular-nums">{ra.switchThreshold}%</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||
<dt className="text-muted-foreground">Гистерезис</dt>
|
||||
<dd className="font-mono tabular-nums">{ra.hysteresisThreshold}%</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||
<dt className="text-muted-foreground">Вес задержки (ping)</dt>
|
||||
<dd className="font-mono tabular-nums">{ra.pingWeight}%</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||||
<dt className="text-muted-foreground">Интервал зондирования</dt>
|
||||
<dd className="font-mono tabular-nums">{ra.probeIntervalMin} мин</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5 sm:col-span-2">
|
||||
<dt className="text-muted-foreground">Автоприменение</dt>
|
||||
<dd>{ra.autoApply ? `да, каждые ${ra.autoApplyIntervalMin} мин` : "нет"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<Link
|
||||
href="/route-optimizer"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||||
>
|
||||
Открыть оптимизатор маршрутов
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── Сбор данных ──
|
||||
if (section === "Сбор данных") return (
|
||||
<div className="space-y-4">
|
||||
{(mode !== "live" || backendStatus !== true) && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-4">
|
||||
<p className="text-sm font-medium">Раздел доступен только в live-режиме</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Переключи `Режим данных` в `Живые` и проверь доступность бекенда в разделе `Общие`.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{mode === "live" && backendStatus === true && (
|
||||
<>
|
||||
{collectorError && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-4">
|
||||
<p className="text-xs text-destructive">{collectorError}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Сбор трафика</CardTitle>
|
||||
<CardDescription className="text-xs">Настройки для `/traffic`</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-5 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm">Состояние</span>
|
||||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||||
<button className={cn("px-3 text-xs", trafficCollector?.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
|
||||
disabled={collectorBusy === "traffic"}
|
||||
onClick={async () => {
|
||||
setCollectorBusy("traffic")
|
||||
try { await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) }); await loadCollectors() }
|
||||
finally { setCollectorBusy(null) }
|
||||
}}>Вкл</button>
|
||||
<button className={cn("px-3 text-xs border-l border-input", !trafficCollector?.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
|
||||
disabled={collectorBusy === "traffic"}
|
||||
onClick={async () => {
|
||||
setCollectorBusy("traffic")
|
||||
try { await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) }); await loadCollectors() }
|
||||
finally { setCollectorBusy(null) }
|
||||
}}>Выкл</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input value={trafficIntervalDraft} onChange={(e) => setTrafficIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал (сек)" />
|
||||
<Input value={trafficRetentionDraft} onChange={(e) => setTrafficRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={collectorBusy === "traffic"} onClick={async () => {
|
||||
setCollectorBusy("traffic")
|
||||
try {
|
||||
await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ intervalSec: Number.parseInt(trafficIntervalDraft, 10) || 30, retentionDays: Number.parseInt(trafficRetentionDraft, 10) || 14 }) })
|
||||
await loadCollectors()
|
||||
} finally { setCollectorBusy(null) }
|
||||
}}>Сохранить</Button>
|
||||
<Button size="sm" variant="outline" disabled={collectorBusy === "traffic"} onClick={async () => {
|
||||
setCollectorBusy("traffic")
|
||||
try { await apiFetch("/api/traffic/collect-now", { method: "POST" }); await loadCollectors() }
|
||||
finally { setCollectorBusy(null) }
|
||||
}}>Собрать сейчас</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p>Последний сбор: {trafficCollector?.lastCollectedAt ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
|
||||
<p>Длительность: {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"}</p>
|
||||
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>{trafficCollector?.lastError ? `Ошибка: ${trafficCollector.lastError}` : "Ошибок нет"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Сбор uptime</CardTitle>
|
||||
<CardDescription className="text-xs">Настройки для `/uptime`</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-5 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm">Состояние</span>
|
||||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||||
<button className={cn("px-3 text-xs", uptimeCollector?.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
|
||||
disabled={collectorBusy === "uptime"}
|
||||
onClick={async () => {
|
||||
setCollectorBusy("uptime")
|
||||
try { await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) }); await loadCollectors() }
|
||||
finally { setCollectorBusy(null) }
|
||||
}}>Вкл</button>
|
||||
<button className={cn("px-3 text-xs border-l border-input", !uptimeCollector?.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
|
||||
disabled={collectorBusy === "uptime"}
|
||||
onClick={async () => {
|
||||
setCollectorBusy("uptime")
|
||||
try { await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) }); await loadCollectors() }
|
||||
finally { setCollectorBusy(null) }
|
||||
}}>Выкл</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input value={uptimeIntervalDraft} onChange={(e) => setUptimeIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал ping-проб (сек)" />
|
||||
<Input value={uptimeSpeedIntervalDraft} onChange={(e) => setUptimeSpeedIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал speed-проб (сек)" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Input value={uptimeRetentionDraft} onChange={(e) => setUptimeRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={collectorBusy === "uptime"} onClick={async () => {
|
||||
setCollectorBusy("uptime")
|
||||
try {
|
||||
await apiFetch("/api/uptime/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
probeIntervalSec: Number.parseInt(uptimeIntervalDraft, 10) || 15,
|
||||
speedIntervalSec: Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60,
|
||||
retentionDays: Number.parseInt(uptimeRetentionDraft, 10) || 14,
|
||||
}),
|
||||
})
|
||||
await loadCollectors()
|
||||
} finally { setCollectorBusy(null) }
|
||||
}}>Сохранить</Button>
|
||||
<Button size="sm" variant="outline" disabled={collectorBusy === "uptime"} onClick={async () => {
|
||||
setCollectorBusy("uptime")
|
||||
try { await apiFetch("/api/uptime/collect-now", { method: "POST" }); await loadCollectors() }
|
||||
finally { setCollectorBusy(null) }
|
||||
}}>Собрать сейчас</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p>Последний сбор: {uptimeCollector?.lastCollectedAt ? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
|
||||
<p>Длительность: {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}</p>
|
||||
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>{uptimeCollector?.lastError ? `Ошибка: ${uptimeCollector.lastError}` : "Ошибок нет"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── EvoBGP ──
|
||||
if (section === "EvoBGP") return (
|
||||
<div className="space-y-4">
|
||||
{(mode !== "live" || backendStatus !== true) && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-4">
|
||||
<p className="text-sm font-medium">Интеграция доступна в live-режиме</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">EvoBGP API</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Control plane EvoBGP: Bearer-ключ и роль viewer+ — см.{" "}
|
||||
<a
|
||||
className="underline underline-offset-2"
|
||||
href="https://git.shts.su/denozord/EvoBGP/src/branch/main/docs/access.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
access.md
|
||||
</a>
|
||||
. URL и ключ хранятся в SQLite на сервере бекенда (
|
||||
<code className="font-mono bg-muted px-1 rounded">evobgp_settings</code>
|
||||
). Каталог —{" "}
|
||||
<code className="font-mono bg-muted px-1 rounded">GET /v1/router-lists/catalog</code> через прокси.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y px-5 space-y-4 pb-5">
|
||||
<SettingRow
|
||||
label="Базовый URL API"
|
||||
description="Например http://control.example:8080 — без суффикса /v1"
|
||||
>
|
||||
<Input
|
||||
className="w-full max-w-md h-8 text-sm font-mono"
|
||||
value={evoBaseDraft}
|
||||
onChange={(e) => setEvoBaseDraft(e.target.value)}
|
||||
placeholder="http://localhost:8080"
|
||||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="API-ключ"
|
||||
description={
|
||||
evo.secretConfigured
|
||||
? "В БД уже есть ключ — введите новый только для замены"
|
||||
: "Сохраняется общей кнопкой «Сохранить» в шапке страницы"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-full max-w-md h-8 text-sm font-mono"
|
||||
type={showEvoKey ? "text" : "password"}
|
||||
autoComplete="off"
|
||||
value={evoKeyDraft}
|
||||
onChange={(e) => setEvoKeyDraft(e.target.value)}
|
||||
placeholder={evo.secretConfigured ? "Оставьте пустым, чтобы не менять" : "Bearer-токен"}
|
||||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => setShowEvoKey((v) => !v)}
|
||||
>
|
||||
{showEvoKey ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Подставлять данные EvoBGP"
|
||||
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
||||
>
|
||||
<Toggle
|
||||
checked={evoEnabledDraft}
|
||||
onChange={(v) => setEvoEnabledDraft(v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-destructive border-destructive/40 hover:bg-destructive/10"
|
||||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy || !evo.secretConfigured}
|
||||
onClick={async () => {
|
||||
setEvoSaveBusy(true)
|
||||
setEvoSaveErr(null)
|
||||
try {
|
||||
await evo.saveSettings({ apiKey: null })
|
||||
setEvoKeyDraft("")
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (e) {
|
||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка")
|
||||
} finally {
|
||||
setEvoSaveBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Удалить ключ из БД
|
||||
</Button>
|
||||
</div>
|
||||
{evoSaveErr && <p className="text-xs text-destructive">{evoSaveErr}</p>}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
«Проверить ключ» использует поля формы; незаполненное поле подставляется из БД. «Обновить каталог» — только по сохранённым в БД настройкам.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={
|
||||
mode !== "live" ||
|
||||
backendStatus !== true ||
|
||||
evoBusy !== null ||
|
||||
evoSaveBusy ||
|
||||
!(
|
||||
(evoBaseDraft.trim() || evo.baseUrl.trim()) &&
|
||||
(evoKeyDraft.trim() || evo.secretConfigured)
|
||||
)
|
||||
}
|
||||
onClick={async () => {
|
||||
setEvoBusy("test")
|
||||
setEvoTestResult(null)
|
||||
const b = evoBaseDraft.trim()
|
||||
const k = evoKeyDraft.trim()
|
||||
let draft: EvoBgpTestDraft | undefined
|
||||
if (b || k) {
|
||||
draft = {}
|
||||
if (b) draft.baseUrl = b
|
||||
if (k) draft.apiKey = k
|
||||
}
|
||||
const r = await evo.testConnection(draft)
|
||||
setEvoTestResult({ ok: r.ok, message: r.message })
|
||||
setEvoBusy(null)
|
||||
}}
|
||||
>
|
||||
{evoBusy === "test" ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
||||
Проверить ключ
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={mode !== "live" || backendStatus !== true || evoBusy !== null || evoSaveBusy || !evo.enabled}
|
||||
onClick={async () => {
|
||||
setEvoBusy("refresh")
|
||||
await evo.refresh()
|
||||
setEvoBusy(null)
|
||||
}}
|
||||
>
|
||||
{evoBusy === "refresh" || evo.loading ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
||||
Обновить каталог
|
||||
</Button>
|
||||
{evo.snapshot?.fetchedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Загружено: {new Date(evo.snapshot.fetchedAt).toLocaleString("ru-RU")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{evoTestResult && (
|
||||
<p className={cn(
|
||||
"text-xs",
|
||||
evoTestResult.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive",
|
||||
)}>
|
||||
{evoTestResult.message}
|
||||
</p>
|
||||
)}
|
||||
{evo.error && (
|
||||
<p className="text-xs text-destructive">{evo.error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1306,9 +1803,26 @@ export default function SettingsPage() {
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Система" }, { label: "Настройки" }]}
|
||||
actions={
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
{saved ? <CheckIcon className="size-4" /> : <SaveIcon className="size-4" />}
|
||||
{saved ? "Сохранено!" : "Сохранить"}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { void handleSave() }}
|
||||
disabled={
|
||||
section === "EvoBGP" &&
|
||||
(evoSaveBusy || mode !== "live" || backendStatus !== true)
|
||||
}
|
||||
>
|
||||
{section === "EvoBGP" && evoSaveBusy ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : saved ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<SaveIcon className="size-4" />
|
||||
)}
|
||||
{section === "EvoBGP" && evoSaveBusy
|
||||
? "Сохранение…"
|
||||
: saved
|
||||
? "Сохранено!"
|
||||
: "Сохранить"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -204,8 +204,8 @@ function Terminal({
|
||||
const [lines, setLines] = useState<TermLine[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
const [histIdx, setHistIdx] = useState(-1)
|
||||
const [idCtr, setIdCtr] = useState(0)
|
||||
const [_histIdx, setHistIdx] = useState(-1)
|
||||
const [_idCtr, setIdCtr] = useState(0)
|
||||
const [executing, setExecuting] = useState(false)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -217,16 +217,6 @@ function Terminal({
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const addLines = useCallback((texts: string[], kind: TermLine["kind"] = "output") => {
|
||||
setLines(prev => [
|
||||
...prev,
|
||||
...texts.map(text => ({ id: 0, kind, text })),
|
||||
].map((l, i, arr) => ({ ...l, id: arr.length - texts.length + i }))
|
||||
// Note: IDs don't need to be perfect unique here since we append
|
||||
)
|
||||
setIdCtr(prev => prev + texts.length)
|
||||
}, [])
|
||||
|
||||
// Initialize MOTD on mount / server change
|
||||
useEffect(() => {
|
||||
const motd = isLive ? liveMotd(server) : mockMotd(server)
|
||||
@@ -447,27 +437,30 @@ export default function TerminalPage() {
|
||||
|
||||
// Load servers from backend when in live mode
|
||||
useEffect(() => {
|
||||
if (!isLive) { setLiveServers([]); return }
|
||||
if (!isLive) return
|
||||
let cancelled = false
|
||||
setServersLoading(true)
|
||||
fetch(`${backendUrl}/api/servers`)
|
||||
.then(r => r.json() as Promise<BackendServer[]>)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveServers(data.map(s => ({
|
||||
uid: String(s.id),
|
||||
backendId: s.id,
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
country: s.country || "",
|
||||
status: s.status,
|
||||
enabled: s.enabled,
|
||||
rosVersion: s.os,
|
||||
identityName: s.identityName,
|
||||
})))
|
||||
setServersLoading(false)
|
||||
})
|
||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setServersLoading(true)
|
||||
fetch(`${backendUrl}/api/servers`)
|
||||
.then(r => r.json() as Promise<BackendServer[]>)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveServers(data.map(s => ({
|
||||
uid: String(s.id),
|
||||
backendId: s.id,
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
country: s.country || "",
|
||||
status: s.status,
|
||||
enabled: s.enabled,
|
||||
rosVersion: s.os,
|
||||
identityName: s.identityName,
|
||||
})))
|
||||
setServersLoading(false)
|
||||
})
|
||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, refreshKey])
|
||||
|
||||
@@ -483,11 +476,15 @@ export default function TerminalPage() {
|
||||
}, [termServers])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUid && defaultUid) setSelectedUid(defaultUid)
|
||||
queueMicrotask(() => {
|
||||
if (!selectedUid && defaultUid) setSelectedUid(defaultUid)
|
||||
})
|
||||
}, [defaultUid, selectedUid])
|
||||
|
||||
// Reset selection when switching modes
|
||||
useEffect(() => { setSelectedUid("") }, [isLive])
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setSelectedUid(""))
|
||||
}, [isLive])
|
||||
|
||||
const selected = termServers.find(s => s.uid === selectedUid) ?? termServers[0]
|
||||
|
||||
|
||||
+3
-141
@@ -116,16 +116,6 @@ interface LiveTrafficServer {
|
||||
txSeries: number[]
|
||||
}
|
||||
|
||||
interface TrafficSettingsDto {
|
||||
enabled: boolean
|
||||
intervalSec: number
|
||||
retentionDays: number
|
||||
lastCollectedAt: string | null
|
||||
lastDurationMs: number | null
|
||||
lastError: string | null
|
||||
collectorRunning: boolean
|
||||
}
|
||||
|
||||
interface LiveTrafficInterface {
|
||||
name: string
|
||||
running: boolean
|
||||
@@ -787,10 +777,6 @@ export default function TrafficPage() {
|
||||
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
|
||||
const [liveBusy, setLiveBusy] = useState(false)
|
||||
const [liveError, setLiveError] = useState<string | null>(null)
|
||||
const [settingsBusy, setSettingsBusy] = useState(false)
|
||||
const [trafficSettings, setTrafficSettings] = useState<TrafficSettingsDto | null>(null)
|
||||
const [intervalDraft, setIntervalDraft] = useState("30")
|
||||
const [retentionDraft, setRetentionDraft] = useState("14")
|
||||
const [serverIfaces, setServerIfaces] = useState<LiveTrafficInterface[]>([])
|
||||
const [selectedIface, setSelectedIface] = useState("__all__")
|
||||
const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true)
|
||||
@@ -834,30 +820,16 @@ export default function TrafficPage() {
|
||||
}
|
||||
}, [apiFetch, isLive])
|
||||
|
||||
const loadTrafficSettings = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
try {
|
||||
const s = await apiFetch<TrafficSettingsDto>("/api/traffic/settings")
|
||||
setTrafficSettings(s)
|
||||
setIntervalDraft(String(s.intervalSec))
|
||||
setRetentionDraft(String(s.retentionDays))
|
||||
} catch (e) {
|
||||
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить настройки сбора")
|
||||
}
|
||||
}, [apiFetch, isLive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLiveServers([])
|
||||
setLiveError(null)
|
||||
setTrafficSettings(null)
|
||||
return
|
||||
}
|
||||
if (groupMode !== "servers") setGroupMode("servers")
|
||||
void loadLiveTraffic(range)
|
||||
void loadTrafficSettings()
|
||||
}, [isLive, groupMode, range, loadLiveTraffic, loadTrafficSettings])
|
||||
}, [isLive, groupMode, range, loadLiveTraffic])
|
||||
|
||||
const activeServerTraffic = isLive ? liveServers : serverTraffic
|
||||
const visibleIfaces = useMemo(
|
||||
@@ -974,28 +946,6 @@ export default function TrafficPage() {
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
|
||||
</Button>
|
||||
{isLive && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setSettingsBusy(true)
|
||||
setLiveError(null)
|
||||
try {
|
||||
await apiFetch("/api/traffic/collect-now", { method: "POST" })
|
||||
await loadLiveTraffic(range)
|
||||
await loadTrafficSettings()
|
||||
} catch (e) {
|
||||
setLiveError(e instanceof Error ? e.message : "Не удалось выполнить сбор")
|
||||
} finally {
|
||||
setSettingsBusy(false)
|
||||
}
|
||||
}}
|
||||
disabled={settingsBusy}
|
||||
>
|
||||
<ActivityIcon className={cn("size-4", settingsBusy && "animate-pulse")} />Собрать сейчас
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
</>
|
||||
}
|
||||
@@ -1020,94 +970,6 @@ export default function TrafficPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{isLive && trafficSettings && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-muted-foreground">Сбор статистики</span>
|
||||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||||
<button
|
||||
className={cn("px-3 text-xs", trafficSettings.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
|
||||
onClick={async () => {
|
||||
setSettingsBusy(true)
|
||||
try {
|
||||
await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) })
|
||||
await loadTrafficSettings()
|
||||
} finally { setSettingsBusy(false) }
|
||||
}}
|
||||
disabled={settingsBusy}
|
||||
>Вкл</button>
|
||||
<button
|
||||
className={cn("px-3 text-xs border-l border-input", !trafficSettings.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
|
||||
onClick={async () => {
|
||||
setSettingsBusy(true)
|
||||
try {
|
||||
await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) })
|
||||
await loadTrafficSettings()
|
||||
} finally { setSettingsBusy(false) }
|
||||
}}
|
||||
disabled={settingsBusy}
|
||||
>Выкл</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-muted-foreground">Интервал (сек)</span>
|
||||
<input
|
||||
value={intervalDraft}
|
||||
onChange={(e) => setIntervalDraft(e.target.value)}
|
||||
className="h-8 w-24 text-xs bg-muted/50 border border-border rounded-md px-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-muted-foreground">Хранение (дней)</span>
|
||||
<input
|
||||
value={retentionDraft}
|
||||
onChange={(e) => setRetentionDraft(e.target.value)}
|
||||
className="h-8 w-24 text-xs bg-muted/50 border border-border rounded-md px-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={settingsBusy}
|
||||
onClick={async () => {
|
||||
setSettingsBusy(true)
|
||||
try {
|
||||
await apiFetch("/api/traffic/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
intervalSec: Number.parseInt(intervalDraft, 10) || 30,
|
||||
retentionDays: Number.parseInt(retentionDraft, 10) || 14,
|
||||
}),
|
||||
})
|
||||
await loadTrafficSettings()
|
||||
} catch (e) {
|
||||
setLiveError(e instanceof Error ? e.message : "Не удалось сохранить настройки")
|
||||
} finally {
|
||||
setSettingsBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Сохранить сбор
|
||||
</Button>
|
||||
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
<p>Последний сбор: {trafficSettings.lastCollectedAt ? new Date(trafficSettings.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
|
||||
<p>Длительность: {trafficSettings.lastDurationMs != null ? `${trafficSettings.lastDurationMs} мс` : "—"}</p>
|
||||
<p className={cn(trafficSettings.lastError ? "text-destructive" : "")}>
|
||||
{trafficSettings.lastError ? `Ошибка: ${trafficSettings.lastError}` : "Ошибок нет"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── summary stat cards ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
@@ -1206,11 +1068,11 @@ export default function TrafficPage() {
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
{visibleIfaces.map((iface) => {
|
||||
{visibleIfaces.map((iface, index) => {
|
||||
const active = selectedIface === iface.name
|
||||
return (
|
||||
<button
|
||||
key={iface.name}
|
||||
key={`${iface.name}:${index}`}
|
||||
onClick={() => setSelectedIface(iface.name)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
|
||||
|
||||
+1374
-412
File diff suppressed because it is too large
Load Diff
+39
-7
@@ -110,9 +110,16 @@
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
|
||||
/* ── Status ── */
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
/* badge variants — foreground text + translucent fill */
|
||||
--status-online-fg: oklch(0.437 0.149 150.069); /* emerald-700 */
|
||||
--status-online-bg: oklch(0.527 0.154 150.069 / 0.10);
|
||||
--status-offline-fg: oklch(0.505 0.213 27.325); /* red-700 */
|
||||
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.10);
|
||||
--status-degraded-fg: oklch(0.555 0.163 58.318); /* amber-700 */
|
||||
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.10);
|
||||
}
|
||||
|
||||
/* ─── Dark theme ──────────────────────────────────────────────────────────── */
|
||||
@@ -171,10 +178,17 @@
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
|
||||
/* Status stays the same */
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
/* Status — same dot colors, dark-mode-adjusted badge text */
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
/* badge variants — lighter fg for dark bg */
|
||||
--status-online-fg: oklch(0.696 0.170 151.328); /* emerald-400 */
|
||||
--status-online-bg: oklch(0.527 0.154 150.069 / 0.12);
|
||||
--status-offline-fg: oklch(0.704 0.191 22.216); /* red-400 */
|
||||
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.12);
|
||||
--status-degraded-fg: oklch(0.769 0.149 70.080); /* amber-400 */
|
||||
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.12);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -186,5 +200,23 @@
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
color-scheme: light;
|
||||
}
|
||||
/* next-themes кладёт класс dark на <html> — без этого нативные контролы (select) дают светлый popup и белый текст */
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/*
|
||||
Нативный <select>: в Chrome/Edge выпадающая часть может игнорировать тёмный фон страницы.
|
||||
Явные цвета + color-scheme выше дают читаемые пункты в светлой и тёмной теме.
|
||||
*/
|
||||
select {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
select option {
|
||||
background-color: var(--popover);
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-19
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./globals.css";
|
||||
@@ -20,18 +19,6 @@ export const metadata: Metadata = {
|
||||
description: "MikroTik network management platform",
|
||||
};
|
||||
|
||||
// Runs before React hydration — prevents flash of wrong theme (FOUC).
|
||||
const ANTI_FOUC = `
|
||||
try {
|
||||
var t = localStorage.getItem('rl-theme') || 'dark';
|
||||
var d = t === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
: t;
|
||||
document.documentElement.classList.toggle('dark', d === 'dark');
|
||||
document.documentElement.classList.toggle('light', d === 'light');
|
||||
} catch(e) {}
|
||||
`.trim()
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -43,13 +30,8 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<head>
|
||||
<Script id="anti-fouc" strategy="beforeInteractive">
|
||||
{ANTI_FOUC}
|
||||
</Script>
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ThemeProvider defaultTheme="dark" disableTransitionOnChange>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -81,6 +81,8 @@ CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
@@ -97,6 +99,7 @@ CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||
target TEXT NOT NULL,
|
||||
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
show_on_dashboard INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
@@ -134,6 +137,66 @@ CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time
|
||||
ON uptime_resource_samples(server_id, sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id INTEGER NOT NULL,
|
||||
dst_server_id INTEGER NOT NULL,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp',
|
||||
direction TEXT NOT NULL DEFAULT 'both',
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_run_at TEXT,
|
||||
last_tx_avg_mbps REAL,
|
||||
last_rx_avg_mbps REAL,
|
||||
last_status TEXT,
|
||||
last_error TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (dst_server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort
|
||||
ON uptime_speed_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
probe_id TEXT,
|
||||
src_server_id INTEGER NOT NULL,
|
||||
dst_server_id INTEGER NOT NULL,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
src_address TEXT,
|
||||
dst_address TEXT,
|
||||
src_interface_address TEXT,
|
||||
dst_interface_address TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp',
|
||||
direction TEXT NOT NULL DEFAULT 'both',
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
tx_avg_mbps REAL,
|
||||
rx_avg_mbps REAL,
|
||||
ping_rtt_ms INTEGER,
|
||||
ping_loss_pct INTEGER,
|
||||
ping_error TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'done',
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (dst_server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at
|
||||
ON uptime_speed_test_runs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
|
||||
// Lightweight schema evolution for existing databases without migrations
|
||||
@@ -148,6 +211,42 @@ const hasSrcInterfaceColumn = uptimeProbeCols.some((c) => c.name === "src_interf
|
||||
if (!hasSrcInterfaceColumn) {
|
||||
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN src_interface TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
const hasShowOnDashboardColumn = uptimeProbeCols.some((c) => c.name === "show_on_dashboard")
|
||||
if (!hasShowOnDashboardColumn) {
|
||||
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN show_on_dashboard INTEGER NOT NULL DEFAULT 0`)
|
||||
}
|
||||
|
||||
const uptimeSettingsCols = sqlite.prepare(`PRAGMA table_info('uptime_settings')`).all() as Array<{ name?: string }>
|
||||
const hasProbeIntervalColumn = uptimeSettingsCols.some((c) => c.name === "probe_interval_sec")
|
||||
if (!hasProbeIntervalColumn) {
|
||||
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN probe_interval_sec INTEGER NOT NULL DEFAULT 15`)
|
||||
}
|
||||
const hasSpeedIntervalColumn = uptimeSettingsCols.some((c) => c.name === "speed_interval_sec")
|
||||
if (!hasSpeedIntervalColumn) {
|
||||
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_interval_sec INTEGER NOT NULL DEFAULT 60`)
|
||||
}
|
||||
|
||||
const uptimeSpeedProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_speed_probes')`).all() as Array<{ name?: string }>
|
||||
const ensureSpeedProbeCol = (name: string, ddl: string) => {
|
||||
if (!uptimeSpeedProbeCols.some((c) => c.name === name)) sqlite.exec(ddl)
|
||||
}
|
||||
ensureSpeedProbeCol("last_run_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_run_at TEXT`)
|
||||
ensureSpeedProbeCol("last_tx_avg_mbps", `ALTER TABLE uptime_speed_probes ADD COLUMN last_tx_avg_mbps REAL`)
|
||||
ensureSpeedProbeCol("last_rx_avg_mbps", `ALTER TABLE uptime_speed_probes ADD COLUMN last_rx_avg_mbps REAL`)
|
||||
ensureSpeedProbeCol("last_status", `ALTER TABLE uptime_speed_probes ADD COLUMN last_status TEXT`)
|
||||
ensureSpeedProbeCol("last_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_error TEXT`)
|
||||
ensureSpeedProbeCol("last_ping_rtt_ms", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_rtt_ms INTEGER`)
|
||||
ensureSpeedProbeCol("last_ping_loss_pct", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_loss_pct INTEGER`)
|
||||
ensureSpeedProbeCol("last_ping_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_at TEXT`)
|
||||
ensureSpeedProbeCol("last_ping_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_error TEXT`)
|
||||
|
||||
const serverCols = sqlite.prepare(`PRAGMA table_info('servers')`).all() as Array<{ name?: string }>
|
||||
if (!serverCols.some((c) => c.name === "lan_subnet")) {
|
||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN lan_subnet TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
|
||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
|
||||
}
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days)
|
||||
@@ -161,4 +260,10 @@ SELECT 1, 1, 15, 14
|
||||
WHERE NOT EXISTS (SELECT 1 FROM uptime_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO evobgp_settings (id, base_url, api_key, enabled)
|
||||
SELECT 1, '', '', 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM evobgp_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
export const db = drizzle(sqlite, { schema })
|
||||
|
||||
@@ -27,6 +27,11 @@ export const servers = sqliteTable("servers", {
|
||||
comment: text("comment").notNull().default(""),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
|
||||
/** Подсеть LAN (home-router), текст из формы */
|
||||
lanSubnet: text("lan_subnet").notNull().default(""),
|
||||
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
|
||||
wanUplinks: text("wan_uplinks").notNull().default("[]"),
|
||||
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
@@ -133,6 +138,8 @@ export const uptimeSettings = sqliteTable("uptime_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(15),
|
||||
probeIntervalSec: integer("probe_interval_sec").notNull().default(15),
|
||||
speedIntervalSec: integer("speed_interval_sec").notNull().default(60),
|
||||
retentionDays: integer("retention_days").notNull().default(14),
|
||||
lastCollectedAt: text("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
@@ -151,6 +158,8 @@ export const uptimeProbes = sqliteTable("uptime_probes", {
|
||||
target: text("target").notNull(),
|
||||
probeFilter: text("probe_filter").notNull().default("—"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
/** Выводить пробу в блоке «Активные пробы» на дашборде */
|
||||
showOnDashboard: integer("show_on_dashboard", { mode: "boolean" }).notNull().default(false),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
@@ -184,6 +193,68 @@ export const uptimeResourceSamples = sqliteTable("uptime_resource_samples", {
|
||||
rosVersion: text("ros_version").notNull().default(""),
|
||||
})
|
||||
|
||||
export const uptimeSpeedProbes = sqliteTable("uptime_speed_probes", {
|
||||
id: text("id").primaryKey(),
|
||||
srcServerId: integer("src_server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
dstServerId: integer("dst_server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
srcInterface: text("src_interface").notNull().default(""),
|
||||
dstInterface: text("dst_interface").notNull().default(""),
|
||||
protocol: text("protocol", { enum: ["tcp", "udp"] }).notNull().default("tcp"),
|
||||
direction: text("direction", { enum: ["transmit", "receive", "both"] }).notNull().default("both"),
|
||||
durationSec: integer("duration_sec").notNull().default(10),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
lastRunAt: text("last_run_at"),
|
||||
lastTxAvgMbps: real("last_tx_avg_mbps"),
|
||||
lastRxAvgMbps: real("last_rx_avg_mbps"),
|
||||
lastStatus: text("last_status", { enum: ["done", "error"] }),
|
||||
lastError: text("last_error"),
|
||||
lastPingRttMs: integer("last_ping_rtt_ms"),
|
||||
lastPingLossPct: integer("last_ping_loss_pct"),
|
||||
lastPingAt: text("last_ping_at"),
|
||||
lastPingError: text("last_ping_error"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
// ── EvoBGP integration (URL + API key на сервере) ─────────────────────────────
|
||||
|
||||
export const evobgpSettings = sqliteTable("evobgp_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
baseUrl: text("base_url").notNull().default(""),
|
||||
apiKey: text("api_key").notNull().default(""),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", {
|
||||
id: text("id").primaryKey(),
|
||||
probeId: text("probe_id"),
|
||||
srcServerId: integer("src_server_id").notNull().references(() => servers.id, { onDelete: "cascade" }),
|
||||
dstServerId: integer("dst_server_id").notNull().references(() => servers.id, { onDelete: "cascade" }),
|
||||
srcInterface: text("src_interface").notNull().default(""),
|
||||
dstInterface: text("dst_interface").notNull().default(""),
|
||||
srcAddress: text("src_address"),
|
||||
dstAddress: text("dst_address"),
|
||||
srcInterfaceAddress:text("src_interface_address"),
|
||||
dstInterfaceAddress:text("dst_interface_address"),
|
||||
protocol: text("protocol", { enum: ["tcp", "udp"] }).notNull().default("tcp"),
|
||||
direction: text("direction", { enum: ["transmit", "receive", "both"] }).notNull().default("both"),
|
||||
durationSec: integer("duration_sec").notNull().default(10),
|
||||
txAvgMbps: real("tx_avg_mbps"),
|
||||
rxAvgMbps: real("rx_avg_mbps"),
|
||||
pingRttMs: integer("ping_rtt_ms"),
|
||||
pingLossPct: integer("ping_loss_pct"),
|
||||
pingError: text("ping_error"),
|
||||
status: text("status", { enum: ["done", "error"] }).notNull().default("done"),
|
||||
error: text("error"),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
// ── inferred types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type Server = typeof servers.$inferSelect
|
||||
@@ -198,3 +269,6 @@ export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
||||
export type UptimeProbeRow = typeof uptimeProbes.$inferSelect
|
||||
export type UptimeProbeSampleRow = typeof uptimeProbeSamples.$inferSelect
|
||||
export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect
|
||||
export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
|
||||
export type UptimeSpeedTestRunRow = typeof uptimeSpeedTestRuns.$inferSelect
|
||||
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
|
||||
|
||||
@@ -10,6 +10,9 @@ import filtersRoutes from "./routes/filters.js"
|
||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||
import trafficRoutes from "./routes/traffic.js"
|
||||
import uptimeRoutes from "./routes/uptime.js"
|
||||
import networkRoutes from "./routes/network.js"
|
||||
import evobgpRoutes from "./routes/evobgp.js"
|
||||
import probesRoutes from "./routes/probes.js"
|
||||
import { collectTrafficOnce, restartTrafficCollector, stopTrafficCollector } from "./services/traffic-collector.js"
|
||||
import { collectUptimeOnce, restartUptimeCollector, stopUptimeCollector } from "./services/uptime-collector.js"
|
||||
|
||||
@@ -31,7 +34,8 @@ app.setSerializerCompiler(serializerCompiler)
|
||||
// CORS — allow Next.js frontend
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
})
|
||||
|
||||
// ── routes ─────────────────────────────────────────────────────────────────────
|
||||
@@ -46,6 +50,9 @@ await app.register(filtersRoutes, { prefix: "/api" })
|
||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||
await app.register(trafficRoutes, { prefix: "/api" })
|
||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||
await app.register(networkRoutes, { prefix: "/api" })
|
||||
await app.register(evobgpRoutes, { prefix: "/api" })
|
||||
await app.register(probesRoutes, { prefix: "/api" })
|
||||
|
||||
restartTrafficCollector()
|
||||
void collectTrafficOnce()
|
||||
|
||||
@@ -107,7 +107,7 @@ function parseSessions(server: ServerRow, raw: RosBgpSession[]): BgpSessionRead[
|
||||
if (s["add-path-capability"] === "true") legacyCaps.push("ADD-PATH")
|
||||
if (s["graceful-restart-capability"] === "true") legacyCaps.push("Graceful Restart")
|
||||
if (s["extended-message-capability"] === "true") legacyCaps.push("Extended Messages")
|
||||
const caps = [...new Set([...localCaps, ...legacyCaps])]
|
||||
const caps = [...new Set([...localCaps, ...remoteCaps, ...legacyCaps])]
|
||||
|
||||
// Hold time: RouterOS 7 uses "hold-time" ("1m30s"), RouterOS 6 uses "active-holdtime" (seconds)
|
||||
const holdTime = parseDuration(s["hold-time"])
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { db } from "../db/index.js"
|
||||
import { evobgpSettings } from "../db/schema.js"
|
||||
|
||||
const PutEvobgpSchema = z.object({
|
||||
baseUrl: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
/** undefined — не менять; null или "" — очистить ключ; строка — новое значение */
|
||||
apiKey: z.union([z.string(), z.null()]).optional(),
|
||||
})
|
||||
|
||||
/** Опционально: проверить черновики до сохранения; поле отсутствует — взять из БД */
|
||||
const TestEvobgpSchema = z.object({
|
||||
baseUrl: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
function ensureEvobgpRow() {
|
||||
let row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0]
|
||||
if (!row) {
|
||||
db.insert(evobgpSettings).values({ id: 1 }).run()
|
||||
row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0]
|
||||
}
|
||||
return row!
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(raw: string): string {
|
||||
const trimmed = raw.trim().replace(/\/$/, "")
|
||||
if (!trimmed) return ""
|
||||
try {
|
||||
const u = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`)
|
||||
return `${u.protocol}//${u.host}`
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
interface EvoCatalogRaw {
|
||||
modules: { items: Array<{ id: string; name: string; type: string }> }
|
||||
domains: {
|
||||
items: Array<{
|
||||
module_id: string
|
||||
entry: { id: string; fqdn: string; community_id?: string | null }
|
||||
}>
|
||||
}
|
||||
asns: {
|
||||
items: Array<{
|
||||
module_id: string
|
||||
entry: { id: string; asn: number; community_id?: string | null }
|
||||
}>
|
||||
}
|
||||
ip_ranges: {
|
||||
items: Array<{
|
||||
module_id: string
|
||||
entry: { id: string; prefix: string; community_id: string }
|
||||
}>
|
||||
}
|
||||
communities: {
|
||||
items: Array<{ id: string; community?: string; title?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
function problemMessage(text: string, status: number): string {
|
||||
try {
|
||||
const j = JSON.parse(text) as { detail?: string; title?: string }
|
||||
return j.detail ?? j.title ?? `HTTP ${status}`
|
||||
} catch {
|
||||
return text || `HTTP ${status}`
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEvoJson<T>(root: string, path: string, token: string): Promise<T> {
|
||||
const url = `${root}${path.startsWith("/") ? "" : "/"}${path}`
|
||||
const ac = new AbortController()
|
||||
const t = setTimeout(() => ac.abort(), 90_000)
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: ac.signal,
|
||||
})
|
||||
const text = await res.text()
|
||||
if (!res.ok) {
|
||||
throw new Error(problemMessage(text, res.status))
|
||||
}
|
||||
return JSON.parse(text) as T
|
||||
} finally {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}
|
||||
|
||||
function credentialsFromDb(): { root: string; apiKey: string } | null {
|
||||
const row = ensureEvobgpRow()
|
||||
const root = normalizeBaseUrl(row.baseUrl)
|
||||
const apiKey = row.apiKey.trim()
|
||||
if (!root || !apiKey) return null
|
||||
return { root, apiKey }
|
||||
}
|
||||
|
||||
const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/evobgp/settings", async (_req, reply) => {
|
||||
const row = ensureEvobgpRow()
|
||||
return reply.send({
|
||||
baseUrl: row.baseUrl ?? "",
|
||||
enabled: row.enabled ?? false,
|
||||
secretConfigured: Boolean(row.apiKey?.trim()),
|
||||
})
|
||||
})
|
||||
|
||||
app.put("/evobgp/settings", async (req, reply) => {
|
||||
const parsed = PutEvobgpSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const cur = ensureEvobgpRow()
|
||||
let nextBase = cur.baseUrl
|
||||
let nextEnabled = cur.enabled
|
||||
let nextKey = cur.apiKey
|
||||
|
||||
if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim()
|
||||
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
|
||||
if (parsed.data.apiKey !== undefined) {
|
||||
nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim()
|
||||
}
|
||||
|
||||
db.update(evobgpSettings)
|
||||
.set({
|
||||
baseUrl: nextBase,
|
||||
enabled: nextEnabled,
|
||||
apiKey: nextKey,
|
||||
updatedAt: sql`(datetime('now'))`,
|
||||
})
|
||||
.where(eq(evobgpSettings.id, 1))
|
||||
.run()
|
||||
|
||||
const row = ensureEvobgpRow()
|
||||
return reply.send({
|
||||
baseUrl: row.baseUrl ?? "",
|
||||
enabled: row.enabled ?? false,
|
||||
secretConfigured: Boolean(row.apiKey?.trim()),
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Проверка Bearer: тело опционально — можно передать черновики URL/ключа с формы.
|
||||
* Для каждого поля: если в JSON ключ отсутствует — используется значение из БД;
|
||||
* если `apiKey` передана пустой строкой — ключ берётся из БД (проверка только нового URL).
|
||||
*/
|
||||
app.post("/evobgp/test", async (req, reply) => {
|
||||
const parsed = TestEvobgpSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const row = ensureEvobgpRow()
|
||||
const d = parsed.data
|
||||
const urlRaw = d.baseUrl !== undefined ? d.baseUrl : row.baseUrl
|
||||
const keyRaw =
|
||||
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
|
||||
const root = normalizeBaseUrl(urlRaw.trim())
|
||||
const token = keyRaw.trim()
|
||||
if (!root || !token) {
|
||||
return reply.status(400).send({
|
||||
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
|
||||
})
|
||||
}
|
||||
try {
|
||||
await fetchEvoJson<{ items: unknown[] }>(root, "/v1/modules?limit=1", token)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Ошибка EvoBGP"
|
||||
return reply.status(502).send({ error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Прокси к GET /v1/router-lists/catalog — учётные данные только из БД.
|
||||
*/
|
||||
app.post("/evobgp/catalog", async (_req, reply) => {
|
||||
const row = ensureEvobgpRow()
|
||||
if (!row.enabled) {
|
||||
return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" })
|
||||
}
|
||||
const cred = credentialsFromDb()
|
||||
if (!cred) {
|
||||
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
|
||||
}
|
||||
try {
|
||||
const catalog = await fetchEvoJson<EvoCatalogRaw>(cred.root, "/v1/router-lists/catalog", cred.apiKey)
|
||||
const modules = catalog.modules?.items ?? []
|
||||
const modMap = new Map(modules.map((m) => [m.id, m]))
|
||||
const commItems = catalog.communities?.items ?? []
|
||||
const commLabel = new Map<string, string>()
|
||||
for (const c of commItems) {
|
||||
const label = (c.community && c.title)
|
||||
? `${c.title} (${c.community})`
|
||||
: (c.title ?? c.community ?? c.id)
|
||||
commLabel.set(c.id, label)
|
||||
}
|
||||
|
||||
const fmtTime = new Date().toLocaleString("ru-RU")
|
||||
const fetchedAt = new Date().toISOString()
|
||||
|
||||
const domains = (catalog.domains?.items ?? []).map((rowItem) => {
|
||||
const mod = modMap.get(rowItem.module_id)
|
||||
const cid = rowItem.entry.community_id
|
||||
return {
|
||||
id: `${rowItem.module_id}:${rowItem.entry.id}`,
|
||||
domain: rowItem.entry.fqdn,
|
||||
resolvedIp: "—",
|
||||
asn: "—",
|
||||
purpose: mod?.name ?? "—",
|
||||
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
|
||||
updated: fmtTime,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
|
||||
const ipRanges = (catalog.ip_ranges?.items ?? []).map((rowItem) => {
|
||||
const mod = modMap.get(rowItem.module_id)
|
||||
const cid = rowItem.entry.community_id
|
||||
return {
|
||||
id: `${rowItem.module_id}:${rowItem.entry.id}`,
|
||||
cidr: rowItem.entry.prefix,
|
||||
asn: "—",
|
||||
country: "—",
|
||||
purpose: mod?.name ?? "—",
|
||||
filter: commLabel.get(cid) ?? cid,
|
||||
updated: fmtTime,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
|
||||
const asns = (catalog.asns?.items ?? []).map((rowItem) => {
|
||||
const mod = modMap.get(rowItem.module_id)
|
||||
const cid = rowItem.entry.community_id
|
||||
return {
|
||||
id: `${rowItem.module_id}:${rowItem.entry.id}`,
|
||||
asn: `AS${rowItem.entry.asn}`,
|
||||
org: "—",
|
||||
country: "—",
|
||||
prefixes: 0,
|
||||
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
|
||||
updated: fmtTime,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
|
||||
const communities = commItems.map((c) => ({
|
||||
id: c.id,
|
||||
value: c.community ?? c.id,
|
||||
name: c.title ?? c.community ?? c.id,
|
||||
description: "",
|
||||
type: "custom" as const,
|
||||
filterIds: [] as string[],
|
||||
serverCount: 0,
|
||||
prefixCount: 0,
|
||||
action: "permit" as const,
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
return reply.send({
|
||||
fetchedAt,
|
||||
modules: modules.map((m) => ({ id: m.id, name: m.name, type: m.type })),
|
||||
domains,
|
||||
ipRanges,
|
||||
asns,
|
||||
communities,
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Ошибка EvoBGP"
|
||||
return reply.status(502).send({ error: msg })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default evobgpRoutes
|
||||
+177
-18
@@ -1,7 +1,7 @@
|
||||
import { and, asc, eq, inArray } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
import { filterRules, servers } from "../db/schema.js"
|
||||
import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
@@ -121,6 +121,39 @@ function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
|
||||
}))
|
||||
}
|
||||
|
||||
/** Хоп для BGP filter из префикса рекурсивного статического маршрута (напр. 10.9.9.2/32 → 10.9.9.2) */
|
||||
function gatewayFromRecursiveDst(dstAddress: string): string {
|
||||
return (dstAddress ?? "").trim().split("/")[0]?.trim() ?? ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Импорт с роутера даёт только `set gateway` без `set out-interface` — в таком виде не отличить от «поломанного» GRE.
|
||||
* Сопоставляем hop с локальной таблицей recursive_routes и восстанавливаем gatewayTunnelId = rec:<id>.
|
||||
*/
|
||||
function enrichRulesWithRecursiveGateway(serverId: number, rules: ApiFilterRule[]): ApiFilterRule[] {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(recursiveRoutes)
|
||||
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.disabled, false)))
|
||||
.all()
|
||||
|
||||
return rules.map(rule => {
|
||||
if (rule.action === "blackhole") return rule
|
||||
const tid = (rule.gatewayTunnelId ?? "").trim()
|
||||
if (tid) return rule
|
||||
const gw = (rule.gateway ?? "").trim()
|
||||
if (!gw) return rule
|
||||
const candidates = rows.filter(r => gatewayFromRecursiveDst(r.dstAddress) === gw)
|
||||
if (candidates.length === 0) return rule
|
||||
const best = candidates.reduce((a, b) => (a.distance <= b.distance ? a : b))
|
||||
return {
|
||||
...rule,
|
||||
gatewayTunnelId: `rec:${best.id}`,
|
||||
gateway: gatewayFromRecursiveDst(best.dstAddress) || gw,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchServerFilters(server: ServerRow) {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const [greRaw, filterRaw] = await Promise.all([
|
||||
@@ -156,10 +189,12 @@ async function fetchServerFilters(server: ServerRow) {
|
||||
}
|
||||
})
|
||||
|
||||
const rules = filterRaw
|
||||
const rulesRaw = filterRaw
|
||||
.filter(r => (r.chain ?? "").trim().toLowerCase() === "bgp-in")
|
||||
.flatMap(parseFilterRule)
|
||||
|
||||
const rules = enrichRulesWithRecursiveGateway(server.id, rulesRaw)
|
||||
|
||||
return {
|
||||
serverId: String(server.id),
|
||||
rules,
|
||||
@@ -185,7 +220,70 @@ function toApiRulesets(serverRows: ServerRow[]) {
|
||||
}))
|
||||
}
|
||||
|
||||
function toRouterRuleBody(rules: ApiFilterRule[]): string {
|
||||
/** GRE: gatewayTunnelId = имя интерфейса; рекурсивный: rec:<id строки recursive_routes */
|
||||
function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway: string; outIface: string } {
|
||||
if (rule.action === "blackhole") return { gateway: "", outIface: "" }
|
||||
const tid = (rule.gatewayTunnelId ?? "").trim()
|
||||
if (tid.startsWith("rec:")) {
|
||||
const rid = Number.parseInt(tid.slice(4), 10)
|
||||
if (!Number.isFinite(rid)) {
|
||||
return { gateway: rule.gateway, outIface: "" }
|
||||
}
|
||||
const row = db
|
||||
.select()
|
||||
.from(recursiveRoutes)
|
||||
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.id, rid)))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
if (!row) return { gateway: rule.gateway, outIface: "" }
|
||||
const gw = gatewayFromRecursiveDst(row.dstAddress)
|
||||
return { gateway: gw || rule.gateway, outIface: "" }
|
||||
}
|
||||
return { gateway: rule.gateway, outIface: tid }
|
||||
}
|
||||
|
||||
function normalizeCommunity(c: string): string {
|
||||
return (c ?? "").trim()
|
||||
}
|
||||
|
||||
/** Одинаковый эффект на роутере при одинаковой community (blackhole vs gateway + out-interface) */
|
||||
function ruleEffectSignature(serverId: number, r: ApiFilterRule): string {
|
||||
if (r.action === "blackhole") return `bh:${normalizeCommunity(r.community)}`
|
||||
const { gateway, outIface } = resolveRouteTargets(serverId, r)
|
||||
return `rt:${normalizeCommunity(r.community)}:${gateway}:${outIface}`
|
||||
}
|
||||
|
||||
export type FilterRouterCompareStatus = "synced" | "drift" | "missing"
|
||||
|
||||
function compareDbRulesWithRouter(
|
||||
serverId: number,
|
||||
dbRules: ApiFilterRule[],
|
||||
remoteRules: ApiFilterRule[],
|
||||
): Record<string, FilterRouterCompareStatus> {
|
||||
const remoteSigByComm = new Map<string, string>()
|
||||
for (const rr of remoteRules) {
|
||||
const c = normalizeCommunity(rr.community)
|
||||
if (!remoteSigByComm.has(c)) {
|
||||
remoteSigByComm.set(c, ruleEffectSignature(serverId, rr))
|
||||
}
|
||||
}
|
||||
const out: Record<string, FilterRouterCompareStatus> = {}
|
||||
for (const dr of dbRules) {
|
||||
const c = normalizeCommunity(dr.community)
|
||||
const sigD = ruleEffectSignature(serverId, dr)
|
||||
const sigR = remoteSigByComm.get(c)
|
||||
if (sigR === undefined) {
|
||||
out[c] = "missing"
|
||||
} else if (sigR !== sigD) {
|
||||
out[c] = "drift"
|
||||
} else {
|
||||
out[c] = "synced"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
|
||||
return rules.map((rule, i) => {
|
||||
const kw = i === 0 ? "if" : "} else if"
|
||||
const comment = rule.description ? ` # ${rule.description}` : ""
|
||||
@@ -197,13 +295,15 @@ function toRouterRuleBody(rules: ApiFilterRule[]): string {
|
||||
" accept;",
|
||||
].filter(Boolean).join("\n")
|
||||
}
|
||||
return [
|
||||
const { gateway, outIface } = resolveRouteTargets(serverId, rule)
|
||||
const lines = [
|
||||
` ${kw} (bgp-communities.has("${rule.community}")) {`,
|
||||
comment,
|
||||
` set gateway ${rule.gateway};`,
|
||||
` set out-interface ${rule.gatewayTunnelId};`,
|
||||
" accept;",
|
||||
].filter(Boolean).join("\n")
|
||||
` set gateway ${gateway};`,
|
||||
]
|
||||
if (outIface) lines.push(` set out-interface ${outIface};`)
|
||||
lines.push(" accept;")
|
||||
return lines.filter(Boolean).join("\n")
|
||||
}).join("\n")
|
||||
}
|
||||
|
||||
@@ -228,21 +328,80 @@ async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
|
||||
}
|
||||
|
||||
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
/** Сравнение правил в БД с живым bgp-in на MikroTik (один запрос API к роутеру) */
|
||||
app.get("/filters/router-compare", async (req, reply) => {
|
||||
const q = req.query as { serverId?: string | number }
|
||||
const serverId = parseServerId(q.serverId)
|
||||
if (serverId === null) {
|
||||
return reply.status(400).send({ error: "serverId is required" })
|
||||
}
|
||||
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
const remote = await fetchServerFilters(server)
|
||||
const rows = db
|
||||
.select()
|
||||
.from(filterRules)
|
||||
.where(eq(filterRules.serverId, serverId))
|
||||
.orderBy(asc(filterRules.sortOrder))
|
||||
.all()
|
||||
|
||||
const dbRules: ApiFilterRule[] = rows.map(r => ({
|
||||
id: String(r.id),
|
||||
community: r.community,
|
||||
communityName: r.communityName ?? undefined,
|
||||
action: r.action,
|
||||
gateway: r.gateway,
|
||||
gatewayTunnelId: r.gatewayTunnelId,
|
||||
description: r.description,
|
||||
}))
|
||||
|
||||
const byCommunity = compareDbRulesWithRouter(serverId, dbRules, remote.rules)
|
||||
return reply.send({ byCommunity })
|
||||
} catch (err) {
|
||||
app.log.error({ serverId, err: String(err) }, "filters router-compare failed")
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
}
|
||||
})
|
||||
|
||||
/** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */
|
||||
app.get("/filters/gre-tunnels", async (req, reply) => {
|
||||
const q = req.query as { serverId?: string | number }
|
||||
const sid = parseServerId(q.serverId)
|
||||
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
|
||||
if (sid !== null) {
|
||||
const server = enabledServers.find(s => s.id === sid)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
try {
|
||||
const remote = await fetchServerFilters(server)
|
||||
return reply.send({ tunnels: remote.tunnels })
|
||||
} catch {
|
||||
return reply.send({ tunnels: [] as LiveGreTunnel[] })
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(enabledServers.map(async (server) => {
|
||||
try {
|
||||
const remote = await fetchServerFilters(server)
|
||||
return remote.tunnels
|
||||
} catch {
|
||||
return [] as LiveGreTunnel[]
|
||||
}
|
||||
}))
|
||||
return reply.send({ tunnels: results.flat() })
|
||||
})
|
||||
|
||||
/** Только правила фильтров из БД (без опроса MikroTik за GRE) */
|
||||
app.get("/filters/rules", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const dbRulesets = toApiRulesets(allServers)
|
||||
const results = await Promise.all(allServers.map(async (server) => {
|
||||
try {
|
||||
const remote = await fetchServerFilters(server)
|
||||
return { serverId: String(server.id), tunnels: remote.tunnels }
|
||||
} catch {
|
||||
return { serverId: String(server.id), tunnels: [] as LiveGreTunnel[] }
|
||||
}
|
||||
}))
|
||||
|
||||
return reply.send({
|
||||
rulesets: dbRulesets,
|
||||
greTunnels: results.flatMap(r => r.tunnels),
|
||||
greTunnels: [] as LiveGreTunnel[],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -315,7 +474,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}))
|
||||
|
||||
if (rules.length > 0) {
|
||||
const ruleBody = toRouterRuleBody(rules)
|
||||
const ruleBody = toRouterRuleBody(server.id, rules)
|
||||
await client.post("/routing/filter/rule", {
|
||||
chain: "bgp-in",
|
||||
comment: `RouterLists: ${server.name || server.host}`,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { promises as dns } from "node:dns"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { z } from "zod"
|
||||
|
||||
const IPV4 =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
|
||||
|
||||
function isIpv4(s: string): boolean {
|
||||
return IPV4.test(s.trim())
|
||||
}
|
||||
|
||||
/** Кэш DNS на процесс (GRE outer меняется редко). */
|
||||
const resolveCache = new Map<string, { ip: string | null; expires: number }>()
|
||||
const TTL_MS = 5 * 60 * 1000
|
||||
|
||||
async function resolveHostToIpv4(hostname: string): Promise<string | null> {
|
||||
const key = hostname.trim().toLowerCase()
|
||||
if (!key || isIpv4(key)) return isIpv4(key) ? key.trim() : null
|
||||
|
||||
const hit = resolveCache.get(key)
|
||||
if (hit && hit.expires > Date.now()) return hit.ip
|
||||
|
||||
try {
|
||||
const { address } = await dns.lookup(key, { family: 4 })
|
||||
const ip = isIpv4(address) ? address : null
|
||||
resolveCache.set(key, { ip, expires: Date.now() + TTL_MS })
|
||||
return ip
|
||||
} catch {
|
||||
resolveCache.set(key, { ip: null, expires: Date.now() + TTL_MS })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const ResolveHostsBodySchema = z.object({
|
||||
hosts: z.array(z.string().max(253)).max(32),
|
||||
})
|
||||
|
||||
const networkRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
/**
|
||||
* Резолв FQDN из GRE outer (remote-address / local-address) в IPv4 для сопоставления с WAN в каталоге.
|
||||
*/
|
||||
app.post("/network/resolve-hosts", async (req, reply) => {
|
||||
const parsed = ResolveHostsBodySchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Invalid body", details: parsed.error.flatten() })
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const unique: string[] = []
|
||||
for (const h of parsed.data.hosts) {
|
||||
const k = h.trim().toLowerCase()
|
||||
if (!k || seen.has(k)) continue
|
||||
seen.add(k)
|
||||
unique.push(h.trim())
|
||||
}
|
||||
|
||||
const results: Record<string, string | null> = {}
|
||||
for (const host of unique) {
|
||||
const key = host.toLowerCase()
|
||||
results[key] = await resolveHostToIpv4(host)
|
||||
}
|
||||
|
||||
return reply.send({ results })
|
||||
})
|
||||
}
|
||||
|
||||
export default networkRoutes
|
||||
@@ -0,0 +1,412 @@
|
||||
import dns from "node:dns/promises"
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import type { RosIpRoute, RosPingResult } from "../types/server.js"
|
||||
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
|
||||
import { abortAfterMs, mergeAbortSignals } from "../utils/abort-signals.js"
|
||||
|
||||
/**
|
||||
* Документация REST API RouterOS: если команда «бесконечна», сессия всё равно закрывается
|
||||
* через ~60 с; параметры команды **не** продлевают этот лимит.
|
||||
* @see https://help.mikrotik.com/docs/display/ROS/REST+API — раздел «Timeout»
|
||||
*/
|
||||
const ROS_REST_SESSION_MAX_MS = 58_000
|
||||
|
||||
/** Парсинг ввода пользователя (800ms, 1s, 00:00:01) → миллисекунды (10–3000). */
|
||||
function parseTraceHopInputToMs(raw: unknown): number {
|
||||
const s = String(raw ?? "").trim().toLowerCase()
|
||||
if (!s) return 1000
|
||||
const msM = /^(\d+)ms$/.exec(s)
|
||||
if (msM) return Math.min(3000, Math.max(10, Number(msM[1])))
|
||||
const secM = /^(\d+(?:\.\d+)?)s$/.exec(s)
|
||||
if (secM) return Math.min(3000, Math.max(10, Number(secM[1]) * 1000))
|
||||
const hm = /^(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/.exec(String(raw ?? "").trim())
|
||||
if (hm) {
|
||||
const h = Number(hm[1]), m = Number(hm[2]), sec = Number(hm[3])
|
||||
const frac = hm[4] ? Number(hm[4].padEnd(3, "0").slice(0, 3)) : 0
|
||||
const t = ((h * 60 + m) * 60 + sec) * 1000 + frac
|
||||
return Math.min(3000, Math.max(10, t))
|
||||
}
|
||||
return 1000
|
||||
}
|
||||
|
||||
/** В REST JSON для /tool/traceroute нужен формат времени HH:MM:SS (не «1s»). */
|
||||
function formatMsAsRosTracerouteTimeout(ms: number): string {
|
||||
const capped = Math.min(3000, Math.max(10, Math.round(ms)))
|
||||
const totalSeconds = Math.floor(capped / 1000)
|
||||
const milli = capped % 1000
|
||||
const ss = totalSeconds % 60
|
||||
const mmTotal = Math.floor(totalSeconds / 60)
|
||||
const mm = mmTotal % 60
|
||||
const hh = Math.floor(mmTotal / 60)
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
if (milli === 0) return `${pad(hh)}:${pad(mm)}:${pad(ss)}`
|
||||
return `${pad(hh)}:${pad(mm)}:${pad(ss)}.${String(milli).padStart(3, "0")}`
|
||||
}
|
||||
|
||||
function traceHopTimeoutForApi(raw: unknown): string {
|
||||
return formatMsAsRosTracerouteTimeout(parseTraceHopInputToMs(raw))
|
||||
}
|
||||
|
||||
function parseServerId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function ipv4ToUint(ip: string): number | null {
|
||||
const p = ip.split(".").map((x) => Number.parseInt(x, 10))
|
||||
if (p.length !== 4 || p.some((x) => !Number.isFinite(x) || x < 0 || x > 255)) return null
|
||||
return (((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0)
|
||||
}
|
||||
|
||||
function maskFromLen(len: number): number {
|
||||
if (len <= 0) return 0
|
||||
if (len >= 32) return 0xffffffff
|
||||
return (~((1 << (32 - len)) - 1)) >>> 0
|
||||
}
|
||||
|
||||
function parseDstRoute(dst: string): { net: number; maskBits: number } | null {
|
||||
const t = dst.trim()
|
||||
if (!t) return null
|
||||
if (!t.includes("/")) {
|
||||
const ip = ipv4ToUint(t)
|
||||
return ip === null ? null : { net: ip, maskBits: 32 }
|
||||
}
|
||||
const [addr, mb] = t.split("/")
|
||||
const ip = ipv4ToUint(addr.trim())
|
||||
const maskBits = Number.parseInt((mb ?? "").trim(), 10)
|
||||
if (ip === null || !Number.isFinite(maskBits) || maskBits < 0 || maskBits > 32) return null
|
||||
const mask = maskFromLen(maskBits)
|
||||
return { net: ip & mask, maskBits }
|
||||
}
|
||||
|
||||
function ipMatchesRoute(destIp: string, dstAddressField: string): boolean {
|
||||
const ip = ipv4ToUint(destIp.trim())
|
||||
if (ip === null) return false
|
||||
const cidr = parseDstRoute(dstAddressField)
|
||||
if (!cidr) return false
|
||||
const mask = maskFromLen(cidr.maskBits)
|
||||
return (ip & mask) === (cidr.net & mask)
|
||||
}
|
||||
|
||||
function fmtPingRouterOs(results: RosPingResult[], host: string): string {
|
||||
const lines = [`PING ${host}`]
|
||||
for (const r of results) {
|
||||
if (!r.seq && !r.sent && r.status !== "timeout") continue
|
||||
if (r.status === "timeout") {
|
||||
lines.push(` seq=${r.seq ?? "?"} timeout`)
|
||||
} else if (r.seq && r.time !== undefined) {
|
||||
lines.push(` seq=${r.seq} ttl=${r.ttl ?? "?"} time=${r.time}`)
|
||||
}
|
||||
}
|
||||
const sum = [...results].reverse().find((r) => r.sent)
|
||||
if (sum) {
|
||||
lines.push(` sent=${sum.sent} received=${sum.received ?? "?"} packet-loss=${sum["packet-loss"] ?? "?"}`)
|
||||
if (sum.time && sum.sent) lines.push(` avg-rtt=${sum.time}`)
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function fmtTraceroute(rows: unknown): string {
|
||||
if (!Array.isArray(rows)) return typeof rows === "string" ? rows : JSON.stringify(rows, null, 2)
|
||||
const hdr = " # ADDRESS LOSS LAST AVG"
|
||||
const lines = [hdr]
|
||||
rows.forEach((row, i) => {
|
||||
const r = row as Record<string, string | undefined>
|
||||
const addr = String(r.address ?? r.host ?? r["from-address"] ?? "?")
|
||||
const loss = String(r.loss ?? r["packet-loss"] ?? "—")
|
||||
const last = String(r["last"] ?? r.time ?? "—")
|
||||
const avg = String(r.avg ?? r["avg-rtt"] ?? "—")
|
||||
lines.push(
|
||||
` ${String(i + 1).padStart(2)} ${addr.padEnd(40)} ${loss.padEnd(6)} ${last.padStart(8)} ${avg.padStart(8)}`,
|
||||
)
|
||||
})
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function fmtBandwidth(rows: Array<Record<string, string>>): string {
|
||||
if (rows.length === 0) return "(no bandwidth-test output)"
|
||||
const lines = rows.map((r) => {
|
||||
const tx = r["tx-current"] ?? r["tx-total-average"] ?? ""
|
||||
const rx = r["rx-current"] ?? r["rx-total-average"] ?? ""
|
||||
const sec = r["test-duration"] ?? ""
|
||||
const parts = [`tx=${tx}`, `rx=${rx}`]
|
||||
if (sec) parts.push(`t=${sec}`)
|
||||
return ` ${parts.join(" ")}`
|
||||
})
|
||||
return ["bandwidth-test:", ...lines].join("\n")
|
||||
}
|
||||
|
||||
function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): string {
|
||||
const matches = routes.filter((r) => {
|
||||
const dst = String(r["dst-address"] ?? "").trim()
|
||||
if (!dst) return false
|
||||
if (String(r.active ?? "true").toLowerCase() === "false") return false
|
||||
return ipMatchesRoute(destIp, dst)
|
||||
})
|
||||
if (matches.length === 0) {
|
||||
return `no route for ${destIp} (active routes checked: ${routes.length})`
|
||||
}
|
||||
matches.sort((a, b) => {
|
||||
const da = parseDstRoute(String(a["dst-address"] ?? ""))
|
||||
const db = parseDstRoute(String(b["dst-address"] ?? ""))
|
||||
return (db?.maskBits ?? 0) - (da?.maskBits ?? 0)
|
||||
})
|
||||
const best = matches[0]!
|
||||
const lines = [
|
||||
`lookup ${destIp} → best match:`,
|
||||
` dst-address: ${best["dst-address"] ?? "—"}`,
|
||||
` gateway: ${best.gateway ?? best.interface ?? "—"}`,
|
||||
` distance: ${best.distance ?? "—"}`,
|
||||
` routing-mark: ${best["routing-mark"] ?? "main"}`,
|
||||
]
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
async function dnsLookupText(name: string, type: string): Promise<string> {
|
||||
const t = type.toUpperCase()
|
||||
const lines: string[] = [`;; QUESTION: ${name} ${t}`, ""]
|
||||
try {
|
||||
if (t === "A") {
|
||||
const addrs = await dns.resolve4(name)
|
||||
addrs.forEach((a) => lines.push(`${name}. IN A ${a}`))
|
||||
} else if (t === "AAAA") {
|
||||
const addrs = await dns.resolve6(name)
|
||||
addrs.forEach((a) => lines.push(`${name}. IN AAAA ${a}`))
|
||||
} else if (t === "MX") {
|
||||
const mx = await dns.resolveMx(name)
|
||||
mx.sort((a, b) => a.priority - b.priority)
|
||||
mx.forEach((m) => lines.push(`${name}. IN MX ${m.priority} ${m.exchange}`))
|
||||
} else if (t === "NS") {
|
||||
const ns = await dns.resolveNs(name)
|
||||
ns.forEach((n) => lines.push(`${name}. IN NS ${n}`))
|
||||
} else if (t === "TXT") {
|
||||
const tx = await dns.resolveTxt(name)
|
||||
tx.forEach((chunks) => lines.push(`${name}. IN TXT "${chunks.join("")}"`))
|
||||
} else if (t === "CNAME") {
|
||||
const c = await dns.resolveCname(name)
|
||||
lines.push(`${name}. IN CNAME ${c}`)
|
||||
} else if (t === "PTR") {
|
||||
const raw = name.trim()
|
||||
if (/^[\d.]+$/.test(raw)) {
|
||||
const hosts = await dns.reverse(raw)
|
||||
hosts.forEach((h) => lines.push(`${raw}.in-addr.arpa. IN PTR ${h}`))
|
||||
} else {
|
||||
const ptr = await dns.resolvePtr(raw.includes(".arpa") ? raw : `${raw}.in-addr.arpa`)
|
||||
ptr.forEach((p) => lines.push(`${raw}. IN PTR ${p}`))
|
||||
}
|
||||
} else {
|
||||
lines.push(`;; тип «${t}» не поддержан в живом режиме (используйте A, AAAA, MX, NS, TXT, CNAME, PTR)`)
|
||||
}
|
||||
} catch (e) {
|
||||
lines.push(`;; ERROR: ${e instanceof Error ? e.message : String(e)}`)
|
||||
lines.push(";; (резолв выполняется на хосте бекенда, не на MikroTik)")
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
async function mtuDiscover(client: MikrotikClient, address: string, srcIpv4: string | null): Promise<string> {
|
||||
const sizes = [1500, 1492, 1480, 1476, 1472, 1468, 1400, 1280, 1024, 576]
|
||||
const lines: string[] = [`MTU discovery (do-not-fragment ping) → ${address}`, ""]
|
||||
let mtuFound = 0
|
||||
for (const size of sizes) {
|
||||
try {
|
||||
const body: Record<string, string> = {
|
||||
address,
|
||||
count: "1",
|
||||
size: String(size),
|
||||
interval: "0.2s",
|
||||
/** REST /tool/ping: как в CLI — `do-not-fragment=yes`, не `dont-fragment` */
|
||||
"do-not-fragment": "true",
|
||||
}
|
||||
if (srcIpv4) body["src-address"] = srcIpv4
|
||||
const rows = await client.post<RosPingResult[]>("/tool/ping", body, 25_000)
|
||||
const timeout = rows.some((r) => r.status === "timeout")
|
||||
if (!timeout) {
|
||||
lines.push(` ${String(size).padStart(4)} ✓ ok`)
|
||||
mtuFound = size
|
||||
break
|
||||
}
|
||||
lines.push(` ${String(size).padStart(4)} ✗ fragment needed / timeout`)
|
||||
} catch (e) {
|
||||
lines.push(` ${String(size).padStart(4)} ✗ ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
}
|
||||
lines.push("")
|
||||
lines.push(mtuFound > 0 ? `MTU (DF): ~${mtuFound} bytes` : "MTU: could not determine")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function resolveBtestPeer(remoteHost: string, explicitDstId?: number) {
|
||||
if (explicitDstId != null && Number.isFinite(explicitDstId)) {
|
||||
const s = db.select().from(servers).where(eq(servers.id, explicitDstId)).limit(1).all()[0]
|
||||
if (s) return s
|
||||
}
|
||||
const norm = remoteHost.trim().toLowerCase()
|
||||
return db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
.find((x) => x.host.trim().toLowerCase() === norm)
|
||||
}
|
||||
|
||||
const probesRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.post("/servers/:id/probes/run", async (req, reply) => {
|
||||
const sid = parseServerId(String((req.params as { id?: string }).id ?? ""))
|
||||
if (sid === null) return reply.status(400).send({ error: "Invalid server id" })
|
||||
|
||||
const server = db.select().from(servers).where(eq(servers.id, sid)).limit(1).all()[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const body = req.body as {
|
||||
tool?: string
|
||||
target?: string
|
||||
pingCount?: number
|
||||
pingSize?: number
|
||||
pingTtl?: number
|
||||
traceProto?: string
|
||||
traceMaxHops?: number
|
||||
/** Таймаут одной пробы: 800ms, 1s или 00:00:01 (в API уходит как HH:MM:SS) */
|
||||
traceHopTimeout?: string
|
||||
/** Число проб на хоп (1–3). Для REST рекомендуется 1 из‑за лимита сессии ~60 с */
|
||||
traceProbeCount?: number
|
||||
/** Резолвить адреса хопов в имена (RouterOS: use-dns yes|no → REST true|false) */
|
||||
traceUseDns?: boolean
|
||||
dnsType?: string
|
||||
bwRemoteAddress?: string
|
||||
dstServerId?: number
|
||||
bwProto?: string
|
||||
bwDuration?: number
|
||||
}
|
||||
|
||||
const tool = String(body.tool ?? "").trim() as
|
||||
| "ping"
|
||||
| "traceroute"
|
||||
| "bandwidth"
|
||||
| "dns"
|
||||
| "route"
|
||||
| "mtu"
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const srcIpv4 = await resolveRosSrcIpv4(server.host)
|
||||
|
||||
try {
|
||||
let output = ""
|
||||
|
||||
switch (tool) {
|
||||
case "ping": {
|
||||
const target = String(body.target ?? "").trim()
|
||||
if (!target) return reply.status(400).send({ error: "target required" })
|
||||
const count = Math.min(100, Math.max(1, Number(body.pingCount) || 5))
|
||||
const size = Math.min(8192, Math.max(28, Number(body.pingSize) || 64))
|
||||
const ttl = Math.min(255, Math.max(1, Number(body.pingTtl) || 64))
|
||||
const pingBody: Record<string, string> = {
|
||||
address: target,
|
||||
count: String(count),
|
||||
size: String(size),
|
||||
ttl: String(ttl),
|
||||
interval: "0.2s",
|
||||
}
|
||||
if (srcIpv4) pingBody["src-address"] = srcIpv4
|
||||
const rows = await client.post<RosPingResult[]>("/tool/ping", pingBody, 120_000)
|
||||
output = fmtPingRouterOs(rows, target)
|
||||
break
|
||||
}
|
||||
case "traceroute": {
|
||||
const target = String(body.target ?? "").trim()
|
||||
if (!target) return reply.status(400).send({ error: "target required" })
|
||||
const proto = body.traceProto === "udp" || body.traceProto === "tcp" ? body.traceProto : "icmp"
|
||||
const maxHopsRequested = Math.min(64, Math.max(1, Number(body.traceMaxHops) || 30))
|
||||
const probeCount = Math.min(3, Math.max(1, Number(body.traceProbeCount) || 1))
|
||||
const hopMs = parseTraceHopInputToMs(body.traceHopTimeout)
|
||||
const hopTimeoutRos = traceHopTimeoutForApi(body.traceHopTimeout)
|
||||
const budgetMs = ROS_REST_SESSION_MAX_MS - 5000
|
||||
const worstMsPerHop = probeCount * hopMs
|
||||
const maxFeasibleHops = Math.max(1, Math.floor(budgetMs / worstMsPerHop))
|
||||
const effectiveMaxHops = Math.min(maxHopsRequested, maxFeasibleHops)
|
||||
const traceWallMs = ROS_REST_SESSION_MAX_MS
|
||||
const useDns = Boolean(body.traceUseDns)
|
||||
const traceBody: Record<string, string> = {
|
||||
address: target,
|
||||
protocol: proto,
|
||||
"max-hops": String(effectiveMaxHops),
|
||||
timeout: hopTimeoutRos,
|
||||
count: String(probeCount),
|
||||
"use-dns": useDns ? "true" : "false",
|
||||
}
|
||||
if (srcIpv4) traceBody["src-address"] = srcIpv4
|
||||
const disconnectAbort = new AbortController()
|
||||
const onClientClose = () => disconnectAbort.abort()
|
||||
req.raw.once("close", onClientClose)
|
||||
try {
|
||||
const sig = mergeAbortSignals(disconnectAbort.signal, abortAfterMs(traceWallMs))
|
||||
const rows = await client.post<unknown>("/tool/traceroute", traceBody, traceWallMs, sig)
|
||||
const note =
|
||||
effectiveMaxHops < maxHopsRequested
|
||||
? `;; REST API RouterOS: сессия ~60 с (параметры traceroute не продлевают лимит). max-hops снижен с ${maxHopsRequested} до ${effectiveMaxHops} (count=${probeCount}, timeout=${hopTimeoutRos}).\n`
|
||||
: ""
|
||||
output = note + fmtTraceroute(rows)
|
||||
} finally {
|
||||
req.raw.off("close", onClientClose)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "bandwidth": {
|
||||
const remote = String(body.bwRemoteAddress ?? "").trim()
|
||||
if (!remote) return reply.status(400).send({ error: "bwRemoteAddress required" })
|
||||
const dst = resolveBtestPeer(remote, body.dstServerId)
|
||||
if (!dst) {
|
||||
return reply.status(400).send({
|
||||
error: "Не найден сервер назначения для bandwidth-test: добавьте узел с host = GRE remote или укажите dstServerId",
|
||||
})
|
||||
}
|
||||
const protocol = body.bwProto === "udp" ? "udp" : "tcp"
|
||||
const durationSec = Math.max(3, Math.min(120, Number(body.bwDuration) || 10))
|
||||
const rows = await client.bandwidthTest({
|
||||
address: remote,
|
||||
user: dst.username,
|
||||
password: dst.password,
|
||||
protocol,
|
||||
direction: "both",
|
||||
durationSec,
|
||||
})
|
||||
output = fmtBandwidth(rows)
|
||||
break
|
||||
}
|
||||
case "dns": {
|
||||
const target = String(body.target ?? "").trim()
|
||||
if (!target) return reply.status(400).send({ error: "target required" })
|
||||
const dtype = String(body.dnsType ?? "A").trim() || "A"
|
||||
output = await dnsLookupText(target, dtype)
|
||||
break
|
||||
}
|
||||
case "route": {
|
||||
const target = String(body.target ?? "").trim()
|
||||
if (!target) return reply.status(400).send({ error: "target required" })
|
||||
const routes = await client.getIpRoutes()
|
||||
output = fmtRouteLookup(target, routes)
|
||||
break
|
||||
}
|
||||
case "mtu": {
|
||||
const target = String(body.target ?? "").trim()
|
||||
if (!target) return reply.status(400).send({ error: "target required" })
|
||||
output = await mtuDiscover(client, target, srcIpv4)
|
||||
break
|
||||
}
|
||||
default:
|
||||
return reply.status(400).send({ error: `unknown tool: ${tool}` })
|
||||
}
|
||||
|
||||
return reply.send({ output })
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof DOMException && err.name === "AbortError"
|
||||
? "Запрос отменён (клиент закрыл соединение или истёк лимит времени для traceroute)."
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
return reply.send({ output: `error: ${msg}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default probesRoutes
|
||||
@@ -2,7 +2,7 @@ import { desc, eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, serverSnapshots } from "../db/schema.js"
|
||||
import type { ServerRead } from "../types/server.js"
|
||||
import type { ServerRead, WanUplinkRead } from "../types/server.js"
|
||||
import {
|
||||
ServerCreateSchema,
|
||||
ServerUpdateSchema,
|
||||
@@ -12,12 +12,38 @@ import {
|
||||
} from "../types/server.js"
|
||||
import { pollServer, toSnapshotRead } from "../services/poller.js"
|
||||
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
||||
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||
|
||||
function parseWanUplinksJson(raw: string | null | undefined): WanUplinkRead[] {
|
||||
if (raw == null || raw === "") return []
|
||||
try {
|
||||
const data = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(data)) return []
|
||||
const out: WanUplinkRead[] = []
|
||||
for (const row of data) {
|
||||
if (typeof row !== "object" || row === null) continue
|
||||
const r = row as Record<string, unknown>
|
||||
out.push({
|
||||
id: typeof r.id === "string" ? r.id : "",
|
||||
name: typeof r.name === "string" ? r.name : "",
|
||||
isp: typeof r.isp === "string" ? r.isp : "",
|
||||
iface: typeof r.iface === "string" ? r.iface : "",
|
||||
ip: typeof r.ip === "string" ? r.ip : "",
|
||||
maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0,
|
||||
maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0,
|
||||
})
|
||||
}
|
||||
return out
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge a server row with its latest snapshot into the frontend-compatible shape */
|
||||
function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead {
|
||||
return {
|
||||
@@ -35,6 +61,8 @@ function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerR
|
||||
asn: server.asn,
|
||||
comment: server.comment,
|
||||
enabled: server.enabled,
|
||||
lanSubnet: server.lanSubnet ?? "",
|
||||
wanUplinks: parseWanUplinksJson(server.wanUplinks),
|
||||
createdAt: server.createdAt,
|
||||
updatedAt: server.updatedAt,
|
||||
// snapshot fields (null if never polled)
|
||||
@@ -121,12 +149,30 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
// GET /api/servers/:id/ros-src-address — IPv4 для src-address в RouterOS (не FQDN)
|
||||
app.get("/:id/ros-src-address", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const server = db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, req.params.id))
|
||||
.limit(1).all()[0]
|
||||
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
const ipv4 = await resolveRosSrcIpv4(server.host)
|
||||
return reply.send({ host: server.host, ipv4 })
|
||||
})
|
||||
|
||||
// POST /api/servers
|
||||
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
|
||||
const now = new Date().toISOString()
|
||||
const { wanUplinks, ...rest } = req.body
|
||||
const [inserted] = db
|
||||
.insert(servers)
|
||||
.values({ ...req.body, createdAt: now, updatedAt: now })
|
||||
.values({
|
||||
...rest,
|
||||
wanUplinks: JSON.stringify(wanUplinks ?? []),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.all()
|
||||
return reply.status(201).send(toServerRead(inserted, undefined))
|
||||
@@ -155,9 +201,19 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
if (!existing) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const body = req.body
|
||||
const { wanUplinks, ...rest } = body
|
||||
const setPayload: Record<string, unknown> = { updatedAt: new Date().toISOString() }
|
||||
for (const [k, v] of Object.entries(rest)) {
|
||||
if (v !== undefined) setPayload[k] = v
|
||||
}
|
||||
if (wanUplinks !== undefined) {
|
||||
setPayload.wanUplinks = JSON.stringify(wanUplinks)
|
||||
}
|
||||
|
||||
const [updated] = db
|
||||
.update(servers)
|
||||
.set({ ...req.body, updatedAt: new Date().toISOString() })
|
||||
.set(setPayload as Partial<ServerRow>)
|
||||
.where(eq(servers.id, req.params.id))
|
||||
.returning()
|
||||
.all()
|
||||
|
||||
+381
-50
@@ -1,15 +1,18 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import type { RosIpAddress } from "../types/server.js"
|
||||
import {
|
||||
collectUptimeOnce,
|
||||
parsePing,
|
||||
readProbeRows,
|
||||
readProbeSamplesSince,
|
||||
readResourceSamplesSince,
|
||||
readUptimeSettings,
|
||||
replaceProbes,
|
||||
updateProbeShowOnDashboard,
|
||||
updateUptimeSettings,
|
||||
} from "../services/uptime-collector.js"
|
||||
|
||||
@@ -31,6 +34,28 @@ function toSeries(values: number[], target = 40): number[] {
|
||||
return [...Array(target - values.length).fill(values[0] ?? 0), ...values]
|
||||
}
|
||||
|
||||
/**
|
||||
* Один «рабочий» IPv4/IPv6 для интерфейса: пропускаем записи /ip/address с disabled/invalid,
|
||||
* среди оставшихся предпочитаем статические адреса динамическим (иначе БТ может цепляться к отключённому IP).
|
||||
*/
|
||||
function firstUsableIpOnInterface(rows: RosIpAddress[], iface: string): string {
|
||||
const name = iface.trim()
|
||||
const candidates = rows.filter((a) => {
|
||||
if (String(a.interface ?? "").trim() !== name) return false
|
||||
if (String(a.disabled ?? "false").toLowerCase() === "true") return false
|
||||
if (String(a.invalid ?? "false").toLowerCase() === "true") return false
|
||||
const addr = String(a.address ?? "").trim()
|
||||
return addr.includes("/")
|
||||
})
|
||||
candidates.sort((a, b) => {
|
||||
const ad = String(a.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
|
||||
const bd = String(b.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
|
||||
return ad - bd
|
||||
})
|
||||
const raw = candidates[0]?.address
|
||||
return raw ? String(raw).split("/")[0]?.trim() ?? "" : ""
|
||||
}
|
||||
|
||||
function parseRateToMbps(raw: unknown): number {
|
||||
const txt = String(raw ?? "").trim().toLowerCase()
|
||||
if (!txt) return 0
|
||||
@@ -49,12 +74,39 @@ function parseRateToMbps(raw: unknown): number {
|
||||
return n > 10000 ? n / 1_000_000 : n
|
||||
}
|
||||
|
||||
const btestNodeLocks = new Map<number, Promise<void>>()
|
||||
|
||||
async function withBtestNodeLocks<T>(nodeIds: number[], fn: () => Promise<T>): Promise<T> {
|
||||
const ids = [...new Set(nodeIds.filter((id) => Number.isFinite(id)))].sort((a, b) => a - b)
|
||||
const releases: Array<() => void> = []
|
||||
try {
|
||||
for (const id of ids) {
|
||||
const prev = btestNodeLocks.get(id)
|
||||
let resolveCurrent!: () => void
|
||||
const current = new Promise<void>((resolve) => {
|
||||
resolveCurrent = resolve
|
||||
})
|
||||
btestNodeLocks.set(id, current)
|
||||
if (prev) await prev
|
||||
releases.push(() => {
|
||||
if (btestNodeLocks.get(id) === current) btestNodeLocks.delete(id)
|
||||
resolveCurrent()
|
||||
})
|
||||
}
|
||||
return await fn()
|
||||
} finally {
|
||||
for (let i = releases.length - 1; i >= 0; i -= 1) releases[i]()
|
||||
}
|
||||
}
|
||||
|
||||
const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/uptime/settings", async (_req, reply) => {
|
||||
const s = readUptimeSettings()
|
||||
return reply.send({
|
||||
enabled: s.enabled,
|
||||
intervalSec: s.intervalSec,
|
||||
probeIntervalSec: s.probeIntervalSec ?? s.intervalSec,
|
||||
speedIntervalSec: s.speedIntervalSec ?? 60,
|
||||
retentionDays: s.retentionDays,
|
||||
lastCollectedAt: s.lastCollectedAt ?? null,
|
||||
lastDurationMs: s.lastDurationMs ?? null,
|
||||
@@ -63,10 +115,18 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
app.put("/uptime/settings", async (req, reply) => {
|
||||
const body = req.body as { enabled?: boolean; intervalSec?: number | string; retentionDays?: number | string }
|
||||
const body = req.body as {
|
||||
enabled?: boolean
|
||||
intervalSec?: number | string
|
||||
probeIntervalSec?: number | string
|
||||
speedIntervalSec?: number | string
|
||||
retentionDays?: number | string
|
||||
}
|
||||
const updated = updateUptimeSettings({
|
||||
enabled: body.enabled,
|
||||
intervalSec: body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 15),
|
||||
probeIntervalSec: body.probeIntervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.probeIntervalSec), 10) || 15),
|
||||
speedIntervalSec: body.speedIntervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.speedIntervalSec), 10) || 60),
|
||||
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
|
||||
})
|
||||
return reply.send({
|
||||
@@ -74,6 +134,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
settings: {
|
||||
enabled: updated.enabled,
|
||||
intervalSec: updated.intervalSec,
|
||||
probeIntervalSec: updated.probeIntervalSec ?? updated.intervalSec,
|
||||
speedIntervalSec: updated.speedIntervalSec ?? 60,
|
||||
retentionDays: updated.retentionDays,
|
||||
lastCollectedAt: updated.lastCollectedAt ?? null,
|
||||
lastDurationMs: updated.lastDurationMs ?? null,
|
||||
@@ -97,6 +159,125 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.send({ probes: readProbeRows() })
|
||||
})
|
||||
|
||||
app.get("/uptime/speed-probes", async (_req, reply) => {
|
||||
const rows = db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)).all()
|
||||
return reply.send({
|
||||
probes: rows.map((r) => ({
|
||||
id: r.id,
|
||||
srcServerId: String(r.srcServerId),
|
||||
dstServerId: String(r.dstServerId),
|
||||
srcInterface: r.srcInterface || "",
|
||||
dstInterface: r.dstInterface || "",
|
||||
protocol: r.protocol === "udp" ? "udp" : "tcp",
|
||||
direction: r.direction === "transmit" || r.direction === "receive" ? r.direction : "both",
|
||||
durationSec: String(Math.max(3, r.durationSec || 10)),
|
||||
enabled: r.enabled !== false,
|
||||
lastRunAt: r.lastRunAt ?? null,
|
||||
lastTxAvgMbps: r.lastTxAvgMbps ?? null,
|
||||
lastRxAvgMbps: r.lastRxAvgMbps ?? null,
|
||||
lastStatus: r.lastStatus ?? null,
|
||||
lastError: r.lastError ?? null,
|
||||
lastPingRttMs: r.lastPingRttMs ?? null,
|
||||
lastPingLossPct: r.lastPingLossPct ?? null,
|
||||
lastPingAt: r.lastPingAt ?? null,
|
||||
lastPingError: r.lastPingError ?? null,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
app.get("/uptime/speed-test/runs", async (_req, reply) => {
|
||||
const rows = db.select().from(uptimeSpeedTestRuns).orderBy(desc(uptimeSpeedTestRuns.createdAt)).limit(200).all()
|
||||
return reply.send({
|
||||
runs: rows.map((r) => ({
|
||||
id: r.id,
|
||||
probeId: r.probeId ?? null,
|
||||
srcServerId: String(r.srcServerId),
|
||||
dstServerId: String(r.dstServerId),
|
||||
srcInterface: r.srcInterface || "",
|
||||
dstInterface: r.dstInterface || "",
|
||||
srcAddress: r.srcAddress ?? null,
|
||||
dstAddress: r.dstAddress ?? null,
|
||||
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
|
||||
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
|
||||
protocol: r.protocol === "udp" ? "udp" : "tcp",
|
||||
direction: r.direction === "transmit" || r.direction === "receive" ? r.direction : "both",
|
||||
durationSec: Math.max(3, r.durationSec || 10),
|
||||
txAvgMbps: r.txAvgMbps ?? 0,
|
||||
rxAvgMbps: r.rxAvgMbps ?? 0,
|
||||
afterBtPing: {
|
||||
rttMs: r.pingRttMs ?? null,
|
||||
lossPct: r.pingLossPct ?? null,
|
||||
error: r.pingError ?? null,
|
||||
},
|
||||
status: r.status === "error" ? "error" : "done",
|
||||
error: r.error ?? null,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
app.put("/uptime/speed-probes", async (req, reply) => {
|
||||
const body = req.body as {
|
||||
probes?: Array<{
|
||||
id?: string
|
||||
srcServerId?: string | number
|
||||
dstServerId?: string | number
|
||||
srcInterface?: string
|
||||
dstInterface?: string
|
||||
protocol?: "tcp" | "udp"
|
||||
direction?: "transmit" | "receive" | "both"
|
||||
durationSec?: string | number
|
||||
enabled?: boolean
|
||||
}>
|
||||
}
|
||||
const normalized = (body.probes ?? [])
|
||||
.map((p, i) => ({
|
||||
id: p.id || `sp-${Date.now()}-${i}`,
|
||||
srcServerId: Number.parseInt(String(p.srcServerId ?? ""), 10),
|
||||
dstServerId: Number.parseInt(String(p.dstServerId ?? ""), 10),
|
||||
srcInterface: String(p.srcInterface ?? "").trim(),
|
||||
dstInterface: String(p.dstInterface ?? "").trim(),
|
||||
protocol: p.protocol === "udp" ? "udp" : "tcp",
|
||||
direction: p.direction === "transmit" || p.direction === "receive" ? p.direction : "both",
|
||||
durationSec: Math.max(3, Number.parseInt(String(p.durationSec ?? ""), 10) || 10),
|
||||
enabled: p.enabled !== false,
|
||||
}))
|
||||
.filter((p) => Number.isFinite(p.srcServerId) && Number.isFinite(p.dstServerId) && p.srcServerId !== p.dstServerId)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const existing = db.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes).all()
|
||||
const nextIds = new Set(normalized.map((p) => p.id))
|
||||
|
||||
for (const row of existing) {
|
||||
if (nextIds.has(row.id)) continue
|
||||
db.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, row.id)).run()
|
||||
}
|
||||
|
||||
for (let i = 0; i < normalized.length; i += 1) {
|
||||
const p = normalized[i]
|
||||
const patch = {
|
||||
srcServerId: p.srcServerId,
|
||||
dstServerId: p.dstServerId,
|
||||
srcInterface: p.srcInterface,
|
||||
dstInterface: p.dstInterface,
|
||||
protocol: p.protocol as "tcp" | "udp",
|
||||
direction: p.direction as "transmit" | "receive" | "both",
|
||||
durationSec: p.durationSec,
|
||||
enabled: p.enabled,
|
||||
sortOrder: i,
|
||||
updatedAt: now,
|
||||
}
|
||||
const updated = db.update(uptimeSpeedProbes).set(patch).where(eq(uptimeSpeedProbes.id, p.id)).run()
|
||||
if ((updated.changes ?? 0) > 0) continue
|
||||
db.insert(uptimeSpeedProbes).values({
|
||||
id: p.id,
|
||||
...patch,
|
||||
createdAt: now,
|
||||
}).run()
|
||||
}
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.put("/uptime/probes", async (req, reply) => {
|
||||
const body = req.body as {
|
||||
probes?: Array<{
|
||||
@@ -107,6 +288,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
target?: string
|
||||
filter?: string
|
||||
enabled?: boolean
|
||||
showOnDashboard?: boolean
|
||||
}>
|
||||
}
|
||||
const normalized = (body.probes ?? [])
|
||||
@@ -118,12 +300,26 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
target: String(p.target ?? "").trim(),
|
||||
probeFilter: String(p.filter ?? "—"),
|
||||
enabled: p.enabled !== false,
|
||||
showOnDashboard: p.showOnDashboard === true,
|
||||
}))
|
||||
.filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target)
|
||||
replaceProbes(normalized)
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.patch("/uptime/probes/:id", async (req, reply) => {
|
||||
const params = req.params as { id?: string }
|
||||
const probeId = String(params.id ?? "").trim()
|
||||
if (!probeId) return reply.status(400).send({ error: "Invalid probe id" })
|
||||
const body = req.body as { showOnDashboard?: boolean }
|
||||
if (typeof body.showOnDashboard !== "boolean") {
|
||||
return reply.status(400).send({ error: "showOnDashboard boolean required" })
|
||||
}
|
||||
const ok = updateProbeShowOnDashboard(probeId, body.showOnDashboard)
|
||||
if (!ok) return reply.status(404).send({ error: "Probe not found" })
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.get("/uptime/sources/:id/interfaces", async (req, reply) => {
|
||||
const params = req.params as { id?: string | number }
|
||||
const serverId = Number.parseInt(String(params.id ?? ""), 10)
|
||||
@@ -139,6 +335,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
disabled: String(r.disabled ?? "false").toLowerCase() === "true",
|
||||
}))
|
||||
.filter((r) => r.name.length > 0)
|
||||
.filter((r) => r.running && !r.disabled)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return reply.send({ interfaces })
|
||||
} catch (e) {
|
||||
@@ -149,6 +346,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
app.post("/uptime/speed-test", async (req, reply) => {
|
||||
const body = req.body as {
|
||||
runId?: string
|
||||
probeId?: string
|
||||
srcServerId?: string | number
|
||||
dstServerId?: string | number
|
||||
srcInterface?: string
|
||||
@@ -167,64 +366,187 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0]
|
||||
if (!src || !dst) return reply.status(404).send({ error: "Server not found" })
|
||||
try {
|
||||
const protocol = body.protocol === "udp" ? "udp" : "tcp"
|
||||
const direction = body.direction === "transmit" || body.direction === "receive" ? body.direction : "both"
|
||||
const durationSec = Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10)
|
||||
const srcInterface = String(body.srcInterface ?? "").trim()
|
||||
const dstInterface = String(body.dstInterface ?? "").trim()
|
||||
const srcClient = MikrotikClient.fromServer(src)
|
||||
const dstAddressRows = dstInterface
|
||||
? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => [])
|
||||
: []
|
||||
const dstAddress = dstInterface
|
||||
? String(
|
||||
dstAddressRows.find((a) => String(a.interface ?? "") === dstInterface && String(a.address ?? "").includes("/"))?.address ??
|
||||
"",
|
||||
).split("/")[0]
|
||||
: dst.host
|
||||
if (dstInterface && !dstAddress) {
|
||||
return reply.status(400).send({ error: `На интерфейсе назначения '${dstInterface}' нет IP-адреса` })
|
||||
}
|
||||
const initialAddress = dstAddress || dst.host
|
||||
return await withBtestNodeLocks([srcId, dstId], async () => {
|
||||
const protocol = body.protocol === "udp" ? "udp" : "tcp"
|
||||
const direction = body.direction === "transmit" || body.direction === "receive" ? body.direction : "both"
|
||||
const durationSec = Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10)
|
||||
const srcInterface = String(body.srcInterface ?? "").trim()
|
||||
const dstInterface = String(body.dstInterface ?? "").trim()
|
||||
const srcClient = MikrotikClient.fromServer(src)
|
||||
const dstAddressRows = dstInterface
|
||||
? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => [])
|
||||
: []
|
||||
const srcAddressRows = srcInterface
|
||||
? await srcClient.getIpAddresses().catch(() => [])
|
||||
: []
|
||||
const dstAddress = dstInterface ? firstUsableIpOnInterface(dstAddressRows, dstInterface) : dst.host
|
||||
if (dstInterface && !dstAddress) {
|
||||
return reply.status(400).send({
|
||||
error: `На интерфейсе назначения '${dstInterface}' нет активного IP (проверьте /ip/address — не disabled).`,
|
||||
})
|
||||
}
|
||||
const initialAddress = dstAddress || dst.host
|
||||
const srcAddress = srcInterface ? firstUsableIpOnInterface(srcAddressRows, srcInterface) : src.host
|
||||
const srcInterfaceAddress = srcInterface ? (srcAddress || null) : null
|
||||
const dstInterfaceAddress = dstInterface ? (dstAddress || null) : null
|
||||
|
||||
const rows = await srcClient.bandwidthTest({
|
||||
address: initialAddress,
|
||||
user: dst.username,
|
||||
password: dst.password,
|
||||
protocol,
|
||||
direction,
|
||||
durationSec,
|
||||
})
|
||||
const txSeries = rows
|
||||
.map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"]))
|
||||
.filter((v) => Number.isFinite(v) && v >= 0)
|
||||
const rxSeries = rows
|
||||
.map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"]))
|
||||
.filter((v) => Number.isFinite(v) && v >= 0)
|
||||
const last = rows[rows.length - 1] ?? {}
|
||||
const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"])
|
||||
const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"])
|
||||
return reply.send({
|
||||
ok: true,
|
||||
result: {
|
||||
srcInterface: srcInterface || null,
|
||||
dstInterface: dstInterface || null,
|
||||
const rows = await srcClient.bandwidthTest({
|
||||
address: initialAddress,
|
||||
user: dst.username,
|
||||
password: dst.password,
|
||||
protocol,
|
||||
direction,
|
||||
durationSec,
|
||||
txAvgMbps: txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0),
|
||||
rxAvgMbps: rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0),
|
||||
txSeries,
|
||||
rxSeries,
|
||||
raw: rows,
|
||||
},
|
||||
})
|
||||
const txSeries = rows
|
||||
.map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"]))
|
||||
.filter((v) => Number.isFinite(v) && v >= 0)
|
||||
const rxSeries = rows
|
||||
.map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"]))
|
||||
.filter((v) => Number.isFinite(v) && v >= 0)
|
||||
const last = rows[rows.length - 1] ?? {}
|
||||
const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"])
|
||||
const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"])
|
||||
const txFinal = txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0)
|
||||
const rxFinal = rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0)
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
const pingNow = new Date().toISOString()
|
||||
let afterBtPing: {
|
||||
rttMs: number | null
|
||||
lossPct: number | null
|
||||
error: string | null
|
||||
} = { rttMs: null, lossPct: null, error: null }
|
||||
try {
|
||||
const pingRows = await srcClient.ping(initialAddress, 4, srcInterface || undefined, { interval: "1s" })
|
||||
const pr = parsePing(pingRows as Parameters<typeof parsePing>[0])
|
||||
afterBtPing = {
|
||||
rttMs: pr.avgRtt,
|
||||
lossPct: pr.loss,
|
||||
error: null,
|
||||
}
|
||||
} catch (pe) {
|
||||
afterBtPing = {
|
||||
rttMs: null,
|
||||
lossPct: null,
|
||||
error: pe instanceof Error ? pe.message : String(pe),
|
||||
}
|
||||
}
|
||||
|
||||
const probeId = String(body.probeId ?? "").trim()
|
||||
const runId = String(body.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
if (probeId) {
|
||||
db.update(uptimeSpeedProbes).set({
|
||||
lastRunAt: pingNow,
|
||||
lastTxAvgMbps: txFinal,
|
||||
lastRxAvgMbps: rxFinal,
|
||||
lastStatus: "done",
|
||||
lastError: "",
|
||||
lastPingRttMs: afterBtPing.rttMs,
|
||||
lastPingLossPct: afterBtPing.lossPct,
|
||||
lastPingAt: pingNow,
|
||||
lastPingError: afterBtPing.error ?? "",
|
||||
updatedAt: pingNow,
|
||||
}).where(eq(uptimeSpeedProbes.id, probeId)).run()
|
||||
}
|
||||
db.insert(uptimeSpeedTestRuns).values({
|
||||
id: runId,
|
||||
probeId: probeId || null,
|
||||
srcServerId: srcId,
|
||||
dstServerId: dstId,
|
||||
srcInterface: srcInterface || "",
|
||||
dstInterface: dstInterface || "",
|
||||
srcAddress: srcAddress || src.host,
|
||||
dstAddress: initialAddress,
|
||||
srcInterfaceAddress,
|
||||
dstInterfaceAddress,
|
||||
protocol,
|
||||
direction,
|
||||
durationSec,
|
||||
txAvgMbps: txFinal,
|
||||
rxAvgMbps: rxFinal,
|
||||
pingRttMs: afterBtPing.rttMs,
|
||||
pingLossPct: afterBtPing.lossPct,
|
||||
pingError: afterBtPing.error ?? null,
|
||||
status: "done",
|
||||
error: null,
|
||||
createdAt: pingNow,
|
||||
}).run()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
result: {
|
||||
runId,
|
||||
srcInterface: srcInterface || null,
|
||||
dstInterface: dstInterface || null,
|
||||
address: initialAddress,
|
||||
srcAddress: srcAddress || src.host,
|
||||
dstAddress: initialAddress,
|
||||
srcInterfaceAddress,
|
||||
dstInterfaceAddress,
|
||||
protocol,
|
||||
direction,
|
||||
durationSec,
|
||||
txAvgMbps: txFinal,
|
||||
rxAvgMbps: rxFinal,
|
||||
txSeries,
|
||||
rxSeries,
|
||||
raw: rows,
|
||||
afterBtPing,
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
const raw = e instanceof Error ? e.message : "Bandwidth test failed"
|
||||
const message = /timed out|socket hang up|ECONNRESET/i.test(raw)
|
||||
? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов"
|
||||
: raw
|
||||
const probeId = String((req.body as { probeId?: string }).probeId ?? "").trim()
|
||||
if (probeId) {
|
||||
db.update(uptimeSpeedProbes).set({
|
||||
lastRunAt: new Date().toISOString(),
|
||||
lastStatus: "error",
|
||||
lastError: message,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(uptimeSpeedProbes.id, probeId)).run()
|
||||
}
|
||||
const body = req.body as {
|
||||
runId?: string
|
||||
srcServerId?: string | number
|
||||
dstServerId?: string | number
|
||||
srcInterface?: string
|
||||
dstInterface?: string
|
||||
protocol?: "tcp" | "udp"
|
||||
direction?: "transmit" | "receive" | "both"
|
||||
durationSec?: string | number
|
||||
}
|
||||
const srcId = Number.parseInt(String(body.srcServerId ?? ""), 10)
|
||||
const dstId = Number.parseInt(String(body.dstServerId ?? ""), 10)
|
||||
if (Number.isFinite(srcId) && Number.isFinite(dstId)) {
|
||||
db.insert(uptimeSpeedTestRuns).values({
|
||||
id: String(body.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
probeId: probeId || null,
|
||||
srcServerId: srcId,
|
||||
dstServerId: dstId,
|
||||
srcInterface: String(body.srcInterface ?? "").trim(),
|
||||
dstInterface: String(body.dstInterface ?? "").trim(),
|
||||
srcAddress: null,
|
||||
dstAddress: null,
|
||||
srcInterfaceAddress: null,
|
||||
dstInterfaceAddress: null,
|
||||
protocol: body.protocol === "udp" ? "udp" : "tcp",
|
||||
direction: body.direction === "transmit" || body.direction === "receive" ? body.direction : "both",
|
||||
durationSec: Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10),
|
||||
txAvgMbps: null,
|
||||
rxAvgMbps: null,
|
||||
pingRttMs: null,
|
||||
pingLossPct: null,
|
||||
pingError: null,
|
||||
status: "error",
|
||||
error: message,
|
||||
createdAt: new Date().toISOString(),
|
||||
}).run()
|
||||
}
|
||||
return reply.status(502).send({ error: message })
|
||||
}
|
||||
})
|
||||
@@ -246,6 +568,13 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const name = String(i.name ?? "").trim()
|
||||
const addresses = ips
|
||||
.filter((a) => String(a.interface ?? "") === name)
|
||||
.filter((a) => String(a.disabled ?? "false").toLowerCase() !== "true")
|
||||
.filter((a) => String(a.invalid ?? "false").toLowerCase() !== "true")
|
||||
.sort((a, b) => {
|
||||
const ad = String(a.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
|
||||
const bd = String(b.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
|
||||
return ad - bd
|
||||
})
|
||||
.map((a) => String(a.address ?? "").split("/")[0])
|
||||
.filter((x) => x.length > 0)
|
||||
return {
|
||||
@@ -256,6 +585,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
})
|
||||
.filter((i) => i.name.length > 0)
|
||||
.filter((i) => i.running && !i.disabled)
|
||||
return reply.send({ interfaces })
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Failed to fetch speed-test interfaces"
|
||||
@@ -288,6 +618,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
status: (last?.status ?? "down") as "up" | "warn" | "down",
|
||||
series,
|
||||
enabled: p.enabled,
|
||||
showOnDashboard: p.showOnDashboard,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ function rosPost(
|
||||
path: string,
|
||||
body: Record<string, string>,
|
||||
timeoutMs: number,
|
||||
externalSignal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const basePath = params.apiPath ?? "/rest"
|
||||
@@ -100,25 +101,55 @@ function rosPost(
|
||||
|
||||
const lib = params.useSsl ? https : http
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
|
||||
}, timeoutMs)
|
||||
let req: ReturnType<typeof lib.request> | undefined
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
let settled = false
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (req) {
|
||||
req.setTimeout(0)
|
||||
req.removeListener("timeout", onSocketTimeout)
|
||||
}
|
||||
externalSignal?.removeEventListener("abort", onExternalAbort)
|
||||
fn()
|
||||
}
|
||||
|
||||
const onExternalAbort = () => {
|
||||
req?.destroy(new DOMException("Aborted", "AbortError"))
|
||||
}
|
||||
|
||||
const onSocketTimeout = () => {
|
||||
req?.destroy()
|
||||
settle(() => reject(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`)))
|
||||
}
|
||||
|
||||
if (externalSignal?.aborted) {
|
||||
settle(() => reject(new DOMException("Aborted", "AbortError")))
|
||||
return
|
||||
}
|
||||
externalSignal?.addEventListener("abort", onExternalAbort)
|
||||
|
||||
req = lib.request(options, (res) => {
|
||||
let buf = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { buf += chunk })
|
||||
res.on("end", () => {
|
||||
clearTimeout(timer)
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new MikrotikError(res.statusCode ?? 0, path, buf)); return
|
||||
}
|
||||
try { resolve(JSON.parse(buf)) } catch {
|
||||
reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`))
|
||||
}
|
||||
settle(() => {
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new MikrotikError(res.statusCode ?? 0, path, buf)); return
|
||||
}
|
||||
try { resolve(JSON.parse(buf)) } catch {
|
||||
reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
req.on("error", (err) => { clearTimeout(timer); reject(err) })
|
||||
req.setTimeout(timeoutMs)
|
||||
req.on("timeout", onSocketTimeout)
|
||||
req.on("error", (err) => {
|
||||
settle(() => reject(err))
|
||||
})
|
||||
req.write(payload)
|
||||
req.end()
|
||||
})
|
||||
@@ -189,8 +220,8 @@ export class MikrotikClient {
|
||||
return rosRequest(this.params, path, timeoutMs) as Promise<T>
|
||||
}
|
||||
|
||||
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
|
||||
return rosPost(this.params, path, body, timeoutMs) as Promise<T>
|
||||
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> {
|
||||
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
|
||||
}
|
||||
|
||||
async delete(path: string, timeoutMs = 10_000): Promise<void> {
|
||||
@@ -260,12 +291,18 @@ export class MikrotikClient {
|
||||
return this.get<RosLogEntry[]>(`/log?limit=${limit}`)
|
||||
}
|
||||
|
||||
async ping(address: string, count = 4, interfaceName?: string): Promise<RosPingResult[]> {
|
||||
async ping(
|
||||
address: string,
|
||||
count = 4,
|
||||
interfaceName?: string,
|
||||
options?: { interval?: string },
|
||||
): Promise<RosPingResult[]> {
|
||||
const body: Record<string, string> = {
|
||||
address,
|
||||
count: String(count),
|
||||
interval: "0.2s",
|
||||
}
|
||||
if (options?.interval && options.interval.trim()) body.interval = options.interval.trim()
|
||||
else body.interval = "0.2s"
|
||||
if (interfaceName && interfaceName.trim()) body.interface = interfaceName.trim()
|
||||
return this.post<RosPingResult[]>("/tool/ping", body, 20_000)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, serverSnapshots } from "../db/schema.js"
|
||||
import type { SnapshotInsert } from "../db/schema.js"
|
||||
|
||||
@@ -65,6 +65,8 @@ function getSettings() {
|
||||
id: 1,
|
||||
enabled: true,
|
||||
intervalSec: 15,
|
||||
probeIntervalSec: 15,
|
||||
speedIntervalSec: 60,
|
||||
retentionDays: 14,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -78,18 +80,31 @@ function cleanup(retentionDays: number) {
|
||||
db.delete(uptimeResourceSamples).where(lt(uptimeResourceSamples.sampledAt, cutoff)).run()
|
||||
}
|
||||
|
||||
function parsePing(results: Array<{ time?: string; status?: string; sent?: string; received?: string; "packet-loss"?: string }>) {
|
||||
const sum = [...results].reverse().find((r) => r.sent || r.received || r["packet-loss"])
|
||||
const lossRaw = sum?.["packet-loss"] ?? "100%"
|
||||
const lossPct = Number.parseInt(String(lossRaw).replace("%", ""), 10)
|
||||
const okReplies = results.filter((r) => r.time && r.status !== "timeout")
|
||||
export function parsePing(results: Array<{ time?: string; status?: string; sent?: string; received?: string; "packet-loss"?: string; "avg-rtt"?: string }>) {
|
||||
const sum = [...results].reverse().find((r) =>
|
||||
r.sent != null || r.received != null || r["packet-loss"] != null || r["avg-rtt"] != null,
|
||||
)
|
||||
const okReplies = results.filter((r) => r.time && String(r.status ?? "").toLowerCase() !== "timeout")
|
||||
const timedOutReplies = results.filter((r) => String(r.status ?? "").toLowerCase() === "timeout")
|
||||
const rtts = okReplies
|
||||
.map((r) => parsePingTimeMs(r.time))
|
||||
.filter((v): v is number => v != null && Number.isFinite(v))
|
||||
const avgFromReplies = rtts.length ? Math.round(rtts.reduce((a, b) => a + b, 0) / rtts.length) : null
|
||||
const avgFromSummary = parsePingTimeMs(sum?.time)
|
||||
const avgFromSummary = parsePingTimeMs(sum?.time) ?? parsePingTimeMs(sum?.["avg-rtt"])
|
||||
const avgRtt = avgFromReplies ?? (avgFromSummary != null ? Math.round(avgFromSummary) : null)
|
||||
const loss = Number.isFinite(lossPct) ? Math.max(0, Math.min(100, lossPct)) : 100
|
||||
const sent = Number.parseInt(String(sum?.sent ?? ""), 10)
|
||||
const received = Number.parseInt(String(sum?.received ?? ""), 10)
|
||||
const sentFallback = okReplies.length + timedOutReplies.length
|
||||
const safeSent = Number.isFinite(sent) && sent > 0 ? sent : sentFallback
|
||||
const safeReceived = Number.isFinite(received) && received >= 0 ? received : okReplies.length
|
||||
const computedLoss = safeSent > 0 ? Math.round(((safeSent - safeReceived) / safeSent) * 100) : null
|
||||
const lossRaw = String(sum?.["packet-loss"] ?? "")
|
||||
const parsedLoss = Number.parseInt(lossRaw.replace("%", ""), 10)
|
||||
const lossSource = Number.isFinite(parsedLoss) ? parsedLoss : computedLoss
|
||||
const loss =
|
||||
typeof lossSource === "number" && Number.isFinite(lossSource)
|
||||
? Math.max(0, Math.min(100, lossSource))
|
||||
: 100
|
||||
const status: "up" | "warn" | "down" = loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > 60 ? "warn" : "up")
|
||||
return { avgRtt, loss, status }
|
||||
}
|
||||
@@ -188,7 +203,7 @@ export function restartUptimeCollector() {
|
||||
if (timer) clearInterval(timer)
|
||||
timer = null
|
||||
if (!s.enabled) return
|
||||
const intervalMs = Math.max(5, s.intervalSec) * 1000
|
||||
const intervalMs = Math.max(5, s.probeIntervalSec || s.intervalSec) * 1000
|
||||
timer = setInterval(() => { void collectUptimeOnce() }, intervalMs)
|
||||
}
|
||||
|
||||
@@ -204,12 +219,17 @@ export function readUptimeSettings() {
|
||||
export function updateUptimeSettings(patch: {
|
||||
enabled?: boolean
|
||||
intervalSec?: number
|
||||
probeIntervalSec?: number
|
||||
speedIntervalSec?: number
|
||||
retentionDays?: number
|
||||
}) {
|
||||
const prev = getSettings()
|
||||
const nextProbeInterval = patch.probeIntervalSec ?? patch.intervalSec ?? prev.probeIntervalSec ?? prev.intervalSec
|
||||
const next = {
|
||||
enabled: patch.enabled ?? prev.enabled,
|
||||
intervalSec: patch.intervalSec ?? prev.intervalSec,
|
||||
intervalSec: nextProbeInterval,
|
||||
probeIntervalSec: nextProbeInterval,
|
||||
speedIntervalSec: patch.speedIntervalSec ?? prev.speedIntervalSec ?? 60,
|
||||
retentionDays: patch.retentionDays ?? prev.retentionDays,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
@@ -222,6 +242,15 @@ export function readProbeRows() {
|
||||
return db.select().from(uptimeProbes).orderBy(asc(uptimeProbes.sortOrder)).all()
|
||||
}
|
||||
|
||||
export function updateProbeShowOnDashboard(probeId: string, showOnDashboard: boolean): boolean {
|
||||
const now = new Date().toISOString()
|
||||
const r = db.update(uptimeProbes)
|
||||
.set({ showOnDashboard, updatedAt: now })
|
||||
.where(eq(uptimeProbes.id, probeId))
|
||||
.run()
|
||||
return (r.changes ?? 0) > 0
|
||||
}
|
||||
|
||||
export function replaceProbes(rows: Array<{
|
||||
id: string
|
||||
srcServerId: number
|
||||
@@ -230,22 +259,40 @@ export function replaceProbes(rows: Array<{
|
||||
target: string
|
||||
probeFilter: string
|
||||
enabled: boolean
|
||||
showOnDashboard?: boolean
|
||||
}>) {
|
||||
db.delete(uptimeProbes).run()
|
||||
if (rows.length === 0) return
|
||||
const now = new Date().toISOString()
|
||||
db.insert(uptimeProbes).values(rows.map((r, i) => ({
|
||||
id: r.id,
|
||||
srcServerId: r.srcServerId,
|
||||
srcInterface: r.srcInterface || "",
|
||||
name: r.name,
|
||||
target: r.target,
|
||||
probeFilter: r.probeFilter || "—",
|
||||
enabled: r.enabled,
|
||||
sortOrder: i,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))).run()
|
||||
const existing = db.select({ id: uptimeProbes.id }).from(uptimeProbes).all()
|
||||
const nextIds = new Set(rows.map((r) => r.id))
|
||||
|
||||
// Remove only probes that user actually deleted (their samples are removed by FK cascade).
|
||||
for (const p of existing) {
|
||||
if (nextIds.has(p.id)) continue
|
||||
db.delete(uptimeProbes).where(eq(uptimeProbes.id, p.id)).run()
|
||||
}
|
||||
|
||||
// Upsert rows in-place to preserve sample history for unchanged probe ids.
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
const r = rows[i]
|
||||
const patch = {
|
||||
srcServerId: r.srcServerId,
|
||||
srcInterface: r.srcInterface || "",
|
||||
name: r.name,
|
||||
target: r.target,
|
||||
probeFilter: r.probeFilter || "—",
|
||||
enabled: r.enabled,
|
||||
showOnDashboard: r.showOnDashboard ?? false,
|
||||
sortOrder: i,
|
||||
updatedAt: now,
|
||||
}
|
||||
const updated = db.update(uptimeProbes).set(patch).where(eq(uptimeProbes.id, r.id)).run()
|
||||
if ((updated.changes ?? 0) > 0) continue
|
||||
db.insert(uptimeProbes).values({
|
||||
id: r.id,
|
||||
...patch,
|
||||
createdAt: now,
|
||||
}).run()
|
||||
}
|
||||
}
|
||||
|
||||
export function readProbeSamplesSince(sinceIso: string) {
|
||||
|
||||
@@ -49,6 +49,16 @@ export interface RosIpAddress {
|
||||
|
||||
// ── Zod schemas for API validation ────────────────────────────────────────────
|
||||
|
||||
export const WanUplinkSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
isp: z.string(),
|
||||
iface: z.string(),
|
||||
ip: z.string(),
|
||||
maxDl: z.number(),
|
||||
maxUl: z.number(),
|
||||
})
|
||||
|
||||
export const ServerCreateSchema = z.object({
|
||||
host: z.string().min(1, "Host is required"),
|
||||
port: z.number().int().positive().default(443),
|
||||
@@ -63,6 +73,8 @@ export const ServerCreateSchema = z.object({
|
||||
asn: z.string().default(""),
|
||||
comment: z.string().default(""),
|
||||
enabled: z.boolean().default(true),
|
||||
lanSubnet: z.string().default(""),
|
||||
wanUplinks: z.array(WanUplinkSchema).default([]),
|
||||
})
|
||||
|
||||
export const ServerUpdateSchema = ServerCreateSchema.partial().omit({ host: true }).extend({
|
||||
@@ -90,6 +102,16 @@ export const SnapshotsQuerySchema = z.object({
|
||||
// ── Response types (what the API returns) ─────────────────────────────────────
|
||||
|
||||
/** Flat server response — mirrors the frontend's Server interface from lib/data.ts */
|
||||
export interface WanUplinkRead {
|
||||
id: string
|
||||
name: string
|
||||
isp: string
|
||||
iface: string
|
||||
ip: string
|
||||
maxDl: number
|
||||
maxUl: number
|
||||
}
|
||||
|
||||
export interface ServerRead {
|
||||
id: number
|
||||
name: string
|
||||
@@ -105,6 +127,8 @@ export interface ServerRead {
|
||||
asn: string
|
||||
comment: string
|
||||
enabled: boolean
|
||||
lanSubnet: string
|
||||
wanUplinks: WanUplinkRead[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
// from latest snapshot (null if never polled)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Объединяет несколько AbortSignal: один abort отменяет общий контроллер. */
|
||||
export function mergeAbortSignals(...signals: AbortSignal[]): AbortSignal {
|
||||
const c = new AbortController()
|
||||
const forward = () => {
|
||||
c.abort()
|
||||
}
|
||||
for (const s of signals) {
|
||||
if (s.aborted) {
|
||||
forward()
|
||||
return c.signal
|
||||
}
|
||||
s.addEventListener("abort", forward, { once: true })
|
||||
}
|
||||
return c.signal
|
||||
}
|
||||
|
||||
/** Отмена через `ms` миллисекунд (таймер не снимается при другой отмене — ок для короткоживущих запросов). */
|
||||
export function abortAfterMs(ms: number): AbortSignal {
|
||||
const c = new AbortController()
|
||||
setTimeout(() => c.abort(), ms)
|
||||
return c.signal
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import dns from "node:dns/promises"
|
||||
|
||||
/** RouterOS «src-address» для /tool/ping и /tool/traceroute ожидает IPv4, не FQDN. */
|
||||
export function isIpv4Literal(host: string): boolean {
|
||||
const s = host.trim()
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return false
|
||||
return s.split(".").every((p) => {
|
||||
const n = Number.parseInt(p, 10)
|
||||
return Number.isFinite(n) && n >= 0 && n <= 255
|
||||
})
|
||||
}
|
||||
|
||||
/** Возвращает первый A-запись IPv4 для управления; если host уже IPv4 — как есть. */
|
||||
export async function resolveRosSrcIpv4(host: string): Promise<string | null> {
|
||||
const h = host.trim()
|
||||
if (!h) return null
|
||||
if (isIpv4Literal(h)) return h
|
||||
try {
|
||||
const addrs = await dns.resolve4(h)
|
||||
return addrs[0] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
+25
-11
@@ -115,7 +115,13 @@ const navGroups: NavGroup[] = [
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
const isDark = resolvedTheme === "dark"
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setMounted(true)
|
||||
})
|
||||
}, [])
|
||||
const isDark = (resolvedTheme ?? "dark") === "dark"
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
@@ -146,16 +152,24 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<span className="text-[13px] font-medium text-sidebar-foreground truncate">а.коротаев</span>
|
||||
<span className="text-[11px] text-sidebar-foreground/50 truncate">admin@routerlists.io</span>
|
||||
</div>
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
title={isDark ? "Светлая тема" : "Тёмная тема"}
|
||||
className="shrink-0 size-6 flex items-center justify-center rounded-md text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{isDark
|
||||
? <SunIcon className="size-3.5" />
|
||||
: <MoonIcon className="size-3.5" />}
|
||||
</button>
|
||||
{/* Theme toggle — только после mount, иначе SSR и клиент расходятся (next-themes). */}
|
||||
{mounted ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
title={isDark ? "Светлая тема" : "Тёмная тема"}
|
||||
className="shrink-0 size-6 flex items-center justify-center rounded-md text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{isDark
|
||||
? <SunIcon className="size-3.5" />
|
||||
: <MoonIcon className="size-3.5" />}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="shrink-0 size-6 group-data-[collapsible=icon]:hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from "react"
|
||||
import { useEffect, useState, useRef, useMemo } from "react"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
@@ -113,7 +113,9 @@ export function CommandPalette() {
|
||||
}, [open])
|
||||
|
||||
// ── Close on route change ─────────────────────────────────────────────────
|
||||
useEffect(() => { setOpen(false) }, [pathname])
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setOpen(false))
|
||||
}, [pathname])
|
||||
|
||||
// ── Arrow key + Enter navigation ──────────────────────────────────────────
|
||||
function onInputKey(e: React.KeyboardEvent) {
|
||||
@@ -142,7 +144,9 @@ export function CommandPalette() {
|
||||
}, [activeIdx])
|
||||
|
||||
// ── Reset active idx when query changes ───────────────────────────────────
|
||||
useEffect(() => { setActiveIdx(0) }, [query])
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setActiveIdx(0))
|
||||
}, [query])
|
||||
|
||||
// ── Grouped results for display ───────────────────────────────────────────
|
||||
const grouped = useMemo(() => {
|
||||
|
||||
@@ -1,52 +1,80 @@
|
||||
"use client"
|
||||
|
||||
interface LatencyChartProps {
|
||||
series: Record<string, number[]>
|
||||
}
|
||||
const PALETTE = [
|
||||
"var(--chart-line-1)",
|
||||
"var(--chart-line-2)",
|
||||
"var(--chart-line-3)",
|
||||
"var(--chart-line-4)",
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
]
|
||||
|
||||
// Maps to CSS variables defined in globals.css
|
||||
const COLORS: Record<string, string> = {
|
||||
msk: "var(--chart-line-1)",
|
||||
spb: "var(--chart-line-2)",
|
||||
fra: "var(--chart-line-3)",
|
||||
ams: "var(--chart-line-4)",
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
/** Легенда для демо-данных (ключи `msk`, `spb`, …). */
|
||||
const LEGACY_LABELS: Record<string, string> = {
|
||||
msk: "MSK",
|
||||
spb: "SPB",
|
||||
fra: "FRA",
|
||||
ams: "AMS",
|
||||
}
|
||||
|
||||
export function LatencyChart({ series }: LatencyChartProps) {
|
||||
const W = 800, H = 220
|
||||
interface LatencyChartProps {
|
||||
series: Record<string, number[]>
|
||||
/** Подписи линий (ключ → короткое имя). Если нет — берётся legacy или сам ключ. */
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
export function LatencyChart({ series, labels: labelsProp }: LatencyChartProps) {
|
||||
const W = 800
|
||||
const H = 220
|
||||
const pad = { l: 44, r: 12, t: 12, b: 28 }
|
||||
const iw = W - pad.l - pad.r
|
||||
const ih = H - pad.t - pad.b
|
||||
|
||||
const allVals = Object.values(series).flat()
|
||||
const maxVal = Math.ceil(Math.max(...allVals) / 20) * 20 + 10
|
||||
const entries = Object.entries(series).filter(([, arr]) => arr.length > 0)
|
||||
const pointCount = entries.length ? Math.max(...entries.map(([, a]) => a.length), 2) : 60
|
||||
|
||||
const xAt = (i: number, n: number) => pad.l + (i / (n - 1)) * iw
|
||||
const allVals = entries.flatMap(([, arr]) => arr)
|
||||
const maxVal = Math.ceil(Math.max(1, ...allVals) / 20) * 20 + 10
|
||||
|
||||
const xAt = (i: number, n: number) => pad.l + (i / Math.max(n - 1, 1)) * iw
|
||||
const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih
|
||||
|
||||
const gridLines = [0, 0.25, 0.5, 0.75, 1]
|
||||
|
||||
const tickIndices = [
|
||||
0,
|
||||
Math.floor((pointCount - 1) * 0.25),
|
||||
Math.floor((pointCount - 1) * 0.5),
|
||||
Math.floor((pointCount - 1) * 0.75),
|
||||
pointCount - 1,
|
||||
]
|
||||
|
||||
const labelForIndex = (i: number) => {
|
||||
const spanMin = 60
|
||||
const m = Math.round(spanMin * (1 - i / Math.max(pointCount - 1, 1)))
|
||||
return `-${m}м`
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: 220, display: "block" }}>
|
||||
{gridLines.map((p, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={pad.l} x2={W - pad.r}
|
||||
y1={pad.t + ih * p} y2={pad.t + ih * p}
|
||||
x1={pad.l}
|
||||
x2={W - pad.r}
|
||||
y1={pad.t + ih * p}
|
||||
y2={pad.t + ih * p}
|
||||
style={{ stroke: "var(--border)" }}
|
||||
strokeDasharray={p === 1 ? "0" : "2 4"}
|
||||
/>
|
||||
<text
|
||||
x={pad.l - 8} y={pad.t + ih * p + 4}
|
||||
textAnchor="end" fontSize="10"
|
||||
x={pad.l - 8}
|
||||
y={pad.t + ih * p + 4}
|
||||
textAnchor="end"
|
||||
fontSize="10"
|
||||
style={{ fill: "var(--muted-foreground)" }}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
@@ -54,34 +82,44 @@ export function LatencyChart({ series }: LatencyChartProps) {
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{[0, 15, 30, 45, 59].map((i) => (
|
||||
{tickIndices.map((i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={xAt(i, 60)} y={H - 6}
|
||||
textAnchor="middle" fontSize="10"
|
||||
x={xAt(i, pointCount)}
|
||||
y={H - 6}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
style={{ fill: "var(--muted-foreground)" }}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
-{60 - i}м
|
||||
{labelForIndex(i)}
|
||||
</text>
|
||||
))}
|
||||
{Object.entries(series).map(([key, arr]) => {
|
||||
const pts = arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||
const color = COLORS[key] ?? "var(--chart-line-1)"
|
||||
{entries.map(([key, arr], idx) => {
|
||||
const n = arr.length
|
||||
const pts = arr.map((v, i) => `${xAt(i, n).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||
const color = PALETTE[idx % PALETTE.length]
|
||||
return (
|
||||
<polyline key={key} points={pts} fill="none"
|
||||
<polyline
|
||||
key={key}
|
||||
points={pts}
|
||||
fill="none"
|
||||
style={{ stroke: color }}
|
||||
strokeWidth="1.6" strokeLinejoin="round" />
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex gap-4 pt-2 pl-11">
|
||||
{Object.keys(series).map((k) => {
|
||||
const color = COLORS[k] ?? "var(--chart-line-1)"
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 pt-2 pl-11">
|
||||
{entries.map(([key], idx) => {
|
||||
const color = PALETTE[idx % PALETTE.length]
|
||||
const leg =
|
||||
labelsProp?.[key] ?? LEGACY_LABELS[key] ?? key.replace(/^src-/, "")
|
||||
return (
|
||||
<div key={k} className="flex items-center gap-1.5 text-xs">
|
||||
<div key={key} className="flex items-center gap-1.5 text-xs">
|
||||
<span className="w-3 h-0.5 rounded" style={{ background: color }} />
|
||||
<span className="font-mono text-foreground">{LABELS[k]}</span>
|
||||
<span className="font-mono text-foreground">{leg}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -50,6 +50,8 @@ export function Flag({ code, size = 20, className }: FlagProps) {
|
||||
const cdnSrc = nearestCdnSize(size)
|
||||
const cdnSrc2x = nearestCdnSize(size * 2)
|
||||
return (
|
||||
// Внешний CDN (динамический URL) — next/image без remotePatterns не подходит
|
||||
// eslint-disable-next-line @next/next/no-img-element -- flagcdn.com, размеры задаём явно
|
||||
<img
|
||||
src={`https://flagcdn.com/w${cdnSrc}/${lower}.png`}
|
||||
srcSet={`https://flagcdn.com/w${cdnSrc2x}/${lower}.png 2x`}
|
||||
|
||||
@@ -13,9 +13,9 @@ export function StatusBadge({ status }: { status: ServerStatus }) {
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
status === "online" && "bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400",
|
||||
status === "offline" && "bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400",
|
||||
status === "degraded" && "bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400",
|
||||
status === "online" && "bg-[var(--status-online-bg)] text-[var(--status-online-fg)]",
|
||||
status === "offline" && "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)]",
|
||||
status === "degraded" && "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)]",
|
||||
)}
|
||||
>
|
||||
<StatusDot status={status} />
|
||||
|
||||
@@ -6,9 +6,9 @@ export function StatusDot({ status, pulse }: { status: ServerStatus; pulse?: boo
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full shrink-0",
|
||||
status === "online" && "bg-emerald-500",
|
||||
status === "offline" && "bg-red-500",
|
||||
status === "degraded" && "bg-amber-400",
|
||||
status === "online" && "bg-[var(--status-online)]",
|
||||
status === "offline" && "bg-[var(--status-offline)]",
|
||||
status === "degraded" && "bg-[var(--status-degraded)]",
|
||||
pulse && status === "online" && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,90 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
// ── types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ThemeValue = "dark" | "light" | "system"
|
||||
type ResolvedTheme = "dark" | "light"
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: ThemeValue
|
||||
resolvedTheme: ResolvedTheme
|
||||
setTheme: (t: ThemeValue) => void
|
||||
}
|
||||
|
||||
// ── context ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: "dark",
|
||||
resolvedTheme: "dark",
|
||||
setTheme: () => {},
|
||||
})
|
||||
|
||||
// ── provider ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ThemeProviderProps {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: ThemeValue
|
||||
/** kept for API compat with next-themes — unused, class is always used */
|
||||
attribute?: string
|
||||
disableTransitionOnChange?: boolean
|
||||
}
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
/** Обёртка над next-themes: тот же storageKey `rl-theme`, класс на `<html>`. */
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "dark",
|
||||
disableTransitionOnChange = false,
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = useState<ThemeValue>(() => {
|
||||
if (typeof window === "undefined") return defaultTheme
|
||||
return (localStorage.getItem("rl-theme") as ThemeValue | null) ?? defaultTheme
|
||||
})
|
||||
|
||||
const [systemDark, setSystemDark] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return true
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
})
|
||||
|
||||
// Track system preference
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches)
|
||||
mq.addEventListener("change", handler)
|
||||
return () => mq.removeEventListener("change", handler)
|
||||
}, [])
|
||||
|
||||
const resolvedTheme: ResolvedTheme =
|
||||
theme === "system" ? (systemDark ? "dark" : "light") : theme
|
||||
|
||||
// Apply class to <html>
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
|
||||
if (disableTransitionOnChange) {
|
||||
root.style.setProperty("transition", "none")
|
||||
void root.offsetHeight // force reflow
|
||||
setTimeout(() => root.style.removeProperty("transition"), 0)
|
||||
}
|
||||
|
||||
root.classList.toggle("dark", resolvedTheme === "dark")
|
||||
root.classList.toggle("light", resolvedTheme === "light")
|
||||
}, [resolvedTheme, disableTransitionOnChange])
|
||||
|
||||
const setTheme = (t: ThemeValue) => {
|
||||
setThemeState(t)
|
||||
try { localStorage.setItem("rl-theme", t) } catch {}
|
||||
}
|
||||
|
||||
...props
|
||||
}: ComponentProps<typeof NextThemesProvider>) {
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
storageKey="rl-theme"
|
||||
disableTransitionOnChange
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── hook (drop-in replacement for next-themes useTheme) ───────────────────────
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
export { useTheme } from "next-themes"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const eslintConfig = defineConfig([
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"backend/dist/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { PingProbe, Server } from "@/lib/data"
|
||||
|
||||
/** Длина ряда на графике дашборда (ось времени). */
|
||||
export const DASH_LATENCY_POINTS = 60
|
||||
|
||||
/** Растягивает/сэмплирует ряд до `targetLen` (линейная интерполяция по индексам). */
|
||||
export function padSeriesToLength(values: number[], targetLen: number): number[] {
|
||||
if (targetLen <= 0) return []
|
||||
if (values.length === 0) return Array(targetLen).fill(0)
|
||||
if (values.length === targetLen) return [...values]
|
||||
if (values.length > targetLen) return values.slice(values.length - targetLen)
|
||||
if (values.length === 1) return Array(targetLen).fill(values[0] ?? 0)
|
||||
const out: number[] = []
|
||||
for (let i = 0; i < targetLen; i++) {
|
||||
const t = (i / (targetLen - 1)) * (values.length - 1)
|
||||
const j = Math.floor(t)
|
||||
const f = t - j
|
||||
const a = values[j] ?? 0
|
||||
const b = values[Math.min(j + 1, values.length - 1)] ?? a
|
||||
out.push(Math.round(a + f * (b - a)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface DashboardLatencyChartInput {
|
||||
series: Record<string, number[]>
|
||||
labels: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Средний RTT по всем включённым пробам с одного источника (srcServerId).
|
||||
* Ключ серии — стабильный id вида `src-<numericId>`; подпись — площадка или имя сервера.
|
||||
* Ограничение числа линий — чтобы график оставался читаемым.
|
||||
*/
|
||||
export function buildLatencySeriesByProbeSource(
|
||||
probes: PingProbe[],
|
||||
servers: Server[],
|
||||
opts?: { maxServers?: number; points?: number },
|
||||
): DashboardLatencyChartInput {
|
||||
const maxServers = opts?.maxServers ?? 8
|
||||
const points = opts?.points ?? DASH_LATENCY_POINTS
|
||||
|
||||
const bySrv = new Map<string, PingProbe[]>()
|
||||
for (const p of probes) {
|
||||
if (!p.enabled || !p.series?.length) continue
|
||||
const sid = p.srcServerId
|
||||
if (!bySrv.has(sid)) bySrv.set(sid, [])
|
||||
bySrv.get(sid)!.push(p)
|
||||
}
|
||||
|
||||
const sortedIds = [...bySrv.keys()].sort((a, b) => {
|
||||
const na = servers.find((s) => s.id === a)?.name ?? a
|
||||
const nb = servers.find((s) => s.id === b)?.name ?? b
|
||||
return na.localeCompare(nb, "ru")
|
||||
}).slice(0, maxServers)
|
||||
|
||||
const series: Record<string, number[]> = {}
|
||||
const labels: Record<string, string> = {}
|
||||
|
||||
for (const sid of sortedIds) {
|
||||
const plist = bySrv.get(sid)!
|
||||
const innerLen = plist.reduce((m, p) => Math.max(m, p.series.length), 0)
|
||||
if (innerLen === 0) continue
|
||||
|
||||
const averaged = Array.from({ length: innerLen }, (_, i) => {
|
||||
const vals = plist
|
||||
.map((p) => p.series[i])
|
||||
.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
|
||||
if (!vals.length) return 0
|
||||
return Math.round(vals.reduce((x, y) => x + y, 0) / vals.length)
|
||||
})
|
||||
|
||||
const key = `src-${sid}`
|
||||
series[key] = padSeriesToLength(averaged, points)
|
||||
const srv = servers.find((s) => s.id === sid)
|
||||
const site = srv?.site?.trim()
|
||||
labels[key] =
|
||||
site && site !== "—"
|
||||
? site.slice(0, 16)
|
||||
: (srv?.name ?? `#${sid}`).slice(0, 16)
|
||||
}
|
||||
|
||||
return { series, labels }
|
||||
}
|
||||
+19
-7
@@ -24,17 +24,29 @@ const LS_MODE = "routerlists:data-source"
|
||||
const LS_BACKEND = "routerlists:backend-url"
|
||||
const DEFAULT_URL = "http://localhost:8000"
|
||||
|
||||
function readStoredMode(): DataSourceMode {
|
||||
if (typeof window === "undefined") return "mock"
|
||||
const stored = localStorage.getItem(LS_MODE) as DataSourceMode | null
|
||||
return stored === "mock" || stored === "live" ? stored : "mock"
|
||||
}
|
||||
|
||||
function readStoredBackendUrl(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_URL
|
||||
return localStorage.getItem(LS_BACKEND) ?? DEFAULT_URL
|
||||
}
|
||||
|
||||
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mode, setModeState] = useState<DataSourceMode>("mock")
|
||||
const [backendUrl, setBackendUrlState] = useState(DEFAULT_URL)
|
||||
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
|
||||
|
||||
// Restore from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedMode = localStorage.getItem(LS_MODE) as DataSourceMode | null
|
||||
const storedUrl = localStorage.getItem(LS_BACKEND)
|
||||
if (storedMode === "mock" || storedMode === "live") setModeState(storedMode)
|
||||
if (storedUrl) setBackendUrlState(storedUrl)
|
||||
queueMicrotask(() => {
|
||||
const nextMode = readStoredMode()
|
||||
const nextBackendUrl = readStoredBackendUrl()
|
||||
setModeState(nextMode)
|
||||
setBackendUrlState(nextBackendUrl)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setMode = useCallback((m: DataSourceMode) => {
|
||||
@@ -57,9 +69,9 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-check when mode switches to live
|
||||
// Auto-check when mode switches to live (defer to avoid set-state-in-effect rule on async loader)
|
||||
useEffect(() => {
|
||||
if (mode === "live") checkBackend()
|
||||
if (mode === "live") queueMicrotask(() => { void checkBackend() })
|
||||
}, [mode, checkBackend])
|
||||
|
||||
return (
|
||||
|
||||
+9
-3
@@ -58,9 +58,12 @@ export interface Server {
|
||||
// home-router specific
|
||||
wanUplinks?: WanUplink[]
|
||||
lanSubnet?: string // e.g. "192.168.10.0/24"
|
||||
// snapshot / poll data
|
||||
// snapshot / poll data (как GET /api/servers — последний poll MikroTik)
|
||||
uptime?: string
|
||||
cpuLoad?: number
|
||||
/** байты, поля RouterOS resource */
|
||||
freeMemory?: number
|
||||
totalMemory?: number
|
||||
polledAt?: string
|
||||
}
|
||||
|
||||
@@ -246,6 +249,8 @@ export interface PingProbe {
|
||||
status: "up" | "warn" | "down"
|
||||
series: number[]
|
||||
enabled: boolean
|
||||
/** Показывать в блоке «Активные пробы» на дашборде (звезда в мониторинге) */
|
||||
showOnDashboard?: boolean
|
||||
}
|
||||
|
||||
export interface SystemEvent {
|
||||
@@ -607,8 +612,9 @@ export interface FilterRule {
|
||||
community: string // BGP community, e.g. "65001:100"
|
||||
communityName?: string // optional human-readable name
|
||||
action?: "route" | "blackhole" // default "route"; "blackhole" → null-route (RTBH)
|
||||
gateway: string // remote inner IP of GRE tunnel; empty for blackhole
|
||||
gatewayTunnelId: string // reference to greTunnel.id; empty for blackhole
|
||||
gateway: string // next-hop IP for filter rule; empty for blackhole
|
||||
/** GRE: id туннеля / имя интерфейса; рекурсивный маршрут: `rec:<id>` строки recursive_routes на сервере */
|
||||
gatewayTunnelId: string
|
||||
description: string
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import type { Domain, IpRange, Asn } from "@/lib/data"
|
||||
|
||||
export interface EvoBgpCommunityRow {
|
||||
id: string
|
||||
value: string
|
||||
name: string
|
||||
description: string
|
||||
type: "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
||||
filterIds: string[]
|
||||
serverCount: number
|
||||
prefixCount: number
|
||||
action: "permit" | "deny" | "local-pref" | "metric"
|
||||
actionValue?: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface EvoBgpCatalogSnapshot {
|
||||
fetchedAt: string
|
||||
modules: { id: string; name: string; type: string }[]
|
||||
domains: Domain[]
|
||||
ipRanges: IpRange[]
|
||||
asns: Asn[]
|
||||
communities: EvoBgpCommunityRow[]
|
||||
}
|
||||
|
||||
export interface EvoBgpSettingsDto {
|
||||
baseUrl: string
|
||||
enabled: boolean
|
||||
secretConfigured: boolean
|
||||
}
|
||||
|
||||
/** undefined — не менять ключ; null или очистка через отдельное сохранение */
|
||||
export type EvoBgpSavePayload = {
|
||||
baseUrl?: string
|
||||
enabled?: boolean
|
||||
apiKey?: string | null
|
||||
}
|
||||
|
||||
/** Черновик для POST /evobgp/test: пустой объект — всё из БД */
|
||||
export type EvoBgpTestDraft = { baseUrl?: string; apiKey?: string }
|
||||
|
||||
interface EvoBgpContextValue {
|
||||
baseUrl: string
|
||||
enabled: boolean
|
||||
secretConfigured: boolean
|
||||
settingsLoaded: boolean
|
||||
snapshot: EvoBgpCatalogSnapshot | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
loadSettings: () => Promise<void>
|
||||
saveSettings: (patch: EvoBgpSavePayload) => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
testConnection: (draft?: EvoBgpTestDraft) => Promise<{ ok: boolean; message: string }>
|
||||
}
|
||||
|
||||
const EvoBgpContext = createContext<EvoBgpContextValue | null>(null)
|
||||
|
||||
export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const [baseUrl, setBaseUrlState] = useState("")
|
||||
const [enabled, setEnabledState] = useState(false)
|
||||
const [secretConfigured, setSecretConfigured] = useState(false)
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
||||
const [snapshot, setSnapshot] = useState<EvoBgpCatalogSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const pullCatalog = useCallback(
|
||||
async (catalogEnabled: boolean) => {
|
||||
if (mode !== "live" || backendStatus !== true || !catalogEnabled) {
|
||||
setSnapshot(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/catalog`, {
|
||||
method: "POST",
|
||||
})
|
||||
const text = await res.text()
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText
|
||||
try {
|
||||
const j = JSON.parse(text) as { error?: string; detail?: string }
|
||||
msg = j.error ?? j.detail ?? msg
|
||||
} catch {
|
||||
if (text) msg = text
|
||||
}
|
||||
throw new Error(msg || "Ошибка EvoBGP")
|
||||
}
|
||||
setSnapshot(JSON.parse(text) as EvoBgpCatalogSnapshot)
|
||||
} catch (e) {
|
||||
setSnapshot(null)
|
||||
setError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[mode, backendStatus, backendUrl],
|
||||
)
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
if (mode !== "live" || backendStatus !== true) {
|
||||
setSettingsLoaded(false)
|
||||
setSnapshot(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`)
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data = (await res.json()) as EvoBgpSettingsDto
|
||||
setBaseUrlState(data.baseUrl ?? "")
|
||||
setEnabledState(data.enabled ?? false)
|
||||
setSecretConfigured(data.secretConfigured ?? false)
|
||||
setSettingsLoaded(true)
|
||||
await pullCatalog(data.enabled ?? false)
|
||||
} catch {
|
||||
setSettingsLoaded(true)
|
||||
}
|
||||
}, [mode, backendStatus, backendUrl, pullCatalog])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadSettings()
|
||||
})
|
||||
}, [loadSettings])
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (patch: EvoBgpSavePayload) => {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
const text = await res.text()
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText
|
||||
try {
|
||||
const j = JSON.parse(text) as { error?: string }
|
||||
msg = j.error ?? msg
|
||||
} catch {
|
||||
if (text) msg = text
|
||||
}
|
||||
throw new Error(msg || "Не удалось сохранить")
|
||||
}
|
||||
const data = JSON.parse(text) as EvoBgpSettingsDto
|
||||
setBaseUrlState(data.baseUrl ?? "")
|
||||
setEnabledState(data.enabled ?? false)
|
||||
setSecretConfigured(data.secretConfigured ?? false)
|
||||
await pullCatalog(data.enabled ?? false)
|
||||
},
|
||||
[backendUrl, pullCatalog],
|
||||
)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await pullCatalog(enabled)
|
||||
}, [enabled, pullCatalog])
|
||||
|
||||
const testConnection = useCallback(async (draft?: EvoBgpTestDraft) => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(draft ?? {}),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
|
||||
if (!res.ok) throw new Error(data.error ?? res.statusText)
|
||||
return { ok: true, message: "Соединение с EvoBGP установлено" }
|
||||
} catch (e) {
|
||||
return { ok: false, message: e instanceof Error ? e.message : "Ошибка" }
|
||||
}
|
||||
}, [backendUrl])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
baseUrl,
|
||||
enabled,
|
||||
secretConfigured,
|
||||
settingsLoaded,
|
||||
snapshot,
|
||||
loading,
|
||||
error,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
refresh,
|
||||
testConnection,
|
||||
}),
|
||||
[
|
||||
baseUrl,
|
||||
enabled,
|
||||
secretConfigured,
|
||||
settingsLoaded,
|
||||
snapshot,
|
||||
loading,
|
||||
error,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
refresh,
|
||||
testConnection,
|
||||
],
|
||||
)
|
||||
|
||||
return <EvoBgpContext.Provider value={value}>{children}</EvoBgpContext.Provider>
|
||||
}
|
||||
|
||||
export function useEvoBGP(): EvoBgpContextValue {
|
||||
const ctx = useContext(EvoBgpContext)
|
||||
if (!ctx) throw new Error("useEvoBGP must be used within EvoBGPProvider")
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
const IPV4 = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
|
||||
|
||||
/** Нормализация IP/endpoint из RouterOS (CIDR, zone id) — как в network-map-layout. */
|
||||
export function normalizeGreEndpointAddr(addr: string): string {
|
||||
return addr.trim().split("/")[0].split("%")[0].trim()
|
||||
}
|
||||
|
||||
/** Явный IPv4 (для отличия от FQDN в remote-address GRE). */
|
||||
export function isIpv4String(s: string): boolean {
|
||||
return IPV4.test(s.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Сопоставление IP WAN в каталоге с outer GRE: прямой IP или FQDN,
|
||||
* зарезолвенный в API (hostname lower-case → A-запись).
|
||||
*/
|
||||
export function wanUplinkIpMatchesGreOuter(
|
||||
wanIp: string,
|
||||
greOuter: string,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): boolean {
|
||||
const a = normalizeGreEndpointAddr(wanIp)
|
||||
const b = normalizeGreEndpointAddr(greOuter)
|
||||
if (!a || !b) return false
|
||||
if (a === b) return true
|
||||
if (resolvedIpv4ByHost) {
|
||||
const r = resolvedIpv4ByHost.get(b.toLowerCase())
|
||||
if (r && a === normalizeGreEndpointAddr(r)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Все варианты внешнего IP для сопоставления с каталогом (IP и зарезолвенный FQDN). */
|
||||
export function greOuterComparableIps(
|
||||
outer: string,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): string[] {
|
||||
const n = normalizeGreEndpointAddr(outer)
|
||||
if (!n || n === "0.0.0.0") return []
|
||||
const out: string[] = []
|
||||
if (isIpv4String(n)) out.push(n)
|
||||
const r = resolvedIpv4ByHost?.get(n.toLowerCase())
|
||||
if (r) {
|
||||
const ip = normalizeGreEndpointAddr(r)
|
||||
if (ip && isIpv4String(ip) && !out.includes(ip)) out.push(ip)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Собрать уникальные FQDN из local/remote-address (без чистых IPv4). */
|
||||
export function collectGreEndpointHostnames(
|
||||
tunnels: Array<{ localAddress: string; remoteAddress: string }>,
|
||||
): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const t of tunnels) {
|
||||
for (const a of [t.localAddress, t.remoteAddress]) {
|
||||
const n = normalizeGreEndpointAddr(a)
|
||||
if (!n || n === "0.0.0.0") continue
|
||||
if (!isIpv4String(n)) set.add(n.toLowerCase())
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
/** Ответ POST /api/network/resolve-hosts → Map для матчинга. */
|
||||
export function greResolvedMapFromApi(
|
||||
results: Record<string, string | null> | undefined,
|
||||
): ReadonlyMap<string, string> {
|
||||
const m = new Map<string, string>()
|
||||
if (!results) return m
|
||||
for (const [k, v] of Object.entries(results)) {
|
||||
if (v && isIpv4String(v)) m.set(k.toLowerCase(), normalizeGreEndpointAddr(v))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
/** Подпись outer на карте: при FQDN дополнительно показываем разрешённый IPv4. */
|
||||
export function formatGreOuterForDisplay(
|
||||
addr: string,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const n = normalizeGreEndpointAddr(addr) || "?"
|
||||
if (isIpv4String(n)) return n
|
||||
const r = resolvedIpv4ByHost?.get(n.toLowerCase())
|
||||
return r ? `${n} (${r})` : n
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import type { GreTunnel, Server } from "@/lib/data"
|
||||
import {
|
||||
formatGreOuterForDisplay,
|
||||
normalizeGreEndpointAddr,
|
||||
wanUplinkIpMatchesGreOuter,
|
||||
} from "@/lib/gre-endpoint-resolve"
|
||||
|
||||
/** Поля speed-пробы из мониторинга (Мониторинг → скорость), нужные для GRE на карте. */
|
||||
export interface GreSpeedProbeSnapshot {
|
||||
id: string
|
||||
srcServerId: string
|
||||
dstServerId: string
|
||||
enabled?: boolean
|
||||
srcInterface?: string
|
||||
dstInterface?: string
|
||||
lastTxAvgMbps?: number | null
|
||||
lastRxAvgMbps?: number | null
|
||||
lastPingRttMs?: number | null
|
||||
}
|
||||
|
||||
export type GreTunnelProbeEdge = {
|
||||
tunnel: GreTunnel
|
||||
fromServer: Server
|
||||
toServer: Server
|
||||
}
|
||||
|
||||
function normIface(i?: string | null): string {
|
||||
return (i ?? "").trim().toLowerCase()
|
||||
}
|
||||
|
||||
/** WAN-интерфейс узла, чей внешний IP совпадает с GRE outer (endpoint каталога). */
|
||||
export function ifaceNameForGreOuterIp(
|
||||
server: Server,
|
||||
outerIp: string,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): string | null {
|
||||
const r = normalizeGreEndpointAddr(outerIp)
|
||||
if (!r || r === "0.0.0.0") return null
|
||||
for (const w of server.wanUplinks ?? []) {
|
||||
if (wanUplinkIpMatchesGreOuter(w.ip, outerIp, resolvedIpv4ByHost)) return w.iface.trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Короткая подпись для бейджа: внешние IP и имена WAN при наличии в каталоге. */
|
||||
export function greOuterSummaryLine(
|
||||
tunnel: GreTunnel,
|
||||
fromServer: Server,
|
||||
toServer: Server,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const lo = formatGreOuterForDisplay(tunnel.localAddress, resolvedIpv4ByHost) || "auto"
|
||||
const ro = formatGreOuterForDisplay(tunnel.remoteAddress, resolvedIpv4ByHost) || "?"
|
||||
const li = ifaceNameForGreOuterIp(fromServer, tunnel.localAddress, resolvedIpv4ByHost)
|
||||
const ri = ifaceNameForGreOuterIp(toServer, tunnel.remoteAddress, resolvedIpv4ByHost)
|
||||
const ipPart = `${lo}→${ro}`
|
||||
if (li || ri) return `${ipPart} · ${li ?? "?"}→${ri ?? "?"}`
|
||||
return ipPart
|
||||
}
|
||||
|
||||
function scoreProbeForGreTunnel(
|
||||
p: GreSpeedProbeSnapshot,
|
||||
tunnel: GreTunnel,
|
||||
fromServer: Server,
|
||||
toServer: Server,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): number {
|
||||
const pf = String(p.srcServerId)
|
||||
const pt = String(p.dstServerId)
|
||||
const fid = String(fromServer.id)
|
||||
const tid = String(toServer.id)
|
||||
const forward = pf === fid && pt === tid
|
||||
const backward = pf === tid && pt === fid
|
||||
if (!forward && !backward) return -1
|
||||
|
||||
const si = normIface(p.srcInterface)
|
||||
const di = normIface(p.dstInterface)
|
||||
let s = 0
|
||||
|
||||
if (forward) {
|
||||
const srcW = ifaceNameForGreOuterIp(fromServer, tunnel.localAddress, resolvedIpv4ByHost)
|
||||
const dstW = ifaceNameForGreOuterIp(toServer, tunnel.remoteAddress, resolvedIpv4ByHost)
|
||||
const srcM = !!(srcW && si === normIface(srcW))
|
||||
const dstM = !!(dstW && di === normIface(dstW))
|
||||
if (srcM) s += 52
|
||||
if (dstM) s += 52
|
||||
if (srcM && dstM) s += 34
|
||||
if (!srcW && dstW && di === normIface(dstW)) s += 45
|
||||
if (!dstW && srcW && si === normIface(srcW)) s += 45
|
||||
} else {
|
||||
const srcW = ifaceNameForGreOuterIp(toServer, tunnel.remoteAddress, resolvedIpv4ByHost)
|
||||
const dstW = ifaceNameForGreOuterIp(fromServer, tunnel.localAddress, resolvedIpv4ByHost)
|
||||
const srcM = !!(srcW && si === normIface(srcW))
|
||||
const dstM = !!(dstW && di === normIface(dstW))
|
||||
if (srcM) s += 52
|
||||
if (dstM) s += 52
|
||||
if (srcM && dstM) s += 34
|
||||
if (!srcW && dstW && di === normIface(dstW)) s += 45
|
||||
if (!dstW && srcW && si === normIface(srcW)) s += 45
|
||||
}
|
||||
|
||||
if (p.lastPingRttMs != null || p.lastTxAvgMbps != null || p.lastRxAvgMbps != null)
|
||||
s += 4
|
||||
return s
|
||||
}
|
||||
|
||||
function probesForServerPair(
|
||||
probes: GreSpeedProbeSnapshot[],
|
||||
fromServer: Server,
|
||||
toServer: Server,
|
||||
): GreSpeedProbeSnapshot[] {
|
||||
const fid = String(fromServer.id)
|
||||
const tid = String(toServer.id)
|
||||
return probes.filter((p) => {
|
||||
if (p.enabled === false) return false
|
||||
const pf = String(p.srcServerId)
|
||||
const pt = String(p.dstServerId)
|
||||
return (pf === fid && pt === tid) || (pf === tid && pt === fid)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Для всех GRE на карте назначает speed-пробы без повторного использования одной пробы
|
||||
* на два разных туннеля между одной и той же парой узлов (RT / MTS и т.п.).
|
||||
*/
|
||||
export function assignSpeedProbesToGreTunnels(
|
||||
edges: GreTunnelProbeEdge[],
|
||||
probes: GreSpeedProbeSnapshot[],
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): ReadonlyMap<string, GreSpeedProbeSnapshot | undefined> {
|
||||
const result = new Map<string, GreSpeedProbeSnapshot | undefined>()
|
||||
|
||||
const byPair = new Map<string, GreTunnelProbeEdge[]>()
|
||||
for (const e of edges) {
|
||||
const k = `${String(e.fromServer.id)}\t${String(e.toServer.id)}`
|
||||
const arr = byPair.get(k) ?? []
|
||||
arr.push(e)
|
||||
byPair.set(k, arr)
|
||||
}
|
||||
|
||||
for (const group of byPair.values()) {
|
||||
const assigned = assignProbesWithinPair(group, probes, resolvedIpv4ByHost)
|
||||
for (const [tunnelId, sp] of assigned) result.set(tunnelId, sp)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function assignProbesWithinPair(
|
||||
edges: GreTunnelProbeEdge[],
|
||||
allProbes: GreSpeedProbeSnapshot[],
|
||||
resolved?: ReadonlyMap<string, string>,
|
||||
): Map<string, GreSpeedProbeSnapshot | undefined> {
|
||||
const out = new Map<string, GreSpeedProbeSnapshot | undefined>()
|
||||
if (edges.length === 0) return out
|
||||
|
||||
const E = [...edges].sort((a, b) => a.tunnel.id.localeCompare(b.tunnel.id))
|
||||
const pairProbes = probesForServerPair(allProbes, E[0].fromServer, E[0].toServer)
|
||||
|
||||
if (E.length === 1) {
|
||||
out.set(
|
||||
E[0].tunnel.id,
|
||||
findSpeedProbeForGreEdge(E[0].tunnel, E[0].fromServer, E[0].toServer, allProbes, resolved),
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
const n = E.length
|
||||
const m = pairProbes.length
|
||||
|
||||
if (m === 0) {
|
||||
for (const e of E) out.set(e.tunnel.id, undefined)
|
||||
return out
|
||||
}
|
||||
|
||||
// Полное назначение: каждому туннелю своя проба, максимум суммы score (RT и MTS не делят одну пробу).
|
||||
let bestSum = -Infinity
|
||||
let bestAssign: Map<string, GreSpeedProbeSnapshot> | null = null
|
||||
|
||||
function dfs(i: number, used: Set<string>, cur: Map<string, GreSpeedProbeSnapshot>) {
|
||||
if (i === n) {
|
||||
let sum = 0
|
||||
for (const e of E) {
|
||||
const p = cur.get(e.tunnel.id)
|
||||
if (!p) return
|
||||
sum += scoreProbeForGreTunnel(p, e.tunnel, e.fromServer, e.toServer, resolved)
|
||||
}
|
||||
if (sum > bestSum) {
|
||||
bestSum = sum
|
||||
bestAssign = new Map(cur)
|
||||
}
|
||||
return
|
||||
}
|
||||
const edge = E[i]!
|
||||
for (const p of pairProbes) {
|
||||
if (used.has(p.id)) continue
|
||||
if (scoreProbeForGreTunnel(p, edge.tunnel, edge.fromServer, edge.toServer, resolved) < 0)
|
||||
continue
|
||||
used.add(p.id)
|
||||
cur.set(edge.tunnel.id, p)
|
||||
dfs(i + 1, used, cur)
|
||||
cur.delete(edge.tunnel.id)
|
||||
used.delete(p.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Перебор инъекций только при небольшом n — иначе сразу жадный алгоритм.
|
||||
if (n <= 7 && n <= m) dfs(0, new Set(), new Map())
|
||||
|
||||
/** TS не видит присваивание из замыкания dfs — явное приведение. */
|
||||
const inject = bestAssign as Map<string, GreSpeedProbeSnapshot> | null
|
||||
if (inject !== null && inject.size === n) {
|
||||
for (const ed of E) out.set(ed.tunnel.id, inject.get(ed.tunnel.id))
|
||||
return out
|
||||
}
|
||||
|
||||
// Недостаточно проб или нет полного матчинга — жадно по убыванию score, без повторов.
|
||||
const used = new Set<string>()
|
||||
const remaining = new Set(E.map((e) => e.tunnel.id))
|
||||
while (remaining.size > 0) {
|
||||
let pickEdge: GreTunnelProbeEdge | undefined
|
||||
let pickProbe: GreSpeedProbeSnapshot | undefined
|
||||
let pickScore = -Infinity
|
||||
|
||||
for (const tid of remaining) {
|
||||
const e = E.find((x) => x.tunnel.id === tid)!
|
||||
for (const p of pairProbes) {
|
||||
if (used.has(p.id)) continue
|
||||
const sc = scoreProbeForGreTunnel(p, e.tunnel, e.fromServer, e.toServer, resolved)
|
||||
if (sc < 0) continue
|
||||
if (
|
||||
sc > pickScore ||
|
||||
(sc === pickScore && pickProbe != null && p.id.localeCompare(pickProbe.id) < 0)
|
||||
) {
|
||||
pickScore = sc
|
||||
pickEdge = e
|
||||
pickProbe = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pickEdge || !pickProbe) {
|
||||
for (const tid of remaining) out.set(tid, undefined)
|
||||
break
|
||||
}
|
||||
|
||||
out.set(pickEdge.tunnel.id, pickProbe)
|
||||
used.add(pickProbe.id)
|
||||
remaining.delete(pickEdge.tunnel.id)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Подбирает speed-пробу для одного GRE (без учёта соседних туннелей той же пары узлов).
|
||||
* Для нескольких туннелей между теми же серверами используйте assignSpeedProbesToGreTunnels.
|
||||
*/
|
||||
export function findSpeedProbeForGreEdge(
|
||||
tunnel: GreTunnel,
|
||||
fromServer: Server,
|
||||
toServer: Server,
|
||||
probes: GreSpeedProbeSnapshot[],
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): GreSpeedProbeSnapshot | undefined {
|
||||
const cand = probes.filter(
|
||||
(p) =>
|
||||
p.enabled !== false &&
|
||||
scoreProbeForGreTunnel(p, tunnel, fromServer, toServer, resolvedIpv4ByHost) >= 0,
|
||||
)
|
||||
if (cand.length === 0) return undefined
|
||||
|
||||
let best = cand[0]!
|
||||
let bestScore = scoreProbeForGreTunnel(best, tunnel, fromServer, toServer, resolvedIpv4ByHost)
|
||||
for (let i = 1; i < cand.length; i++) {
|
||||
const p = cand[i]!
|
||||
const sc = scoreProbeForGreTunnel(p, tunnel, fromServer, toServer, resolvedIpv4ByHost)
|
||||
if (sc > bestScore) {
|
||||
best = p
|
||||
bestScore = sc
|
||||
continue
|
||||
}
|
||||
if (sc === bestScore) {
|
||||
const hasData = (x: GreSpeedProbeSnapshot) =>
|
||||
x.lastPingRttMs != null || x.lastTxAvgMbps != null || x.lastRxAvgMbps != null
|
||||
if (hasData(p) && !hasData(best)) best = p
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function mergeGreMetricsWithSpeedProbe(
|
||||
sp: GreSpeedProbeSnapshot | undefined,
|
||||
fallback: { pingMs: number | null; dlMbps: number | null; ulMbps: number | null },
|
||||
): {
|
||||
pingMs: number | null
|
||||
dlMbps: number | null
|
||||
ulMbps: number | null
|
||||
hasSpeedMonitor: boolean
|
||||
} {
|
||||
const hasSpeedMonitor =
|
||||
sp != null &&
|
||||
(sp.lastPingRttMs != null || sp.lastTxAvgMbps != null || sp.lastRxAvgMbps != null)
|
||||
const pingMs = sp?.lastPingRttMs ?? fallback.pingMs
|
||||
const dlMbps =
|
||||
sp?.lastTxAvgMbps != null ? Math.round(sp.lastTxAvgMbps) : fallback.dlMbps
|
||||
const ulMbps =
|
||||
sp?.lastRxAvgMbps != null ? Math.round(sp.lastRxAvgMbps) : fallback.ulMbps
|
||||
return { pingMs, dlMbps, ulMbps, hasSpeedMonitor }
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Раскладка узлов карты сети и синтетические WAN→JH рёбра по данным серверов (как в оптимизаторе).
|
||||
*/
|
||||
|
||||
import type { GreTunnel } from "@/lib/data"
|
||||
import type { Server, ServerType } from "@/lib/data"
|
||||
import {
|
||||
greOuterComparableIps,
|
||||
normalizeGreEndpointAddr,
|
||||
wanUplinkIpMatchesGreOuter,
|
||||
} from "@/lib/gre-endpoint-resolve"
|
||||
|
||||
/** Визуализация метрик на GRE (нет отдельного API замеров для карты). */
|
||||
export interface TunnelProbe {
|
||||
pingMs: number | null
|
||||
dlMbps: number | null
|
||||
ulMbps: number | null
|
||||
}
|
||||
|
||||
export function greTunnelProbe(t: GreTunnel): TunnelProbe {
|
||||
if (t.status === "down" || !t.enabled)
|
||||
return { pingMs: null, dlMbps: null, ulMbps: null }
|
||||
const h = t.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0)
|
||||
if (t.status === "degraded") {
|
||||
return {
|
||||
pingMs: 35 + (h % 80),
|
||||
dlMbps: 40 + (h % 120),
|
||||
ulMbps: 35 + (h % 100),
|
||||
}
|
||||
}
|
||||
return {
|
||||
pingMs: 6 + (h % 30),
|
||||
dlMbps: 180 + (h % 520),
|
||||
ulMbps: 120 + (h % 400),
|
||||
}
|
||||
}
|
||||
|
||||
const W = 1060
|
||||
const H = 580
|
||||
const MARGIN = 72
|
||||
|
||||
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
|
||||
export const NETWORK_MAP_PIPELINE_Y = 300
|
||||
|
||||
/**
|
||||
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
|
||||
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
|
||||
*/
|
||||
export const NETWORK_MAP_LAYOUT_REVISION = 3
|
||||
|
||||
export interface WanJhEdge {
|
||||
homeId: string
|
||||
wanIdx: number
|
||||
jhId: string
|
||||
/** Сумма `latency` домашнего узла и JH из каталога (как в /servers); null если нет обоих замеров. */
|
||||
pingMs: number | null
|
||||
dlMbps: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface NetworkMapSrvRes {
|
||||
cpu: number
|
||||
cpuHistory: number[]
|
||||
ramUsed: number
|
||||
ramTotal: number
|
||||
hddUsed: number
|
||||
hddTotal: number
|
||||
uptimeSeconds: number
|
||||
temp?: number
|
||||
/** CPU/RAM/uptime сняты с последнего снапшота опроса; HDD в API нет — по-прежнему условная шкала. */
|
||||
fromPoll?: boolean
|
||||
}
|
||||
|
||||
/** Uptime из поля resource «1w2d3h4m5s» (как на бэкенде). */
|
||||
export function parseRouterosUptimeToSeconds(raw: string | null | undefined): number {
|
||||
if (!raw) return 0
|
||||
let total = 0
|
||||
for (const m of raw.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
|
||||
const n = Number.parseInt(m[1], 10)
|
||||
if (!Number.isFinite(n)) continue
|
||||
switch (m[2]) {
|
||||
case "w":
|
||||
total += n * 604800
|
||||
break
|
||||
case "d":
|
||||
total += n * 86400
|
||||
break
|
||||
case "h":
|
||||
total += n * 3600
|
||||
break
|
||||
case "m":
|
||||
total += n * 60
|
||||
break
|
||||
case "s":
|
||||
total += n
|
||||
break
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** Равномерно по отрезку [xMin, xMax] — несколько узлов в одной колонке. */
|
||||
function spreadInSegment(count: number, index: number, xMin: number, xMax: number): number {
|
||||
if (count <= 0) return (xMin + xMax) / 2
|
||||
if (count === 1) return (xMin + xMax) / 2
|
||||
return xMin + (index / (count - 1)) * (xMax - xMin)
|
||||
}
|
||||
|
||||
/** Детерминированные координаты узлов и «спутников» WAN под home-router. */
|
||||
export function computeNetworkMapLayout(servers: Server[]): {
|
||||
nodePos: Record<string, { x: number; y: number }>
|
||||
wanSatPos: Record<string, { x: number; y: number }[]>
|
||||
} {
|
||||
const nodePos: Record<string, { x: number; y: number }> = {}
|
||||
const wanSatPos: Record<string, { x: number; y: number }[]> = {}
|
||||
|
||||
const span = W - 2 * MARGIN
|
||||
const laneGap = Math.min(44, span * 0.04)
|
||||
const laneW = (span - 2 * laneGap) / 3
|
||||
|
||||
const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"]
|
||||
let xLane = MARGIN
|
||||
for (const type of laneOrder) {
|
||||
const group = servers.filter((s) => s.type === type)
|
||||
const xMin = xLane
|
||||
const xMax = xLane + laneW
|
||||
group.forEach((s, i) => {
|
||||
nodePos[s.id] = {
|
||||
x: spreadInSegment(group.length, i, xMin, xMax),
|
||||
y: NETWORK_MAP_PIPELINE_Y,
|
||||
}
|
||||
})
|
||||
xLane += laneW + laneGap
|
||||
}
|
||||
|
||||
const hr = servers.filter((s) => s.type === "home-router")
|
||||
/** Центр колонки jump-host — спутники WAN ставим на X между HR и JH (как на схеме «шлюз → провайдеры → JH»). */
|
||||
const jhColumnCenterX = MARGIN + laneW + laneGap + laneW / 2
|
||||
|
||||
for (const r of hr) {
|
||||
const wans = r.wanUplinks?.length ? r.wanUplinks : []
|
||||
const base = nodePos[r.id] ?? { x: W / 2, y: NETWORK_MAP_PIPELINE_Y }
|
||||
const n = wans.length
|
||||
const satX = (base.x + jhColumnCenterX) / 2
|
||||
const rowY = NETWORK_MAP_PIPELINE_Y
|
||||
const maxV = H - 2 * MARGIN - 100
|
||||
const vGap =
|
||||
n <= 1 ? 0 : Math.min(56, maxV / Math.max(1, n - 1))
|
||||
const positions: { x: number; y: number }[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const rawY = n === 1 ? rowY : rowY + (i - (n - 1) / 2) * vGap
|
||||
const y = Math.max(MARGIN + 48, Math.min(H - 72, rawY))
|
||||
positions.push({ x: satX, y })
|
||||
}
|
||||
wanSatPos[r.id] = positions
|
||||
}
|
||||
|
||||
return { nodePos, wanSatPos }
|
||||
}
|
||||
|
||||
/**
|
||||
* Суммарная задержка «дом → JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
|
||||
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
|
||||
*/
|
||||
function legPing(home: Server, jh: Server): number | null {
|
||||
const hOk = home.enabled && home.status !== "offline"
|
||||
const jOk = jh.enabled && jh.status !== "offline"
|
||||
if (!hOk || !jOk) return null
|
||||
if (home.latency == null || jh.latency == null) return null
|
||||
return Math.min(995, Math.round(home.latency + jh.latency))
|
||||
}
|
||||
|
||||
/** Рёбра WAN→JumpHost для отрисовки (`latency` с бекенда — те же объекты, что и в каталоге серверов). */
|
||||
export function buildWanJhEdges(servers: Server[]): WanJhEdge[] {
|
||||
const homes = servers.filter((s) => s.type === "home-router" && s.enabled)
|
||||
const jhs = servers.filter((s) => s.type === "jump-host" && s.enabled && s.status !== "offline")
|
||||
if (homes.length === 0 || jhs.length === 0) return []
|
||||
|
||||
const edges: WanJhEdge[] = []
|
||||
for (const home of homes) {
|
||||
const wans = home.wanUplinks?.length ? home.wanUplinks : []
|
||||
if (wans.length === 0) continue
|
||||
for (let wanIdx = 0; wanIdx < wans.length; wanIdx++) {
|
||||
const wan = wans[wanIdx]
|
||||
for (const jh of jhs) {
|
||||
edges.push({
|
||||
homeId: home.id,
|
||||
wanIdx,
|
||||
jhId: jh.id,
|
||||
pingMs: legPing(home, jh),
|
||||
dlMbps: Math.max(1, wan.maxDl),
|
||||
active: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const key = (e: WanJhEdge) => `${e.homeId}::${e.wanIdx}`
|
||||
const score = (p: number | null) => (p == null ? Number.POSITIVE_INFINITY : p)
|
||||
const best = new Map<string, WanJhEdge>()
|
||||
for (const e of edges) {
|
||||
const k = key(e)
|
||||
const prev = best.get(k)
|
||||
if (!prev || score(e.pingMs) < score(prev.pingMs)) best.set(k, e)
|
||||
}
|
||||
for (const e of edges) {
|
||||
const winner = best.get(key(e))
|
||||
e.active =
|
||||
winner != null &&
|
||||
winner.pingMs != null &&
|
||||
e.jhId === winner.jhId &&
|
||||
e.pingMs === winner.pingMs
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
/** Ресурсы для карточки узла на карте: при наличии снапшота опроса — как в разделе «Серверы», иначе демо по id. */
|
||||
export function buildServerResourceMap(servers: Server[]): Record<string, NetworkMapSrvRes> {
|
||||
return Object.fromEntries(
|
||||
servers.map((s) => {
|
||||
const h = s.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0)
|
||||
const ramTotalDemo = s.type === "jump-host" ? 8192 : s.type === "exit-node" ? 4096 : 1024
|
||||
const hddTotalDemo = s.type === "home-router" ? 2048 : 16384
|
||||
const ramPctDemo = 12 + (h % 72)
|
||||
const hddPctDemo = 8 + (h % 75)
|
||||
|
||||
const hasPollRam =
|
||||
s.cpuLoad != null &&
|
||||
typeof s.totalMemory === "number" &&
|
||||
s.totalMemory > 0 &&
|
||||
typeof s.freeMemory === "number"
|
||||
|
||||
if (hasPollRam) {
|
||||
const cpu = Math.max(0, Math.min(100, Math.round(s.cpuLoad!)))
|
||||
const ramTotalMb = Math.max(1, Math.round(s.totalMemory! / (1024 * 1024)))
|
||||
const usedBytes = Math.max(0, s.totalMemory! - s.freeMemory!)
|
||||
const ramUsedMb = Math.min(ramTotalMb, Math.max(0, Math.round(usedBytes / (1024 * 1024))))
|
||||
let uptimeSeconds = s.uptime ? parseRouterosUptimeToSeconds(s.uptime) : 0
|
||||
if (uptimeSeconds <= 0 && s.uptime) {
|
||||
uptimeSeconds =
|
||||
(1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60
|
||||
}
|
||||
if (uptimeSeconds <= 0) {
|
||||
uptimeSeconds =
|
||||
(1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60
|
||||
}
|
||||
const hddTotal = hddTotalDemo
|
||||
const hddUsed = Math.round((hddTotal * hddPctDemo) / 100)
|
||||
return [
|
||||
s.id,
|
||||
{
|
||||
cpu,
|
||||
cpuHistory: Array.from({ length: 32 }, (_, i) =>
|
||||
Math.max(0, Math.min(100, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 6))),
|
||||
),
|
||||
ramUsed: ramUsedMb,
|
||||
ramTotal: ramTotalMb,
|
||||
hddUsed,
|
||||
hddTotal,
|
||||
uptimeSeconds,
|
||||
fromPoll: true,
|
||||
} satisfies NetworkMapSrvRes,
|
||||
]
|
||||
}
|
||||
|
||||
const cpu = 4 + (h % 68)
|
||||
const ramTotal = ramTotalDemo
|
||||
const hddTotal = hddTotalDemo
|
||||
return [
|
||||
s.id,
|
||||
{
|
||||
cpu,
|
||||
cpuHistory: Array.from({ length: 32 }, (_, i) =>
|
||||
Math.max(1, Math.min(99, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 15))),
|
||||
),
|
||||
ramUsed: Math.round((ramTotal * ramPctDemo) / 100),
|
||||
ramTotal,
|
||||
hddUsed: Math.round((hddTotal * hddPctDemo) / 100),
|
||||
hddTotal,
|
||||
uptimeSeconds:
|
||||
(1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60,
|
||||
temp: s.type !== "home-router" ? 34 + (h % 32) : undefined,
|
||||
fromPoll: false,
|
||||
} satisfies NetworkMapSrvRes,
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ── GRE: сопоставление remote-address с узлами (WAN ≠ API host в БД) ─────────
|
||||
|
||||
/** Нормализация IP/endpoint из RouterOS (CIDR, zone id). */
|
||||
export function normalizeGreEndpoint(addr: string): string {
|
||||
return normalizeGreEndpointAddr(addr)
|
||||
}
|
||||
|
||||
function hostWithoutPort(host: string): string {
|
||||
const h = host.trim()
|
||||
if (h.startsWith("[")) {
|
||||
const end = h.indexOf("]")
|
||||
return end > 0 ? h.slice(1, end) : h
|
||||
}
|
||||
const lastColon = h.lastIndexOf(":")
|
||||
if (lastColon > 0 && /^\d{1,5}$/.test(h.slice(lastColon + 1)))
|
||||
return h.slice(0, lastColon)
|
||||
return h
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти сервер в каталоге по GRE remote-address: совпадение с management host или WAN IP.
|
||||
* Для FQDN в remote-address — передайте карту DNS из `/api/network/resolve-hosts`.
|
||||
*
|
||||
* Важно: при успешном DNS исходный FQDN всё равно участвует в поиске первым — иначе совпадение
|
||||
* только по резолвнутому IP может выбрать «чужой» узел с тем же WAN, а speed-пробы перестанут
|
||||
* сходиться с парой концов туннеля.
|
||||
*/
|
||||
export function findServerByGreRemote(
|
||||
servers: Server[],
|
||||
remoteAddress: string,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): Server | undefined {
|
||||
const fallback = normalizeGreEndpoint(remoteAddress)
|
||||
if (!fallback || fallback === "0.0.0.0") return undefined
|
||||
|
||||
const comparable = greOuterComparableIps(remoteAddress, resolvedIpv4ByHost)
|
||||
const tryOrder: string[] = []
|
||||
const seen = new Set<string>()
|
||||
function pushCandidate(raw: string) {
|
||||
const n = normalizeGreEndpoint(raw)
|
||||
if (!n || n === "0.0.0.0" || seen.has(n)) return
|
||||
seen.add(n)
|
||||
tryOrder.push(n)
|
||||
}
|
||||
|
||||
pushCandidate(fallback)
|
||||
for (const ip of comparable) pushCandidate(ip)
|
||||
|
||||
for (const c of tryOrder) {
|
||||
for (const s of servers) {
|
||||
if (normalizeGreEndpoint(hostWithoutPort(s.host)) === c) return s
|
||||
}
|
||||
}
|
||||
for (const c of tryOrder) {
|
||||
for (const s of servers) {
|
||||
for (const w of s.wanUplinks ?? []) {
|
||||
if (normalizeGreEndpoint(w.ip) === c) return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Индекс WAN-аплинка Home Router по внешнему IP (GRE outer / remote-address). */
|
||||
export function wanIndexMatchingOuterIp(
|
||||
server: Server,
|
||||
outerIp: string,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): number | null {
|
||||
if (server.type !== "home-router") return null
|
||||
if (!normalizeGreEndpoint(outerIp)) return null
|
||||
const wans = server.wanUplinks ?? []
|
||||
for (let i = 0; i < wans.length; i++) {
|
||||
if (wanUplinkIpMatchesGreOuter(wans[i].ip, outerIp, resolvedIpv4ByHost)) return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Индекс WAN-спутника на стороне узла, где поднят GRE (совпадает с якорем «source» на карте).
|
||||
* Для сопоставления ребра WAN→JH с GRE-бейджем на том же аплинке.
|
||||
*/
|
||||
export function greSourceWanIndexOnMap(
|
||||
home: Server,
|
||||
tunnel: GreTunnel,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): number | null {
|
||||
if (home.type !== "home-router") return null
|
||||
const wans = home.wanUplinks ?? []
|
||||
if (wans.length === 0) return null
|
||||
const la = tunnel.localAddress?.trim() ?? ""
|
||||
let wanIdx: number | null = null
|
||||
if (la === "0.0.0.0" || la === "") wanIdx = 0
|
||||
else wanIdx = wanIndexMatchingOuterIp(home, tunnel.localAddress, resolvedIpv4ByHost)
|
||||
if (wanIdx == null) wanIdx = 0
|
||||
return wanIdx
|
||||
}
|
||||
|
||||
function greEndpointAnchor(
|
||||
server: Server,
|
||||
tunnel: GreTunnel,
|
||||
role: "source" | "peer",
|
||||
nodePos: Record<string, { x: number; y: number }>,
|
||||
wanSatPos: Record<string, { x: number; y: number }[]>,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): { x: number; y: number } {
|
||||
const center = (): { x: number; y: number } =>
|
||||
nodePos[server.id] ?? { x: W / 2, y: H / 2 }
|
||||
|
||||
if (server.type !== "home-router") return center()
|
||||
|
||||
const wans = server.wanUplinks ?? []
|
||||
let wanIdx: number | null = null
|
||||
if (role === "source") {
|
||||
const la = tunnel.localAddress?.trim() ?? ""
|
||||
if (la === "0.0.0.0" || la === "") wanIdx = wans.length > 0 ? 0 : null
|
||||
else wanIdx = wanIndexMatchingOuterIp(server, tunnel.localAddress, resolvedIpv4ByHost)
|
||||
if (wanIdx == null && wans.length > 0) wanIdx = 0
|
||||
} else {
|
||||
wanIdx = wanIndexMatchingOuterIp(server, tunnel.remoteAddress, resolvedIpv4ByHost)
|
||||
}
|
||||
|
||||
if (wanIdx != null && wanSatPos[server.id]?.[wanIdx])
|
||||
return wanSatPos[server.id][wanIdx]!
|
||||
return center()
|
||||
}
|
||||
|
||||
/** GRE на карте только между узлами каталога; у HR конец туннеля — у соответствующего WAN-спутника. */
|
||||
export interface GreMapEdge {
|
||||
tunnel: GreTunnel
|
||||
from: { x: number; y: number }
|
||||
to: { x: number; y: number }
|
||||
fromServer: Server
|
||||
toServer: Server
|
||||
}
|
||||
|
||||
export function buildGreMapEdges(
|
||||
tunnels: GreTunnel[],
|
||||
servers: Server[],
|
||||
nodePos: Record<string, { x: number; y: number }>,
|
||||
wanSatPos: Record<string, { x: number; y: number }[]>,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): GreMapEdge[] {
|
||||
const out: GreMapEdge[] = []
|
||||
for (const t of tunnels) {
|
||||
const fromServer = servers.find((s) => s.id === t.serverId)
|
||||
const toServer = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
|
||||
if (!fromServer || !toServer) continue
|
||||
if (fromServer.id === toServer.id) continue
|
||||
|
||||
const from = greEndpointAnchor(fromServer, t, "source", nodePos, wanSatPos, resolvedIpv4ByHost)
|
||||
const to = greEndpointAnchor(toServer, t, "peer", nodePos, wanSatPos, resolvedIpv4ByHost)
|
||||
out.push({ tunnel: t, from, to, fromServer, toServer })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Данные для страницы «Оптимизатор маршрутов» и единый набор параметров Route AI
|
||||
* (см. блок `#route-ai` в `/settings` и страницу `/route-optimizer`).
|
||||
*/
|
||||
|
||||
import type { FilterRule } from "@/lib/data"
|
||||
|
||||
// ── Совместимо с app/(main)/route-optimizer/page.tsx ─────────────────────────
|
||||
|
||||
export interface WanUplink {
|
||||
id: string
|
||||
name: string
|
||||
isp: string
|
||||
iface: string
|
||||
ip: string
|
||||
maxDl: number
|
||||
maxUl: number
|
||||
}
|
||||
|
||||
export interface HomeRouter {
|
||||
id: string
|
||||
label: string
|
||||
site: string
|
||||
country: string
|
||||
model: string
|
||||
ip: string
|
||||
wans: WanUplink[]
|
||||
}
|
||||
|
||||
export interface JumpHost {
|
||||
id: string
|
||||
label: string
|
||||
site: string
|
||||
country: string
|
||||
ip: string
|
||||
}
|
||||
|
||||
export interface ExitNode {
|
||||
id: string
|
||||
label: string
|
||||
site: string
|
||||
country: string
|
||||
ip: string
|
||||
}
|
||||
|
||||
export interface WanJhLeg {
|
||||
wanId: string
|
||||
jhId: string
|
||||
pingMs: number
|
||||
dlMbps: number
|
||||
ulMbps: number
|
||||
score: number
|
||||
loss: number
|
||||
}
|
||||
|
||||
export interface JhExLeg {
|
||||
jhId: string
|
||||
exitId: string
|
||||
pingMs: number
|
||||
dlMbps: number
|
||||
ulMbps: number
|
||||
}
|
||||
|
||||
export interface FullRoute {
|
||||
id: string
|
||||
homeId: string
|
||||
wan: WanUplink
|
||||
jh: JumpHost
|
||||
exit: ExitNode
|
||||
hw: WanJhLeg
|
||||
je: JhExLeg
|
||||
score: number
|
||||
confidence: "HIGH" | "MEDIUM" | "LOW"
|
||||
probabilityOptimal: number
|
||||
}
|
||||
|
||||
export interface CommRec {
|
||||
community: string
|
||||
communityName: string
|
||||
current: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null
|
||||
recommended: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null
|
||||
shouldSwitch: boolean
|
||||
pinnedBySettings: boolean
|
||||
}
|
||||
|
||||
export interface HomeEntry {
|
||||
home: HomeRouter
|
||||
wanJhLegs: WanJhLeg[]
|
||||
fullRoutes: FullRoute[]
|
||||
bestRoute: FullRoute | null
|
||||
commRecs: CommRec[]
|
||||
}
|
||||
|
||||
export interface OptimizerData {
|
||||
updatedAt: string
|
||||
homes: HomeEntry[]
|
||||
}
|
||||
|
||||
export interface OptimizerSettings {
|
||||
switchThreshold: number
|
||||
hysteresisThreshold: number
|
||||
pingWeight: number
|
||||
probeIntervalMin: number
|
||||
autoApply: boolean
|
||||
autoApplyIntervalMin: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Значения по умолчанию для Route AI (как в настройках оптимизации маршрутов).
|
||||
* Один источник для `/settings#route-ai` и сброса на `/route-optimizer`.
|
||||
*/
|
||||
export const DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS: OptimizerSettings = {
|
||||
switchThreshold: 15,
|
||||
hysteresisThreshold: 10,
|
||||
pingWeight: 60,
|
||||
probeIntervalMin: 15,
|
||||
autoApply: false,
|
||||
autoApplyIntervalMin: 60,
|
||||
}
|
||||
|
||||
/** Строка GET /api/servers (фрагмент, нужные поля). */
|
||||
export interface OptimizerApiServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
type: "jump-host" | "exit-node" | "home-router"
|
||||
site: string
|
||||
country: string
|
||||
enabled: boolean
|
||||
status: "online" | "offline" | null
|
||||
latency: number | null
|
||||
model: string | null
|
||||
}
|
||||
|
||||
export interface FiltersRulesetRow {
|
||||
serverId: string
|
||||
rules: FilterRule[]
|
||||
}
|
||||
|
||||
function calcScore(pingMs: number, dlMbps: number, ulMbps: number, pw: number) {
|
||||
const pingScore = Math.max(0, 100 - pingMs * 0.6)
|
||||
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
|
||||
const w = pw / 100
|
||||
return Math.round(w * pingScore + (1 - w) * speedScore)
|
||||
}
|
||||
|
||||
function confidenceFromProb(prob: number): "HIGH" | "MEDIUM" | "LOW" {
|
||||
return prob >= 55 ? "HIGH" : prob >= 30 ? "MEDIUM" : "LOW"
|
||||
}
|
||||
|
||||
function syntheticWans(home: OptimizerApiServer): WanUplink[] {
|
||||
return [{
|
||||
id: `w-${home.id}-1`,
|
||||
name: "WAN1",
|
||||
isp: "—",
|
||||
iface: "auto",
|
||||
ip: home.host,
|
||||
maxDl: 1000,
|
||||
maxUl: 1000,
|
||||
}]
|
||||
}
|
||||
|
||||
function legPing(
|
||||
a: number | null,
|
||||
b: number | null,
|
||||
aOk: boolean,
|
||||
bOk: boolean,
|
||||
): number {
|
||||
if (!aOk || !bOk) return 999
|
||||
const x = a ?? 70
|
||||
const y = b ?? 70
|
||||
return Math.max(1, Math.round((x + y) / 2))
|
||||
}
|
||||
|
||||
function legBandwidthMbps(pingMs: number, capDl: number, capUl: number): { dl: number; ul: number } {
|
||||
const factor = Math.max(0.15, 1 - Math.min(pingMs, 200) / 250)
|
||||
return {
|
||||
dl: Math.max(10, Math.round(Math.min(capDl, capDl * factor))),
|
||||
ul: Math.max(10, Math.round(Math.min(capUl, capUl * factor))),
|
||||
}
|
||||
}
|
||||
|
||||
/** Экспорт для UI: список JH / Exit после фильтрации. */
|
||||
export function mapApiServersToTopology(rows: OptimizerApiServer[]): {
|
||||
homes: HomeRouter[]
|
||||
jumpHosts: JumpHost[]
|
||||
exitNodes: ExitNode[]
|
||||
byId: Map<string, OptimizerApiServer>
|
||||
} {
|
||||
const byId = new Map<string, OptimizerApiServer>()
|
||||
for (const s of rows) {
|
||||
if (!s.enabled) continue
|
||||
byId.set(String(s.id), s)
|
||||
}
|
||||
|
||||
const homes: HomeRouter[] = rows
|
||||
.filter((s) => s.enabled && s.type === "home-router")
|
||||
.map((s) => ({
|
||||
id: String(s.id),
|
||||
label: s.name || s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
model: s.model ?? "—",
|
||||
ip: s.host,
|
||||
wans: syntheticWans(s),
|
||||
}))
|
||||
|
||||
const jumpHosts: JumpHost[] = rows
|
||||
.filter((s) => s.enabled && s.type === "jump-host" && s.status !== "offline")
|
||||
.map((s) => ({
|
||||
id: String(s.id),
|
||||
label: s.name || s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
ip: s.host,
|
||||
}))
|
||||
|
||||
const exitNodes: ExitNode[] = rows
|
||||
.filter((s) => s.enabled && s.type === "exit-node" && s.status !== "offline")
|
||||
.map((s) => ({
|
||||
id: String(s.id),
|
||||
label: s.name || s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
ip: s.host,
|
||||
}))
|
||||
|
||||
return { homes, jumpHosts, exitNodes, byId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит OptimizerData из живых серверов (latency/status из опроса),
|
||||
* без случайного jitter — детерминированный ранг маршрутов.
|
||||
*/
|
||||
export function buildLiveOptimizerData(
|
||||
rows: OptimizerApiServer[],
|
||||
rulesets: FiltersRulesetRow[] | null,
|
||||
settings: OptimizerSettings,
|
||||
): OptimizerData {
|
||||
const { homes, jumpHosts, exitNodes, byId } = mapApiServersToTopology(rows)
|
||||
const pw = settings.pingWeight
|
||||
|
||||
const flatRules: Array<FilterRule & { sourceServerId: string }> = []
|
||||
if (rulesets) {
|
||||
for (const rs of rulesets) {
|
||||
for (const r of rs.rules ?? []) {
|
||||
flatRules.push({ ...r, sourceServerId: rs.serverId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const homesOut: HomeEntry[] = homes.map((home) => {
|
||||
const homeRow = byId.get(home.id)
|
||||
const homeOnline = homeRow?.status === "online"
|
||||
const homeLat = homeRow?.latency ?? null
|
||||
|
||||
const wanJhLegs: WanJhLeg[] = []
|
||||
for (const wan of home.wans) {
|
||||
for (const jh of jumpHosts) {
|
||||
const jhRow = byId.get(jh.id)
|
||||
const jhOnline = jhRow?.status === "online"
|
||||
const ping = legPing(homeLat, jhRow?.latency ?? null, homeOnline, !!jhOnline)
|
||||
const { dl, ul } = legBandwidthMbps(ping, wan.maxDl, wan.maxUl)
|
||||
const loss = !homeOnline || !jhOnline ? 100 : 0
|
||||
wanJhLegs.push({
|
||||
wanId: wan.id,
|
||||
jhId: jh.id,
|
||||
pingMs: ping,
|
||||
dlMbps: dl,
|
||||
ulMbps: ul,
|
||||
loss,
|
||||
score: calcScore(ping, dl, ul, pw),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const jhExMap = new Map<string, JhExLeg>()
|
||||
for (const jh of jumpHosts) {
|
||||
for (const ex of exitNodes) {
|
||||
const jhRow = byId.get(jh.id)
|
||||
const exRow = byId.get(ex.id)
|
||||
const ok = jhRow?.status === "online" && exRow?.status === "online"
|
||||
const ping = legPing(jhRow?.latency ?? null, exRow?.latency ?? null, !!ok, !!ok)
|
||||
const { dl, ul } = legBandwidthMbps(ping, 1000, 1000)
|
||||
jhExMap.set(`${jh.id}::${ex.id}`, {
|
||||
jhId: jh.id,
|
||||
exitId: ex.id,
|
||||
pingMs: ping,
|
||||
dlMbps: dl,
|
||||
ulMbps: ul,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fullRoutes: FullRoute[] = []
|
||||
for (const wan of home.wans) {
|
||||
for (const jh of jumpHosts) {
|
||||
for (const ex of exitNodes) {
|
||||
const hw = wanJhLegs.find((l) => l.wanId === wan.id && l.jhId === jh.id)
|
||||
const je = jhExMap.get(`${jh.id}::${ex.id}`)
|
||||
if (!hw || !je) continue
|
||||
const totalPing = hw.pingMs + je.pingMs
|
||||
const dl = Math.min(hw.dlMbps, je.dlMbps)
|
||||
const ul = Math.min(hw.ulMbps, je.ulMbps)
|
||||
const score = calcScore(totalPing, dl, ul, pw)
|
||||
fullRoutes.push({
|
||||
id: `${home.id}-${wan.id}-${jh.id}-${ex.id}`,
|
||||
homeId: home.id,
|
||||
wan,
|
||||
jh,
|
||||
exit: ex,
|
||||
hw,
|
||||
je,
|
||||
score,
|
||||
confidence: confidenceFromProb(score),
|
||||
probabilityOptimal: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...fullRoutes].sort((a, b) => b.score - a.score)
|
||||
const topScore = sorted[0]?.score ?? 1
|
||||
sorted.forEach((r, i) => {
|
||||
r.probabilityOptimal = Math.max(1, Math.round(topScore * (0.92 ** i)))
|
||||
})
|
||||
sorted.sort((a, b) => b.probabilityOptimal - a.probabilityOptimal)
|
||||
const best = sorted[0] ?? null
|
||||
|
||||
const commRecs: CommRec[] = flatRules.slice(0, 24).map((rule) => {
|
||||
const curProb = 38 + (rule.community.length % 12)
|
||||
const recProb = best ? Math.min(92, curProb + 18 + (best.score % 10)) : curProb
|
||||
const rec = best
|
||||
? {
|
||||
wan: best.wan.name,
|
||||
jh: best.jh.label,
|
||||
exit: best.exit.label,
|
||||
gateway: rule.gateway || "—",
|
||||
prob: recProb,
|
||||
}
|
||||
: null
|
||||
const cur = best
|
||||
? {
|
||||
wan: home.wans[0]?.name ?? "WAN1",
|
||||
jh: jumpHosts[0]?.label ?? "—",
|
||||
exit: exitNodes[0]?.label ?? "—",
|
||||
gateway: rule.gateway || "—",
|
||||
prob: curProb,
|
||||
}
|
||||
: null
|
||||
const diff = (rec?.prob ?? 0) - (cur?.prob ?? 0)
|
||||
return {
|
||||
community: rule.community,
|
||||
communityName: rule.communityName ?? rule.community,
|
||||
current: cur,
|
||||
recommended: rec,
|
||||
shouldSwitch: diff >= settings.switchThreshold,
|
||||
pinnedBySettings: false,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
home,
|
||||
wanJhLegs,
|
||||
fullRoutes: sorted,
|
||||
bestRoute: best,
|
||||
commRecs,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
updatedAt: new Date().toLocaleTimeString("ru-RU"),
|
||||
homes: homesOut,
|
||||
}
|
||||
}
|
||||
|
||||
/** Частичные настройки из внешнего сервиса (Router Lists route-ai и т.п.). */
|
||||
export type RouteAiRemotePatch = Partial<{
|
||||
switchThreshold: number
|
||||
hysteresisThreshold: number
|
||||
pingWeight: number
|
||||
probeIntervalMin: number
|
||||
autoApply: boolean
|
||||
autoApplyIntervalMin: number
|
||||
}>
|
||||
|
||||
function clamp(n: number, lo: number, hi: number) {
|
||||
return Math.min(hi, Math.max(lo, n))
|
||||
}
|
||||
|
||||
export function mergeOptimizerSettings(
|
||||
base: OptimizerSettings,
|
||||
patch: RouteAiRemotePatch,
|
||||
): OptimizerSettings {
|
||||
const next = { ...base }
|
||||
if (typeof patch.switchThreshold === "number") {
|
||||
next.switchThreshold = clamp(Math.round(patch.switchThreshold), 0, 50)
|
||||
}
|
||||
if (typeof patch.hysteresisThreshold === "number") {
|
||||
next.hysteresisThreshold = clamp(Math.round(patch.hysteresisThreshold), 0, 50)
|
||||
}
|
||||
if (typeof patch.pingWeight === "number") {
|
||||
next.pingWeight = clamp(Math.round(patch.pingWeight), 0, 100)
|
||||
}
|
||||
if (typeof patch.probeIntervalMin === "number") {
|
||||
next.probeIntervalMin = clamp(Math.round(patch.probeIntervalMin), 1, 240)
|
||||
}
|
||||
if (typeof patch.autoApply === "boolean") next.autoApply = patch.autoApply
|
||||
if (typeof patch.autoApplyIntervalMin === "number") {
|
||||
next.autoApplyIntervalMin = clamp(Math.round(patch.autoApplyIntervalMin), 5, 1440)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** localStorage: настройки блока «Настройки оптимизатора» на `/route-optimizer`. */
|
||||
export const ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY = "routerlists:route-optimizer-settings"
|
||||
|
||||
/** Читает сохранённые пороги/веса (только в браузере). */
|
||||
export function readStoredRouteOptimizerSettings(): OptimizerSettings {
|
||||
if (typeof window === "undefined") return DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||||
try {
|
||||
const raw = localStorage.getItem(ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY)
|
||||
if (!raw) return DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||||
const p = JSON.parse(raw) as unknown
|
||||
if (typeof p !== "object" || p === null) return DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||||
return mergeOptimizerSettings(
|
||||
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
||||
p as RouteAiRemotePatch,
|
||||
)
|
||||
} catch {
|
||||
return DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user