This commit is contained in:
Denozordec
2026-05-03 11:16:07 +07:00
parent ce00c4c671
commit bdb9b72fac
66 changed files with 9553 additions and 1547 deletions
+3 -1
View File
@@ -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)"
]
}
}
+21
View File
@@ -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}
+131
View File
@@ -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**: чистый модульный код, строгое разделение слоёв, долгосрочная поддерживаемость и предсказуемый процесс разработки.
+38
View File
@@ -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’у однозначный вход: **что трогать, что не плодить, откуда копировать поведение**.
+29
View File
@@ -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`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
+36
View File
@@ -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) и требования к проверкам.
+58
View File
@@ -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.
+42
View File
@@ -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**.
+43
View File
@@ -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.
+81
View File
@@ -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, готовый к продакшену код без ошибок сборки/рантайма по возможности.
+1 -3
View File
@@ -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,
)}
>
+31 -3
View File
@@ -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
View File
@@ -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])
+37 -7
View File
@@ -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
View File
@@ -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>
+31 -3
View File
@@ -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={[
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -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
View File
@@ -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>
+32 -3
View File
@@ -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={[
+8 -5
View File
@@ -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>
)
}
File diff suppressed because it is too large Load Diff
+42 -28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
}
/>
+31 -34
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+39 -7
View File
@@ -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
View File
@@ -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>
+105
View File
@@ -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 })
+74
View File
@@ -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
+8 -1
View File
@@ -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()
+1 -1
View File
@@ -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"])
+281
View File
@@ -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
View File
@@ -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}`,
+67
View File
@@ -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
+412
View File
@@ -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
+59 -3
View File
@@ -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
View File
@@ -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,
}
})
+53 -16
View File
@@ -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 -1
View File
@@ -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"
+70 -23
View File
@@ -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) {
+24
View File
@@ -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)
+22
View File
@@ -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
}
+24
View File
@@ -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
View File
@@ -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 />
+7 -3
View File
@@ -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(() => {
+73 -35
View File
@@ -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>
)
})}
+2
View File
@@ -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`}
+3 -3
View File
@@ -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} />
+3 -3
View File
@@ -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",
)}
/>
+15 -80
View File
@@ -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"
+90
View File
@@ -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,
}
+1
View File
@@ -9,6 +9,7 @@ const eslintConfig = defineConfig([
".next/**",
"out/**",
"build/**",
"backend/dist/**",
"next-env.d.ts",
]),
{
+84
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+223
View File
@@ -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
}
+86
View File
@@ -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
}
+310
View File
@@ -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 }
}
+446
View File
@@ -0,0 +1,446 @@
/**
* Раскладка узлов карты сети и синтетические WANJH рёбра по данным серверов (как в оптимизаторе).
*/
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» на карте).
* Для сопоставления ребра WANJH с 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
}
+434
View File
@@ -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
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long