diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3b6d903..e67e8ad 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/.cursor/debug-00844d.log b/.cursor/debug-00844d.log new file mode 100644 index 0000000..43753d3 --- /dev/null +++ b/.cursor/debug-00844d.log @@ -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} diff --git a/.cursor/rules/fastify-backend-drizzle.mdc b/.cursor/rules/fastify-backend-drizzle.mdc new file mode 100644 index 0000000..9767e54 --- /dev/null +++ b/.cursor/rules/fastify-backend-drizzle.mdc @@ -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// + 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**: чистый модульный код, строгое разделение слоёв, долгосрочная поддерживаемость и предсказуемый процесс разработки. diff --git a/.cursor/rules/ma-analyzer.mdc b/.cursor/rules/ma-analyzer.mdc new file mode 100644 index 0000000..13e87a6 --- /dev/null +++ b/.cursor/rules/ma-analyzer.mdc @@ -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’у однозначный вход: **что трогать, что не плодить, откуда копировать поведение**. diff --git a/.cursor/rules/ma-global.mdc b/.cursor/rules/ma-global.mdc new file mode 100644 index 0000000..2e55ccd --- /dev/null +++ b/.cursor/rules/ma-global.mdc @@ -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`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют. diff --git a/.cursor/rules/ma-implementer.mdc b/.cursor/rules/ma-implementer.mdc new file mode 100644 index 0000000..297cddd --- /dev/null +++ b/.cursor/rules/ma-implementer.mdc @@ -0,0 +1,36 @@ +--- +description: "[Multi-agent] Роль IMPLEMENTER — реализация по результатам анализа, /servers, shadcn" +alwaysApply: false +--- + +# Агент: IMPLEMENTER (реализатор) + +Ты пишешь **production-ready** изменения **на основе** отчёта Analyzer (и уточнённой задачи пользователя). + +## Язык + +- Все ответы пользователю — **на русском**. + +## Обязанности + +- Внедрять фичу **минимально**, но **полностью**: все затронутые файлы (типы, UI, API, actions/handlers) согласованы. +- **Переиспользовать** существующие абстракции из отчёта анализа и кодовой базы. +- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция). +- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики). +- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент. + +## Формат вывода (строго) + +1. **Что изменено** (файлы и суть). +2. **Почему так** — связка Next.js + shadcn/ui + эталон **`/servers`** + принятые в репозитории паттерны. +3. **Код** — диффы или полные фрагменты по всем затронутым файлам. +4. По возможности: **Server vs Client** для новых/изменённых границ компонентов. + +## Запреты + +- Не оставлять «полфичи» (забытые типы, сломанные импорты, несогласованный API). +- Не копипастить большие куски без привязки к существующим компонентам, если анализ уже указал переиспользование. + +## Согласование с проектом + +- Если активно правило **`next-shadcn-production.mdc`**, выдерживай его структуру ответа (шаги 0–6) и требования к проверкам. diff --git a/.cursor/rules/ma-orchestrator.mdc b/.cursor/rules/ma-orchestrator.mdc new file mode 100644 index 0000000..b722cd5 --- /dev/null +++ b/.cursor/rules/ma-orchestrator.mdc @@ -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. diff --git a/.cursor/rules/ma-reviewer.mdc b/.cursor/rules/ma-reviewer.mdc new file mode 100644 index 0000000..990fa64 --- /dev/null +++ b/.cursor/rules/ma-reviewer.mdc @@ -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**. diff --git a/.cursor/rules/ma-ui-guardian.mdc b/.cursor/rules/ma-ui-guardian.mdc new file mode 100644 index 0000000..9cdd3f4 --- /dev/null +++ b/.cursor/rules/ma-ui-guardian.mdc @@ -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. diff --git a/.cursor/rules/next-shadcn-production.mdc b/.cursor/rules/next-shadcn-production.mdc new file mode 100644 index 0000000..77e9c7e --- /dev/null +++ b/.cursor/rules/next-shadcn-production.mdc @@ -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, готовый к продакшену код без ошибок сборки/рантайма по возможности. diff --git a/app/(main)/alerts/page.tsx b/app/(main)/alerts/page.tsx index 5f9c0ef..9e4364e 100644 --- a/app/(main)/alerts/page.tsx +++ b/app/(main)/alerts/page.tsx @@ -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, )} > diff --git a/app/(main)/asns/page.tsx b/app/(main)/asns/page.tsx index 8f08bd7..aa2ca2a 100644 --- a/app/(main)/asns/page.tsx +++ b/app/(main)/asns/page.tsx @@ -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 (
Автономные системы — источники маршрутов для BGP-фильтров

+ {useEvoCatalog && ( +

+ {loading && } + {error + ? `EvoBGP: ${error}` + : snapshot?.fetchedAt + ? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}` + : loading ? "Загрузка EvoBGP…" : "EvoBGP"} +

+ )}
{[ - { 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 => (
{r.label} @@ -426,15 +427,15 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
{/* search */}
- - + 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 && ( )} @@ -522,7 +523,7 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) { {s.prefixesTx > 0 - ? {fmtNum(s.prefixesTx)} + ? {fmtNum(s.prefixesTx)} : } @@ -582,12 +583,14 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
{estCnt > 0 && ( - + ✓ {estCnt} )} {downCnt > 0 && ( - + ⚠ {downCnt} )} @@ -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 - }) - .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 + }) + .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]) diff --git a/app/(main)/communities/page.tsx b/app/(main)/communities/page.tsx index b76e253..214d0db 100644 --- a/app/(main)/communities/page.tsx +++ b/app/(main)/communities/page.tsx @@ -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 = { // ─── 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("all") const [copied, setCopied] = useState(null) const [selected, setSelected] = useState(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() {
+ {useEvoCatalog && ( +

+ {loading && } + {error + ? `EvoBGP: ${error}` + : snapshot?.fetchedAt + ? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}` + : loading ? "Загрузка EvoBGP…" : "EvoBGP"} +

+ )} {/* ── summary ── */}
{[ - { 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 }) => ( diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index fa012d9..fe9a825 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -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(path: string, init?: RequestInit): Promise { + 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 + } +} function StatCard({ label, value, unit, delta, deltaDir, spark, sparkColor, @@ -25,7 +63,7 @@ function StatCard({ {unit && {unit}}
{delta && ( -

+

{delta}

)} @@ -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 { + 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 ( + + {type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"} + + ) +} + +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 ( +
+ + + +
+

+ Сервер {probe.srcServerId} +

+

+ {iface} +

+
+
+ ) + } + + return ( +
+ + + +
+ +
+
+ {srv.name} + +
+

+ {iface} + {srv.site && srv.site !== "—" && ( + · {srv.site} + )} +

+
+
+
+ ) +} + 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(null) + const [liveServersResolved, setLiveServersResolved] = useState(null) + const [liveKpi, setLiveKpi] = useState(null) + const [probesLoading, setProbesLoading] = useState(false) + const [probesError, setProbesError] = useState(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("/api/servers") + setLiveServersResolved(backendServers.map(mapBackendToServer)) + } catch { + setLiveServersResolved([]) + } + + const [fr, br] = await Promise.allSettled([ + apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"), + apiFetch>("/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 | 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 (
@@ -89,12 +609,33 @@ export default function DashboardPage() {
Задержка до серверов -

Последние 60 минут · ping от монитора → серверы MikroTik

+

+ {latencyChartBlock.kind === "mock" && latencyChartBlock.subtitle} + {latencyChartBlock.kind === "live" && latencyChartBlock.subtitle} + {latencyChartBlock.kind === "loading" && "Загрузка…"} + {latencyChartBlock.kind === "empty" && "Нет серии RTT для графика"} +

- + {latencyChartBlock.kind === "loading" && ( +
+ )} + {latencyChartBlock.kind === "empty" && ( +

+ {latencyChartBlock.message} +

+ )} + {(latencyChartBlock.kind === "mock" || latencyChartBlock.kind === "live") && ( + + )} @@ -111,9 +652,9 @@ export default function DashboardPage() { {systemEvents.map((e) => (
- {e.sev === "destructive" && } - {e.sev === "warning" && } - {e.sev === "info" && } + {e.sev === "destructive" && } + {e.sev === "warning" && } + {e.sev === "info" && }

{e.title}

@@ -138,7 +679,7 @@ export default function DashboardPage() {
RX 318 Мбит/с - TX 244 Мбит/с + TX 244 Мбит/с
@@ -149,26 +690,65 @@ export default function DashboardPage() { -
-
+
+
Состояние серверов -

7 узлов MikroTik

+

+ {serverStatusModel.subtitle} +

- + + Управление → +
- {servers.slice(0, 5).map((s) => ( -
- -
-

{s.name}

-

- {s.host} · {s.site} · {s.asn} + {serverStatusModel.kind === "loading" && ( + <> + {Array.from({ length: 5 }, (_, i) => ( +

+
+
+
+
+
+
+
+
+ ))} + + )} + {serverStatusModel.kind === "unavailable" && ( +
+ {serverStatusModel.subtitle} +
+ )} + {(serverStatusModel.kind === "mock" || serverStatusModel.kind === "live") && serverStatusModel.servers.length === 0 && ( +
+ Нет серверов в базе. Добавьте узел на странице «Серверы». +
+ )} + {(serverStatusModel.kind === "mock" || serverStatusModel.kind === "live") && serverStatusModel.servers.map((s) => ( +
+ +
+

{s.name}

+

+ + {s.host} · {s.site} · {s.asn}

- 60 ? "text-amber-500" : "text-muted-foreground"}`}> + 60 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground"}`}> {s.latency == null ? "недоступен" : `${s.latency}мс`} @@ -185,9 +765,20 @@ export default function DashboardPage() {
Активные пробы -

8 ping-зондов · опрос раз в секунду

+

+ {probesSubtitle} +

- + + Открыть монитор → +
@@ -195,7 +786,8 @@ export default function DashboardPage() { - + + @@ -205,11 +797,21 @@ export default function DashboardPage() { - {pingProbes.map((p) => { + {isLive && probesLoading && liveProbes === null && ( + + + + )} + {!(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 ( - + + ) })} + {!(isLive && probesLoading && liveProbes === null) && activeProbesTable.length === 0 && ( + + + + )}
ПробаИсточникПроба Цель Фильтр RTT
+
+
{p.name} + + {p.name} {p.target} @@ -218,7 +820,7 @@ export default function DashboardPage() { {p.rtt == null ? "—" : `${p.rtt} мс`} 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}> - {p.loss}% + {formatLossPct(p.loss)} @@ -229,6 +831,15 @@ export default function DashboardPage() {
+ {isLive && probesError + ? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга." + : "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."} +
diff --git a/app/(main)/domains/page.tsx b/app/(main)/domains/page.tsx index 6fdafa0..30a91f1 100644 --- a/app/(main)/domains/page.tsx +++ b/app/(main)/domains/page.tsx @@ -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 (
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik

+ {useEvoCatalog && ( +

+ {loading && } + {error + ? `EvoBGP: ${error}` + : snapshot?.fetchedAt + ? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}` + : loading ? "Загрузка EvoBGP…" : "EvoBGP"} +

+ )}
+ } + const icon = + status === "synced" + ? + : status === "drift" + ? + : status === "missing" + ? + : + const title = + status === "synced" + ? "Совпадает с цепочкой bgp-in на MikroTik" + : status === "drift" + ? "В БД и на роутере разное действие (gateway, blackhole или out-interface)" + : status === "missing" + ? "Эта community не найдена в правиле bgp-in на роутере" + : "Не проверено — нажмите «Сверить с роутером»" + return ( + + + {icon} + + + {title} + + + ) +} + function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` } function innerIpToGateway(ip: string) { return ip.split("/")[0] } +/** В BGP routing filter для рекурсивного маршрута — set gateway к хопу из dst-address (напр. 10.9.9.2/32 → 10.9.9.2) */ +function gatewayFromRecursiveDst(dstAddress: string): string { + return innerIpToGateway((dstAddress ?? "").trim()) +} + +/** Если в правиле нет rec: (например после sync с роутера до обогащения на бэкенде), находим строку по совпадению hop с dst-prefix */ +function pickRecursiveRouteByGatewayHop( + routes: RecursiveRouteLite[], + hopIp: string, +): RecursiveRouteLite | undefined { + const hop = hopIp.trim() + if (!hop) return undefined + const candidates = routes.filter( + r => !r.disabled && gatewayFromRecursiveDst(r.dstAddress) === hop, + ) + if (candidates.length === 0) return undefined + return candidates.reduce((a, b) => (a.distance <= b.distance ? a : b)) +} + +/** Same as backend: IP%iface из поля gateway рекурсивного маршрута */ +function splitRecursiveGateway(raw: string): { ip: string; iface: string } { + const v = (raw ?? "").trim() + if (!v) return { ip: "", iface: "" } + const pct = v.indexOf("%") + const left = pct >= 0 ? v.slice(0, pct) : v + const iface = pct >= 0 ? v.slice(pct + 1).trim() : "" + const ip = left.split("/")[0]?.trim() ?? "" + return { ip, iface } +} + +function isRecursiveGatewayRef(ref: string): boolean { + return ref.startsWith("rec:") +} + +/** Один ключ на префикс назначения — несколько записей с тем же dst-address (разные via, резерв) = один логический маршрут */ +function normalizeRecursiveDstAddress(raw: string): string { + return (raw ?? "").trim().toLowerCase() +} + +/** + * Рекурсивный маршрут в RouterOS может быть представлен несколькими статическими записями + * на один и тот же dst-address (разный gateway / резервирование). Для выбора в фильтре нужна + * одна строка на уникальный dst-address; при совпадении оставляем меньший distance. + */ +function dedupeRecursiveRoutesByDstAddress(routes: RecursiveRouteLite[]): RecursiveRouteLite[] { + const byDst = new Map() + for (const r of routes) { + if (r.disabled) continue + const key = normalizeRecursiveDstAddress(r.dstAddress) + if (!key) continue + const prev = byDst.get(key) + if (!prev) { + byDst.set(key, r) + continue + } + if (r.distance < prev.distance) byDst.set(key, r) + } + return Array.from(byDst.values()).sort((a, b) => { + if (a.distance !== b.distance) return a.distance - b.distance + return normalizeRecursiveDstAddress(a.dstAddress).localeCompare(normalizeRecursiveDstAddress(b.dstAddress)) + }) +} + +interface RecursiveRouteLite { + id: string + dstAddress: string + gateway: string + distance: number + routingTable: string + comment: string + disabled: boolean +} + const COMMUNITY_NAMES: Record = { "65001:100": "youtube-bypass", "65001:200": "streaming-eu", @@ -49,8 +164,6 @@ const COMMUNITY_NAMES: Record = { // Communities that imply blackhole action by default const BLACKHOLE_COMMUNITIES = new Set(["65535:666", "65001:666", "65002:666"]) -const COMMUNITY_SUGGESTIONS = Object.keys(COMMUNITY_NAMES) - // ── RouterOS config generator (combined, all servers) ───────────────────────── // // Единый скрипт для всех MikroTik (RouterOS 7). @@ -62,6 +175,7 @@ function generateCombinedRouterOSConfig( rulesets: ServerFilterRuleset[], serversList: Server[], tunnelsList: GreTunnel[], + recRoutesByServer: Record, ): string { const now = new Date().toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" }) const withRules = rulesets.filter(r => r.rules.length > 0) @@ -85,10 +199,9 @@ function generateCombinedRouterOSConfig( lines.push(`# ${server.name} · ${server.type === "jump-host" ? "JH" : "EN"} · ${server.site} · ${server.host}`) + const recList = recRoutesByServer[rs.serverId] ?? [] const branches = rs.rules.map((rule, i) => { const isBlackhole = rule.action === "blackhole" - const tunnel = !isBlackhole ? tunnelsList.find(t => t.id === rule.gatewayTunnelId) : undefined - const iface = tunnel ? tunnel.name : "unknown" const label = rule.communityName ?? rule.community const desc = rule.description ? `${label} — ${rule.description}` : label const kw = i === 0 ? "if" : "} else if" @@ -100,6 +213,19 @@ function generateCombinedRouterOSConfig( ` accept;`, ].join("\n") } + if (isRecursiveGatewayRef(rule.gatewayTunnelId)) { + const rid = rule.gatewayTunnelId.slice(4) + const rr = recList.find(r => r.id === rid) + const gw = rr ? gatewayFromRecursiveDst(rr.dstAddress) : rule.gateway + return [ + ` ${kw} (bgp-communities.has(\\"${rule.community}\\")) {`, + ` # ${desc}`, + ` set gateway ${gw};`, + ` accept;`, + ].join("\n") + } + const tunnel = tunnelsList.find(t => t.id === rule.gatewayTunnelId) + const iface = tunnel ? tunnel.name : "unknown" return [ ` ${kw} (bgp-communities.has(\\"${rule.community}\\")) {`, ` # ${desc}`, @@ -181,7 +307,7 @@ function GatewayCards({
{remoteSrv && } {t.name} @@ -200,13 +326,103 @@ function GatewayCards({ ) } +function RecursiveGatewayCards({ + selectedTunnelId, + routes, + onSelect, +}: { + selectedTunnelId: string + routes: RecursiveRouteLite[] + onSelect: (route: RecursiveRouteLite) => void +}) { + const list = useMemo(() => dedupeRecursiveRoutesByDstAddress(routes), [routes]) + + /** Подсветка, если в правиле сохранён rec:id «дубликата», убранного из списка — совпадение по dst-address */ + const selectedDstNorm = useMemo(() => { + if (!isRecursiveGatewayRef(selectedTunnelId)) return null + const id = selectedTunnelId.slice(4) + const raw = routes.find(r => r.id === id) + return raw ? normalizeRecursiveDstAddress(raw.dstAddress) : null + }, [selectedTunnelId, routes]) + + if (list.length === 0) { + return ( +
+ Нет рекурсивных маршрутов для этого сервера. +
+ + Добавьте их в «Рекурсивные маршруты» и синхронизируйте с роутером + +
+ ) + } + + return ( +
+ {list.map(r => { + const ref = `rec:${r.id}` + const filterGw = gatewayFromRecursiveDst(r.dstAddress) + const { ip: viaIp } = splitRecursiveGateway(r.gateway) + const sel = + selectedTunnelId === ref || + (!!selectedDstNorm && normalizeRecursiveDstAddress(r.dstAddress) === selectedDstNorm) + return ( + + ) + })} +
+ ) +} + // ── community autocomplete ───────────────────────────────────────────────────── -function CommunityInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { +function CommunityInput({ + value, + onChange, + suggestions, + nameMap, +}: { + value: string + onChange: (v: string) => void + suggestions: string[] + nameMap: Record +}) { const [open, setOpen] = useState(false) - const filtered = COMMUNITY_SUGGESTIONS.filter(c => + const filtered = suggestions.filter(c => c.toLowerCase().includes(value.toLowerCase()) || - (COMMUNITY_NAMES[c] ?? "").toLowerCase().includes(value.toLowerCase()) + (nameMap[c] ?? "").toLowerCase().includes(value.toLowerCase()) ) return (
@@ -214,12 +430,12 @@ function CommunityInput({ value, onChange }: { value: string; onChange: (v: stri onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 150)} placeholder="65001:100" className="font-mono h-9" /> {open && filtered.length > 0 && ( -
+
{filtered.map(c => ( ))}
@@ -232,22 +448,39 @@ function CommunityInput({ value, onChange }: { value: string; onChange: (v: stri function FilterRow({ rule, index, isLast, onEdit, onDelete, onMoveUp, onMoveDown, tunnelsList, serversList, + communityNameMap, + recursiveRoutes, + routerSyncStatus, }: { rule: FilterRule; index: number; isLast: boolean onEdit: () => void; onDelete: () => void; onMoveUp: () => void; onMoveDown: () => void tunnelsList: GreTunnel[] serversList: Server[] + communityNameMap: Record + recursiveRoutes: RecursiveRouteLite[] + routerSyncStatus?: FilterRouterSyncStatus | null | "skip" }) { const [confirmDel, setConfirmDel] = useState(false) const isBlackhole = rule.action === "blackhole" - const tunnel = !isBlackhole ? tunnelsList.find(t => t.id === rule.gatewayTunnelId) : undefined + const isRecRef = !isBlackhole && isRecursiveGatewayRef(rule.gatewayTunnelId) + const recRowByRef = isRecRef ? recursiveRoutes.find(r => r.id === rule.gatewayTunnelId.slice(4)) : undefined + const recRowByHop = + !isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim() + ? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway) + : undefined + const recRow = recRowByRef ?? recRowByHop + const treatAsRecursive = + !isBlackhole && (isRecRef || !!recRowByHop) + const tunnel = !isBlackhole && !treatAsRecursive + ? tunnelsList.find(t => t.id === rule.gatewayTunnelId) + : undefined const remoteSrv = tunnel ? serversList.find(s => s.host === tunnel.remoteAddress) : undefined - const communityName = COMMUNITY_NAMES[rule.community] ?? rule.communityName + const communityName = communityNameMap[rule.community] ?? rule.communityName return (
{/* priority */} @@ -270,6 +503,13 @@ function FilterRow({
+ {/* MikroTik sync marker */} +
+ +
+ {/* community */}
@@ -302,18 +542,45 @@ function FilterRow({
) : ( <> -
+
- {remoteSrv && } - {rule.gateway} + {treatAsRecursive ? ( + <> + + + {recRow ? gatewayFromRecursiveDst(recRow.dstAddress) : rule.gateway} + + + recursive + + + ) : ( + <> + {remoteSrv && } + {rule.gateway} + + )}
- {tunnel && ( + {treatAsRecursive ? ( + recRow ? ( +

{recRow.dstAddress}

+ ) : isRecRef ? ( +

+ рекурсивный маршрут (нет строки в списке — синхронизируйте «Рекурсивные маршруты») +

+ ) : null + ) : tunnel ? (

{tunnel.name}

- )} + ) : null} )}
@@ -349,20 +616,35 @@ interface RuleForm { community: string gateway: string gatewayTunnelId: string + /** Только UI: GRE-туннель или рекурсивный маршрут (хранится как rec:id в gatewayTunnelId) */ + gatewayKind: "gre" | "recursive" description: string } const emptyForm = (): RuleForm => ({ - action: "route", community: "", gateway: "", gatewayTunnelId: "", description: "", + action: "route", + community: "", + gateway: "", + gatewayTunnelId: "", + gatewayKind: "gre", + description: "", }) function RuleSheet({ open, mode, initial, serverId, onSave, onClose, serversList, tunnelsList, + communityNameMap, + communitySuggestions, + evoHint, + recursiveRoutes, }: { open: boolean; mode: "create" | "edit" initial: RuleForm; serverId: string onSave: (f: RuleForm) => void; onClose: () => void serversList: Server[] tunnelsList: GreTunnel[] + communityNameMap: Record + communitySuggestions: string[] + evoHint?: string | null + recursiveRoutes: RecursiveRouteLite[] }) { const [form, setForm] = useState(initial) const [error, setError] = useState(null) @@ -385,16 +667,20 @@ function RuleSheet({ } const validate_ = (f: RuleForm) => { - if (!f.community.trim()) { setError("BGP Community обязательно"); return false } - if (f.action === "route" && !f.gateway.trim()) { setError("Выберите GRE-шлюз"); return false } + if (!f.community.trim()) { + setError("BGP Community обязательно"); return false + } + if (f.action === "route" && !f.gatewayTunnelId.trim()) { + setError(f.gatewayKind === "recursive" ? "Выберите рекурсивный маршрут" : "Выберите GRE-шлюз") + return false + } setError(null); return true } const handleSave = () => { setTouched(true) if (!validate_(form)) return - // Clear gateway fields when saving blackhole rule onSave(form.action === "blackhole" - ? { ...form, gateway: "", gatewayTunnelId: "" } + ? { ...form, gateway: "", gatewayTunnelId: "", gatewayKind: "gre" } : form ) } @@ -412,7 +698,13 @@ function RuleSheet({ {mode === "create" ? "Новое правило" : "Редактировать правило"} - Community → {isBlackhole ? "Blackhole" : "GRE-шлюз"} · {serversList.find(s => s.id === serverId)?.name} + Community →{" "} + {isBlackhole + ? "Blackhole" + : form.gatewayKind === "recursive" + ? "рекурсивный маршрут" + : "GRE-шлюз"}{" "} + · {serversList.find(s => s.id === serverId)?.name}
@@ -479,7 +781,7 @@ function RuleSheet({
{a === "route" - ? "Перенаправить на GRE-шлюз" + ? "Next-hop через GRE или рекурсивный маршрут" : "set type blackhole в таблице маршрутов"} @@ -487,21 +789,76 @@ function RuleSheet({
- {/* ── GRE gateway (only for route action) ── */} + {/* ── GRE / recursive gateway (only for route action) ── */} {!isBlackhole && ( <>
- { set("gateway", gw); set("gatewayTunnelId", tid) }} /> - {touched && !form.gateway && ( + { + const k = v as "gre" | "recursive" + setForm(f => { + const next = { ...f, gatewayKind: k, gateway: "", gatewayTunnelId: "" } + if (touched) validate_(next) + return next + }) + }} + className="flex flex-col gap-3" + > + + + + GRE-туннель + + + + Рекурсивный + + + + { + setForm(f => { + const next = { ...f, gatewayKind: "gre" as const, gateway: gw, gatewayTunnelId: tid } + if (touched) validate_(next) + return next + }) + }} + /> + + + { + setForm(f => { + const next = { + ...f, + gatewayKind: "recursive" as const, + gateway: gatewayFromRecursiveDst(r.dstAddress), + gatewayTunnelId: `rec:${r.id}`, + } + if (touched) validate_(next) + return next + }) + }} + /> + + + {touched && form.action === "route" && !form.gatewayTunnelId.trim() && (

- Выберите шлюз + + {form.gatewayKind === "recursive" + ? "Выберите рекурсивный маршрут" + : "Выберите GRE-шлюз"}

)}
@@ -560,13 +917,14 @@ function RuleSheet({ // ── config preview modal ─────────────────────────────────────────────────────── -function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsList }: { +function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsList, recRoutesByServer }: { open: boolean serverId: string rulesets: ServerFilterRuleset[] onClose: () => void serversList: Server[] tunnelsList: GreTunnel[] + recRoutesByServer: Record }) { const [copied, setCopied] = useState(false) @@ -578,8 +936,11 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0) const config = useMemo( - () => open ? generateCombinedRouterOSConfig(singleRuleset, serversList, tunnelsList) : "", - [open, singleRuleset, serversList, tunnelsList], + () => + open + ? generateCombinedRouterOSConfig(singleRuleset, serversList, tunnelsList, recRoutesByServer) + : "", + [open, singleRuleset, serversList, tunnelsList, recRoutesByServer], ) const handleCopy = () => { @@ -691,33 +1052,103 @@ function mapTunnel( return { tunnelId: dst.id, gateway: innerIpToGateway(dst.remoteInnerIp) } } +/** Лучший кандидат на целевом сервере: тот же dst-address и routing-table (несколько via — меньший distance) */ +function pickRecursiveRouteForCopy( + src: RecursiveRouteLite, + targetRoutes: RecursiveRouteLite[], +): RecursiveRouteLite | undefined { + const candidates = targetRoutes.filter( + r => + r.dstAddress === src.dstAddress && + r.routingTable === src.routingTable && + !r.disabled, + ) + if (candidates.length === 0) return undefined + return candidates.reduce((a, b) => (a.distance <= b.distance ? a : b)) +} + +/** Сопоставить рекурсивный маршрут на целевом сервере по dst + table; gateway для фильтра — из dst-address */ +function mapRecursiveGatewayForCopy( + gatewayTunnelId: string, + sourceRoutes: RecursiveRouteLite[], + targetRoutes: RecursiveRouteLite[], +): { gateway: string; gatewayTunnelId: string } { + const sid = gatewayTunnelId.slice(4) + const src = sourceRoutes.find(r => r.id === sid) + if (!src) return { gateway: "", gatewayTunnelId: "" } + const tgt = pickRecursiveRouteForCopy(src, targetRoutes) + if (!tgt) { + return { gateway: gatewayFromRecursiveDst(src.dstAddress), gatewayTunnelId: "" } + } + return { + gateway: gatewayFromRecursiveDst(tgt.dstAddress), + gatewayTunnelId: `rec:${tgt.id}`, + } +} + function CopyRulesSheet({ open, onClose, sourceServerId, sourceRules, - rulesets, onCopy, serversList, tunnelsList, + rulesets, onCopy, serversList, + greByServer, + ensureGreFor, + recRoutesByServer, + ensureRecursiveFor, }: { open: boolean; onClose: () => void sourceServerId: string; sourceRules: FilterRule[] rulesets: ServerFilterRuleset[] onCopy: (targetServerId: string, rules: FilterRule[], mode: CopyMode) => void serversList: Server[] - tunnelsList: GreTunnel[] + greByServer: Record + ensureGreFor?: (serverId: string) => void + recRoutesByServer: Record + ensureRecursiveFor?: (serverId: string) => void }) { const [selected, setSelected] = useState>(new Set()) const [targetId, setTargetId] = useState(null) const [mode, setMode] = useState("append") const [done, setDone] = useState(false) + const sourceRecursiveRoutes = recRoutesByServer[sourceServerId] ?? [] + const targetRecursiveRoutes = targetId ? (recRoutesByServer[targetId] ?? []) : [] + // Reset each time the sheet opens - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - if (!open) { setDone(false); return } - // eslint-disable-next-line react-hooks/set-state-in-effect - setSelected(new Set(sourceRules.map(r => r.id))) - // eslint-disable-next-line react-hooks/set-state-in-effect - setTargetId(null); setMode("append"); setDone(false) - }, [open]) + queueMicrotask(() => { + if (!open) { setDone(false); return } + setSelected(new Set(sourceRules.map(r => r.id))) + setTargetId(null); setMode("append"); setDone(false) + }) + }, [open, sourceRules]) + + useEffect(() => { + if (!open || !ensureGreFor) return + ensureGreFor(sourceServerId) + }, [open, sourceServerId, ensureGreFor]) + + useEffect(() => { + if (!open || !targetId || !ensureGreFor) return + ensureGreFor(targetId) + }, [open, targetId, ensureGreFor]) + + useEffect(() => { + if (!open || !ensureRecursiveFor) return + ensureRecursiveFor(sourceServerId) + }, [open, sourceServerId, ensureRecursiveFor]) + + useEffect(() => { + if (!open || !targetId || !ensureRecursiveFor) return + ensureRecursiveFor(targetId) + }, [open, targetId, ensureRecursiveFor]) + + const tunnelsList = useMemo( + () => [ + ...(greByServer[sourceServerId] ?? []), + ...(targetId ? (greByServer[targetId] ?? []) : []), + ], + [greByServer, sourceServerId, targetId], + ) const sourceServer = serversList.find(s => s.id === sourceServerId)! const targetServers = serversList.filter(s => s.id !== sourceServerId) @@ -731,7 +1162,11 @@ function CopyRulesSheet({ const mapping = useMemo(() => { if (!targetId) return [] return selectedRules - .filter(r => (r.action ?? "route") === "route" && r.gatewayTunnelId) + .filter(r => + (r.action ?? "route") === "route" && + r.gatewayTunnelId && + !isRecursiveGatewayRef(r.gatewayTunnelId), + ) .map(r => { const srcTunnel = tunnelsList.find(t => t.id === r.gatewayTunnelId) const dstMapping = mapTunnel(r.gatewayTunnelId, targetId, tunnelsList) @@ -742,7 +1177,7 @@ function CopyRulesSheet({ .filter((m, i, arr) => arr.findIndex(x => x.srcTunnel?.id === m.srcTunnel?.id) === i ) - }, [selectedRules, targetId]) + }, [selectedRules, targetId, tunnelsList]) const unmappedCount = mapping.filter(m => !m.mapped).length @@ -767,6 +1202,15 @@ function CopyRulesSheet({ if ((r.action ?? "route") === "blackhole") { return { ...r, id: newId() } } + if (isRecursiveGatewayRef(r.gatewayTunnelId)) { + const m = mapRecursiveGatewayForCopy(r.gatewayTunnelId, sourceRecursiveRoutes, targetRecursiveRoutes) + return { + ...r, + id: newId(), + gateway: m.gateway, + gatewayTunnelId: m.gatewayTunnelId, + } + } const m = mapTunnel(r.gatewayTunnelId, targetId, tunnelsList) return { ...r, @@ -822,7 +1266,12 @@ function CopyRulesSheet({
{sourceRules.map(r => { const isBlackhole = r.action === "blackhole" - const tunnel = !isBlackhole ? tunnelsList.find(t => t.id === r.gatewayTunnelId) : undefined + const tunnel = !isBlackhole && !isRecursiveGatewayRef(r.gatewayTunnelId) + ? tunnelsList.find(t => t.id === r.gatewayTunnelId) + : undefined + const rec = !isBlackhole && isRecursiveGatewayRef(r.gatewayTunnelId) + ? sourceRecursiveRoutes.find(x => x.id === r.gatewayTunnelId.slice(4)) + : undefined const isSelected = selected.has(r.id) return ( + )} @@ -1458,11 +2048,19 @@ export default function FiltersPage() { {/* table header with sort */}
# + + + MT + + + Совпадение с MikroTik (bgp-in): нажмите «Сверить с роутером» + +
) diff --git a/app/(main)/firewall/page.tsx b/app/(main)/firewall/page.tsx index 9d1423f..64ad946 100644 --- a/app/(main)/firewall/page.tsx +++ b/app/(main)/firewall/page.tsx @@ -404,9 +404,8 @@ function NativeSelect({ value, onChange, children, className }: { return ( 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"> - {servers.map((s) => )} + {displayServers.map((s) => )} @@ -692,9 +884,9 @@ export default function GrePage() { Внутренний IP
@@ -736,20 +928,20 @@ export default function GrePage() {
@@ -788,7 +980,7 @@ export default function GrePage() {
diff --git a/app/(main)/ip-ranges/page.tsx b/app/(main)/ip-ranges/page.tsx index d3adc77..84dbfd4 100644 --- a/app/(main)/ip-ranges/page.tsx +++ b/app/(main)/ip-ranges/page.tsx @@ -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 (
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам

+ {useEvoCatalog && ( +

+ {loading && } + {error + ? `EvoBGP: ${error}` + : snapshot?.fetchedAt + ? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}` + : loading ? "Загрузка EvoBGP…" : "EvoBGP"} +

+ )}
- - - {children} - - + + + + {children} + + + ) } diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index 9f7e856..b3e9d01 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -1,28 +1,54 @@ "use client" -import { useState, useRef, useEffect } from "react" +import { useState, useRef, useEffect, useMemo, useCallback } from "react" import { PageHeader } from "@/components/page-header" -import { servers, greTunnels, type Server, type ServerType } from "@/lib/data" +import { + servers as mockServers, + greTunnels as mockGreTunnels, + type GreTunnel, + type Server, + type ServerStatus, + type ServerType, +} from "@/lib/data" +import { useDataSource } from "@/lib/data-source" +import { + buildGreMapEdges, + buildServerResourceMap, + buildWanJhEdges, + computeNetworkMapLayout, + NETWORK_MAP_LAYOUT_REVISION, + NETWORK_MAP_PIPELINE_Y, + findServerByGreRemote, + greSourceWanIndexOnMap, + greTunnelProbe, + type GreMapEdge, + type WanJhEdge, +} from "@/lib/network-map-layout" +import { + collectGreEndpointHostnames, + greResolvedMapFromApi, +} from "@/lib/gre-endpoint-resolve" +import { + assignSpeedProbesToGreTunnels, + greOuterSummaryLine, + ifaceNameForGreOuterIp, + mergeGreMetricsWithSpeedProbe, + type GreSpeedProbeSnapshot, +} from "@/lib/map-gre-speed-probe" import { Button } from "@/components/ui/button" import { StatusBadge } from "@/components/status-badge" +import { StatusDot } from "@/components/status-dot" import { - DownloadIcon, XIcon, LockIcon, LockOpenIcon, WifiIcon, + DownloadIcon, XIcon, LockIcon, LockOpenIcon, WifiIcon, RefreshCwIcon, ZoomInIcon, ZoomOutIcon, Maximize2Icon, SearchIcon, LayersIcon, MapIcon, HomeIcon, TerminalIcon, ShieldIcon, CableIcon, CopyIcon, ActivityIcon, ExternalLinkIcon, } from "lucide-react" import { cn } from "@/lib/utils" +import Link from "next/link" +import { Flag } from "@/components/flag" -// ─── Resource metrics (mirrors uptime/page.tsx INIT_RESOURCES formula) ─────── - -interface SrvRes { - cpu: number - cpuHistory: number[] - ramUsed: number; ramTotal: number - hddUsed: number; hddTotal: number - uptimeSeconds: number - temp?: number -} +// ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─ const BOARD_MAP: Record = { "jump-host": "RB5009UG+S+IN", @@ -30,26 +56,132 @@ const BOARD_MAP: Record = { "home-router": "hAP ax²", } -const SERVER_RESOURCES: Record = Object.fromEntries( - servers.map(s => { - const h = s.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0) - const cpu = 4 + (h % 68) - const ramTotal = s.type === "jump-host" ? 8192 : s.type === "exit-node" ? 4096 : 1024 - const hddTotal = s.type === "home-router" ? 2048 : 16384 - const ramPct = 12 + (h % 72) - const hddPct = 8 + (h % 75) - 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 * ramPct / 100), ramTotal, - hddUsed: Math.round(hddTotal * hddPct / 100), hddTotal, - uptimeSeconds: (1 + h % 200) * 86400 + (h % 24) * 3600 + (h % 60) * 60, - temp: s.type !== "home-router" ? 34 + (h % 32) : undefined, - } satisfies SrvRes] - }) -) +// ─── Backend → frontend (как /servers) ─────────────────────────────────────── + +interface BackendServerRow { + id: number + name: string + host: string + port: number + useSsl: boolean + verifySsl: boolean + username: string + password: string + type: ServerType + site: string + country: string + asn: string + comment: string + enabled: boolean + lanSubnet?: string + wanUplinks?: Array<{ + id: string + name: string + isp: string + iface: string + ip: string + maxDl: number + maxUl: number + }> + status: "online" | "offline" | null + latency: number | null + os: string | null + model: string | null + sessions: number + /** Снапшот последнего poll (как в разделе «Серверы») */ + uptime?: string | null + cpuLoad?: number | null + freeMemory?: number | null + totalMemory?: number | null + polledAt?: string | null + createdAt: string + updatedAt: string +} + +function mapBackendRowToServer(s: BackendServerRow): Server { + return { + id: String(s.id), + name: s.name || s.host, + host: s.host, + type: s.type, + site: s.site, + country: s.country, + asn: s.asn, + model: s.model ?? "—", + os: s.os ?? "—", + enabled: s.enabled, + status: (s.status === null ? "offline" : s.status) as Server["status"], + 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, + uptime: s.uptime ?? undefined, + cpuLoad: s.cpuLoad ?? undefined, + freeMemory: s.freeMemory ?? undefined, + totalMemory: s.totalMemory ?? undefined, + polledAt: s.polledAt ?? undefined, + } +} + +interface ApiGreTunnelRow { + id: string + name: string + serverId: string + localAddress: string + remoteAddress: string + localInnerIp: string + remoteInnerIp: string + poolId: string + ipsec: null + mtu: number + keepaliveInterval: number + keepaliveRetries: number + dscp: "inherit" | number + clampTcpMss: boolean + allowFastPath: boolean + comment: string + enabled: boolean + status: "up" | "down" | "degraded" +} + +function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel { + return { + id: t.id, + name: t.name, + serverId: String(t.serverId), + localAddress: t.localAddress, + remoteAddress: t.remoteAddress, + localInnerIp: t.localInnerIp, + remoteInnerIp: t.remoteInnerIp, + poolId: t.poolId || "live", + ipsec: null, + mtu: t.mtu, + keepaliveInterval: t.keepaliveInterval, + keepaliveRetries: t.keepaliveRetries, + dscp: t.dscp, + clampTcpMss: t.clampTcpMss, + allowFastPath: t.allowFastPath, + comment: t.comment, + enabled: t.enabled, + status: t.status, + } +} + +function makeApiFetch(backendUrl: string) { + return async function apiFetch(path: string, init?: RequestInit): Promise { + const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} + const res = await fetch(backendUrl.replace(/\/$/, "") + path, { + ...init, + headers: { ...headers, ...(init?.headers ?? {}) }, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) as { error?: string } + throw new Error(err.error ?? res.statusText) + } + return res.json() as Promise + } +} function fmtMB(mb: number): string { if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ` @@ -130,54 +262,6 @@ const TUNNEL_STYLE = { down: { stroke: "#f87171", opacity: 0.15, dotOpacity: 0 }, } -// ─── Positions ──────────────────────────────────────────────────────────────── - -const POS: Record = { - srv1: { x: 500, y: 170 }, - srv2: { x: 130, y: 75 }, - srv3: { x: 820, y: 75 }, - srv4: { x: 910, y: 265 }, - srv5: { x: 700, y: 385 }, - srv6: { x: 310, y: 345 }, - srv7: { x: 125, y: 255 }, - home1: { x: 270, y: 470 }, - home2: { x: 790, y: 470 }, -} - -const WAN_SAT_POS: Record = { - home1: [{ x: 155, y: 510 }, { x: 385, y: 510 }], - home2: [{ x: 665, y: 510 }, { x: 915, y: 510 }], -} - -// ─── WAN→JH edges ──────────────────────────────────────────────────────────── - -interface WanJhEdge { - homeId: string; wanIdx: number; jhId: string - pingMs: number; dlMbps: number; active: boolean -} - -const WAN_JH_EDGES: WanJhEdge[] = [ - { homeId: "home1", wanIdx: 0, jhId: "srv1", pingMs: 4, dlMbps: 480, active: true }, - { homeId: "home1", wanIdx: 0, jhId: "srv7", pingMs: 6, dlMbps: 480, active: true }, - { homeId: "home1", wanIdx: 1, jhId: "srv1", pingMs: 12, dlMbps: 95, active: false }, - { homeId: "home2", wanIdx: 0, jhId: "srv1", pingMs: 18, dlMbps: 280, active: true }, - { homeId: "home2", wanIdx: 0, jhId: "srv7", pingMs: 20, dlMbps: 278, active: false }, - { homeId: "home2", wanIdx: 1, jhId: "srv1", pingMs: 22, dlMbps: 185, active: false }, -] - -// ─── Tunnel probes ──────────────────────────────────────────────────────────── - -interface TunnelProbe { pingMs: number | null; dlMbps: number | null; ulMbps: number | null } - -const TUNNEL_PROBES: Record = { - gre1: { pingMs: 28, dlMbps: 614, ulMbps: 421 }, - gre2: { pingMs: 41, dlMbps: 488, ulMbps: 352 }, - gre3: { pingMs: 88, dlMbps: 143, ulMbps: 104 }, - gre4: { pingMs: null, dlMbps: null, ulMbps: null }, - gre5: { pingMs: 14, dlMbps: 578, ulMbps: 412 }, - gre6: { pingMs: 92, dlMbps: 155, ulMbps: 112 }, -} - const TYPE_LABELS: Record = { "jump-host": "JumpHost", "exit-node": "Exit Node", @@ -201,12 +285,42 @@ function shortName(name: string) { return name.replace(/^(home|mt)-/, "").split("-").slice(0, 3).join("-") } +function greStatusToServerStatus(s: "up" | "down" | "degraded"): ServerStatus { + if (s === "up") return "online" + if (s === "degraded") return "degraded" + return "offline" +} + +/** + * Позиция бейджа на отрезке: не только середина, а доля t + сдвиг по нормали. + * GRE и PingBadge (WAN→JH) используют разные t и знак нормали — меньше наложений. + */ +function edgeBadgePosition( + x1: number, + y1: number, + x2: number, + y2: number, + t: number, + normalPx: number, +): { mx: number; my: number } { + const tc = Math.min(0.82, Math.max(0.18, t)) + const px = x1 + (x2 - x1) * tc + const py = y1 + (y2 - y1) * tc + const dx = x2 - x1 + const dy = y2 - y1 + const len = Math.hypot(dx, dy) || 1 + const nx = -dy / len + const ny = dx / len + return { mx: px + nx * normalPx, my: py + ny * normalPx } +} + // ─── Filter ─────────────────────────────────────────────────────────────────── type FilterKey = "all" | "online" | "degraded" | "offline" | "jump-host" | "exit-node" | "home-router" // ─── SVG sub-components ─────────────────────────────────────────────────────── +/** Бейдж на ребре WAN → JumpHost (не GRE): сумма latency домашнего узла и JH — те же поля, что в разделе Серверы. */ function PingBadge({ mx, my, ping, dl, ul, color }: { mx: number; my: number ping: number | null; dl?: number | null; ul?: number | null @@ -216,11 +330,14 @@ function PingBadge({ mx, my, ping, dl, ul, color }: { const h = hasSpeed ? 36 : 20 return ( + + WAN → JumpHost: latency (из каталога) дом + latency JH, без отдельного ping по ребру. Не RTT по GRE. + - {ping == null ? "timeout" : `${ping} мс`} + {ping == null ? "—" : `${ping} мс`} {hasSpeed && ( @@ -231,6 +348,78 @@ function PingBadge({ mx, my, ping, dl, ul, color }: { ) } +/** Метрики на GRE-ребре: компактно; подробности — в tooltip (`title`). */ +function GreEdgeMetricBadge({ + mx, + my, + pingMs, + dl, + ul, + onOpen, + rttFromMonitor, + throughputFromMonitor, + outerSummary, +}: { + mx: number + my: number + pingMs: number | null + dl: number | null + ul: number | null + onOpen: (e: React.MouseEvent) => void + /** Данные из Мониторинг → скорость (uptime speed-probes) */ + rttFromMonitor?: boolean + throughputFromMonitor?: boolean + /** Внешние IP GRE и WAN — только в подсказке при наведении */ + outerSummary?: string +}) { + const hasSpeed = + dl != null && + ul != null && + (throughputFromMonitor || dl > 0 || ul > 0) + const bw = 62 + const bh = 24 + (hasSpeed ? 14 : 0) + const v1 = pingMs == null ? "—" : `${pingMs} мс` + const hint = + (outerSummary ? `${outerSummary}. ` : "") + + (rttFromMonitor + ? "RTT — ping/BT из «Мониторинг → скорость». " + : "RTT — модель по данным туннеля. ") + + (throughputFromMonitor ? "Строка ниже — TX/RX с пробы. " : "") + + "Клик — панель справа." + return ( + { e.stopPropagation() }} + onClick={(e) => { e.stopPropagation(); onOpen(e) }} + > + {hint} + + + {v1} + + {hasSpeed && ( + + {`↓${dl} ↑${ul}`} + + )} + + ) +} + function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) { const ss = STATUS_STYLE[n.status] const ts = TYPE_STYLE[n.type] @@ -263,9 +452,11 @@ function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) { ) } -function ServerNode({ n, isSel, isVis, isDragged, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: { +function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: { n: Server & { x: number; y: number } isSel: boolean; isVis: boolean; isDragged: boolean + /** Не дублировать «N мс» из каталога, если на карте уже показаны метрики GRE (оранжевые числа в круге). */ + hideCatalogLatency?: boolean onClick: () => void onDblClick: () => void onHoverChange: (hov: boolean) => void @@ -274,7 +465,6 @@ function ServerNode({ n, isSel, isVis, isDragged, onClick, onDblClick, onHoverCh }) { const ss = STATUS_STYLE[n.status] const ts = TYPE_STYLE[n.type] - const lc = latencyColor(n.latency) return ( {ts.label} - 32 ? "13" : "11"} fontWeight="700" - fill="#f1f5f9" fontFamily="ui-monospace,monospace">{n.site} - - {n.latency == null ? "офлайн" : `${n.latency} мс`} - - {shortName(n.name)} + {n.country?.trim() ? ( + + {/* Внутри foreignObject Tailwind не всегда применяется к тексту — явные стили, без lineHeight:0 (иначе подпись пропадает). */} +
)} + > + + 32 ? 18 : 16} /> + + 32 ? 13 : 11, + color: "#f1f5f9", + lineHeight: 1.25, + }} + > + {n.site} + +
+
+ ) : ( + 32 ? "13" : "11"} fontWeight="700" + fill="#f1f5f9" fontFamily="ui-monospace,monospace">{n.site} + )} + {!hideCatalogLatency && ( + + {n.latency == null ? "офлайн" : `${n.latency} мс`} + + )} + {n.type === "home-router" && ( + {shortName(n.name)} + )} ) } @@ -431,11 +667,13 @@ function ContextMenu({ menu, onClose }: { menu: CtxMenu; onClose: () => void }) const MM_W = 172, MM_H = 94 -function Minimap({ pan, zoom, nodes, greEdges, satPos, onClose, onPan }: { +function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, onClose, onPan }: { pan: { x: number; y: number }; zoom: number nodes: (Server & { x: number; y: number })[] - greEdges: { tunnel: typeof greTunnels[0]; from: typeof nodes[0]; to: typeof nodes[0] }[] + greEdges: GreMapEdge[] satPos: Record + wanJhEdges: WanJhEdge[] + homeRouters: Server[] onClose: () => void onPan: (x: number, y: number) => void }) { @@ -458,12 +696,12 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, onClose, onPan }: { style={{ display: "block", cursor: "crosshair" }} onClick={handleClick}> {/* simplified edges */} - {greEdges.map(e => ( + {greEdges.map((e) => ( ))} {/* WAN edges */} - {WAN_JH_EDGES.map((edge, i) => { + {wanJhEdges.map((edge, i) => { const sat = satPos[edge.homeId]?.[edge.wanIdx] const jh = nodes.find(n => n.id === edge.jhId) if (!sat || !jh) return null @@ -481,7 +719,7 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, onClose, onPan }: { /> ))} {/* WAN satellites */} - {servers.filter(s => s.type === "home-router").flatMap(r => + {homeRouters.flatMap(r => (satPos[r.id] ?? []).map((p, i) => ( @@ -538,6 +776,109 @@ function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: { // ════════════════════════════════════════════════════════════════════════════ export default function NetworkMapPage() { + const { mode, backendUrl, backendStatus } = useDataSource() + const useLiveData = mode === "live" && backendStatus === true + const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) + + const [mapServers, setMapServers] = useState(mockServers) + const [mapGreTunnels, setMapGreTunnels] = useState(mockGreTunnels) + const [speedProbes, setSpeedProbes] = useState([]) + /** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */ + const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState>({}) + const [dataError, setDataError] = useState(null) + const [dataLoading, setDataLoading] = useState(false) + + const greResolvedMap = useMemo( + () => new Map(Object.entries(greResolvedIpv4ByHost)) as ReadonlyMap, + [greResolvedIpv4ByHost], + ) + + const loadLive = useCallback(async () => { + if (!useLiveData) return + setDataLoading(true) + setDataError(null) + try { + const [rows, fr, spRes] = await Promise.all([ + apiFetch("/api/servers"), + apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels").catch(() => ({ tunnels: [] })), + apiFetch<{ + probes?: Array<{ + id: string + srcServerId: string + dstServerId: string + enabled?: boolean + srcInterface?: string + dstInterface?: string + lastTxAvgMbps?: number | null + lastRxAvgMbps?: number | null + lastPingRttMs?: number | null + }> + }>("/api/uptime/speed-probes").catch(() => ({ probes: [] })), + ]) + setMapServers(rows.map(mapBackendRowToServer)) + const mappedTunnels = (fr.tunnels ?? []).map(apiGreToGreTunnel) + setMapGreTunnels(mappedTunnels) + setSpeedProbes( + (spRes.probes ?? []).map((p) => ({ + id: p.id, + srcServerId: String(p.srcServerId), + dstServerId: String(p.dstServerId), + enabled: p.enabled !== false, + srcInterface: p.srcInterface || undefined, + dstInterface: p.dstInterface || undefined, + lastTxAvgMbps: p.lastTxAvgMbps, + lastRxAvgMbps: p.lastRxAvgMbps, + lastPingRttMs: p.lastPingRttMs, + })), + ) + const hostnames = collectGreEndpointHostnames(mappedTunnels) + if (hostnames.length > 0) { + try { + const res = await apiFetch<{ results: Record }>( + "/api/network/resolve-hosts", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hosts: hostnames }), + }, + ) + setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(res.results))) + } catch { + setGreResolvedIpv4ByHost({}) + } + } else { + setGreResolvedIpv4ByHost({}) + } + } catch (e) { + setDataError(e instanceof Error ? e.message : "Ошибка загрузки") + } finally { + setDataLoading(false) + } + }, [apiFetch, useLiveData]) + + useEffect(() => { + if (!useLiveData) { + queueMicrotask(() => { + setMapServers(mockServers) + setMapGreTunnels(mockGreTunnels) + setSpeedProbes([]) + setGreResolvedIpv4ByHost({}) + setDataError(null) + }) + return + } + queueMicrotask(() => { + void loadLive() + }) + }, [useLiveData, loadLive]) + + const autoLayout = useMemo(() => computeNetworkMapLayout(mapServers), [mapServers]) + const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers), [mapServers]) + const srvResMap = useMemo(() => buildServerResourceMap(mapServers), [mapServers]) + const homeRouters = useMemo( + () => mapServers.filter((s) => s.type === "home-router"), + [mapServers], + ) // ── View state ────────────────────────────────────────────────────────────── const [pan, setPan] = useState({ x: 0, y: 0 }) @@ -553,9 +894,24 @@ export default function NetworkMapPage() { const [nodePositions, setNodePositions] = useState>({}) const [satPositions, setSatPositions] = useState>({}) + /** После обновления алгоритма раскладки (см. NETWORK_MAP_LAYOUT_REVISION) сбрасываем drag, иначе старые координаты «перебивают» computeNetworkMapLayout. */ + useEffect(() => { + if (typeof window === "undefined") return + try { + const k = "mm-network-map-layout-rev" + if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) { + sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION)) + } + } catch { + /* private mode / disabled storage */ + } + }, []) + // ── Context menu ──────────────────────────────────────────────────────────── const [ctxMenu, setCtxMenu] = useState(null) + const [selectedGreEdge, setSelectedGreEdge] = useState(null) + // ── Toggles ───────────────────────────────────────────────────────────────── const [filter, setFilter] = useState("all") const [search, setSearch] = useState("") @@ -565,6 +921,97 @@ export default function NetworkMapPage() { const [showHints, setShowHints] = useState(false) const [showLayers, setShowLayers] = useState(false) + const effectiveSatPos = useMemo(() => { + const out: Record = {} + mapServers + .filter((s) => s.type === "home-router") + .forEach((r) => { + const defaults = autoLayout.wanSatPos[r.id] ?? [] + const user = satPositions[r.id] + if (user && user.length === defaults.length) { + out[r.id] = defaults.map((def, i) => user[i] ?? def) + } else { + out[r.id] = defaults + } + }) + return out + }, [mapServers, autoLayout.wanSatPos, satPositions]) + + const nodePosById = useMemo( + () => + Object.fromEntries( + mapServers.map((s) => { + const pos = + nodePositions[s.id] ?? + autoLayout.nodePos[s.id] ?? + { x: W / 2, y: NETWORK_MAP_PIPELINE_Y } + return [s.id, pos] as const + }), + ), + [mapServers, nodePositions, autoLayout.nodePos], + ) + + const greEdges = useMemo( + () => + buildGreMapEdges(mapGreTunnels, mapServers, nodePosById, effectiveSatPos, greResolvedMap), + [mapGreTunnels, mapServers, nodePosById, effectiveSatPos, greResolvedMap], + ) + + /** WAN→JH: не показывать PingBadge, если на том же дом+WAN+JH уже есть GRE с метрик-бейджем. */ + const suppressWanJhPingBadge = useMemo(() => { + const s = new Set() + for (const g of greEdges) { + if (g.fromServer.type !== "home-router") continue + if (g.toServer.type !== "jump-host") continue + const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap) + if (widx == null) continue + s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`) + } + return s + }, [greEdges, greResolvedMap]) + + /** Концы GRE: не дублировать «N мс» из каталога внутри круга, если показаны бейджи по туннелям. */ + const hideCatalogLatencyByNodeId = useMemo(() => { + if (!showPingBadges) return new Set() + const s = new Set() + for (const e of greEdges) { + s.add(e.fromServer.id) + s.add(e.toServer.id) + } + return s + }, [greEdges, showPingBadges]) + + /** Одна speed-проба не может быть назначена двум GRE между одной парой узлов (RT vs MTS и т.д.). */ + const speedProbeByTunnelId = useMemo( + () => + assignSpeedProbesToGreTunnels( + greEdges.map((e) => ({ + tunnel: e.tunnel, + fromServer: e.fromServer, + toServer: e.toServer, + })), + speedProbes, + greResolvedMap, + ), + [greEdges, speedProbes, greResolvedMap], + ) + + /** Несколько GRE между одной парой узлов — смещаем бейджи по дуге и по нормали, без наложения. */ + const greBadgeStaggerByTunnelId = useMemo(() => { + const pairOrder = new Map() + const out = new Map() + for (const ge of greEdges) { + const k = `${ge.fromServer.id}:${ge.toServer.id}` + const lane = pairOrder.get(k) ?? 0 + out.set(ge.tunnel.id, lane) + pairOrder.set(k, lane + 1) + } + return out + }, [greEdges]) + + const nodes = mapServers.map((s) => ({ ...s, ...nodePosById[s.id]! })) + const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n])) + // ── Refs ───────────────────────────────────────────────────────────────────── const svgRef = useRef(null) @@ -639,7 +1086,7 @@ export default function NetworkMapPage() { useEffect(() => { function onKey(e: KeyboardEvent) { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return - if (e.key === "Escape") { setSelected(null); setSelWanIdx(null) } + if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) } if (e.key === "=" || e.key === "+") applyZoomCenter(1.25) if (e.key === "-") applyZoomCenter(1 / 1.25) if (e.key === "0" || e.key.toLowerCase() === "f") fitView() @@ -648,7 +1095,7 @@ export default function NetworkMapPage() { } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) - }, [zoom, pan]) // re-register when zoom/pan change so closure has fresh values + }, [zoom, pan]) // eslint-disable-line react-hooks/exhaustive-deps -- applyZoomCenter/fitView ниже; handler обновляется через [zoom, pan] // ── Zoom helpers ────────────────────────────────────────────────────────── function applyZoomCenter(factor: number) { @@ -694,7 +1141,7 @@ export default function NetworkMapPage() { setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } })) } else { setSatPositions(prev => { - const arr = [...(prev[nd.homeId] ?? (WAN_SAT_POS[nd.homeId] ?? []))] + const arr = [...(prev[nd.homeId] ?? (autoLayout.wanSatPos[nd.homeId] ?? []))] arr[nd.wanIdx] = { x: nx, y: ny } return { ...prev, [nd.homeId]: arr } }) @@ -727,7 +1174,7 @@ export default function NetworkMapPage() { const moved = dragRef.current?.moved ?? false dragRef.current = null setIsDragging(false) - if (!moved) { setSelected(null); setSelWanIdx(null) } + if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) } } // ── Node drag start ────────────────────────────────────────────────────── @@ -740,28 +1187,6 @@ export default function NetworkMapPage() { setDraggedSatKey(`${homeId}-${wanIdx}`) } - // ── Data ───────────────────────────────────────────────────────────────── - const nodes = servers.map(s => { - const pos = nodePositions[s.id] ?? POS[s.id] ?? { x: 0, y: 0 } - return { ...s, ...pos } - }) - const nodeById = Object.fromEntries(nodes.map(n => [n.id, n])) - - // Satellite positions merging defaults with overrides - const effectiveSatPos: Record = {} - servers.filter(s => s.type === "home-router").forEach(r => { - effectiveSatPos[r.id] = (WAN_SAT_POS[r.id] ?? []).map((def, i) => - satPositions[r.id]?.[i] ?? def - ) - }) - - const greEdges = greTunnels.flatMap(t => { - const from = nodes.find(n => n.id === t.serverId) - const to = nodes.find(n => n.host === t.remoteAddress) - if (!from || !to || from.type === "home-router" || to.type === "home-router") return [] - return [{ tunnel: t, from, to }] - }) - // ── Visibility / search ────────────────────────────────────────────────── const sq = search.toLowerCase().trim() const matchesSearch = (n: typeof nodes[0]) => @@ -780,20 +1205,23 @@ export default function NetworkMapPage() { // ── Side panel ──────────────────────────────────────────────────────────── function selectServer(s: Server) { + setSelectedGreEdge(null) setSelected(prev => prev?.id === s.id ? null : s) setSelWanIdx(null) setHoveredId(null) } function selectWan(s: Server, wanIdx: number) { + setSelectedGreEdge(null) setSelected(s) setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx) } - const connectedTunnels = selected && selected.type !== "home-router" - ? greTunnels.filter(t => - t.serverId === selected.id || - nodes.find(n => n.id === selected.id)?.host === t.remoteAddress - ) + const connectedTunnels = selected + ? mapGreTunnels.filter((t) => { + const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap) + if (!peer) return false + return t.serverId === selected.id || peer.id === selected.id + }) : [] const hoveredNode = hoveredId ? nodes.find(n => n.id === hoveredId) : null @@ -815,9 +1243,17 @@ export default function NetworkMapPage() { - Экспорт SVG - + <> + {useLiveData && ( + + )} + + } /> @@ -913,18 +1349,29 @@ export default function NetworkMapPage() {
{/* Stats */} -
+
+ + {useLiveData ? "Живые данные · API" : "Демо · mock"} + + {dataError && ( + + {dataError} + + )} - {servers.filter(s => s.status === "online").length} онлайн + {mapServers.filter(s => s.status === "online").length} онлайн - {servers.filter(s => s.status === "degraded").length} с проблемами + {mapServers.filter(s => s.status === "degraded").length} с проблемами - {servers.filter(s => s.status === "offline").length} оффлайн + {mapServers.filter(s => s.status === "offline").length} оффлайн - - {greTunnels.length} GRE + + {greEdges.length} + / + {mapGreTunnels.length} + {" "}GRE
@@ -964,11 +1411,37 @@ export default function NetworkMapPage() { {/* ── GRE edges ── */} {greEdges.map((e, i) => { const ts = TUNNEL_STYLE[e.tunnel.status] - const dimmed = filter !== "all" && !isVisible(e.from) && !isVisible(e.to) - const probe = TUNNEL_PROBES[e.tunnel.id] - const pc = pingColor(probe?.pingMs ?? null) - const mx = (e.from.x + e.to.x) / 2 - const my = (e.from.y + e.to.y) / 2 + const fromN = nodeById[e.fromServer.id] + const toN = nodeById[e.toServer.id] + const dimmed = + filter !== "all" && + fromN && toN && + !isVisible(fromN) && + !isVisible(toN) + const baseProbe = greTunnelProbe(e.tunnel) + const spGre = speedProbeByTunnelId.get(e.tunnel.id) + const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe) + const rttFromMonitor = spGre?.lastPingRttMs != null + const throughputFromMonitor = + spGre?.lastTxAvgMbps != null || spGre?.lastRxAvgMbps != null + const badgeLane = greBadgeStaggerByTunnelId.get(e.tunnel.id) ?? 0 + const tBadge = Math.min(0.78, Math.max(0.22, 0.34 + badgeLane * 0.052)) + const normalPx = + (badgeLane % 2 === 0 ? 1 : -1) * (16 + badgeLane * 22) + const { mx, my } = edgeBadgePosition( + e.from.x, + e.from.y, + e.to.x, + e.to.y, + tBadge, + normalPx, + ) + function openGreDetail(ev: React.MouseEvent) { + ev.stopPropagation() + setSelectedGreEdge(e) + setSelected(null) + setSelWanIdx(null) + } return ( + { ev.stopPropagation() }} + onClick={openGreDetail} + > + GRE: подробнее + {showAnimDots && ts.dotOpacity > 0 && ( - + )} - {showPingBadges && probe && ( - + {showPingBadges && ( + )} ) })} {/* ── Home Router → WAN satellite connectors ── */} - {servers.filter(s => s.type === "home-router").flatMap(router => { + {homeRouters.flatMap(router => { const rNode = nodes.find(n => n.id === router.id) if (!rNode) return [] return (router.wanUplinks ?? []).map((wan, wIdx) => { @@ -1009,14 +1503,13 @@ export default function NetworkMapPage() { })} {/* ── WAN→JH edges ── */} - {WAN_JH_EDGES.map((edge, i) => { + {wanJhEdges.map((edge, i) => { const jh = nodeById[edge.jhId] const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx] if (!jh || !satPos) return null const color = WAN_COLORS[edge.wanIdx] ?? "#888" const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online" - const mx = (satPos.x + jh.x) / 2 - const my = (satPos.y + jh.y) / 2 + const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17) const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx) return ( )} - {showPingBadges && ( + {showPingBadges && + !suppressWanJhPingBadge.has(`${edge.homeId}\t${edge.wanIdx}\t${edge.jhId}`) && ( )} @@ -1049,6 +1543,7 @@ export default function NetworkMapPage() { isSel={selected?.id === n.id} isVis={isVisible(n)} isDragged={draggedNodeId === n.id} + hideCatalogLatency={hideCatalogLatencyByNodeId.has(n.id)} onClick={() => { if (suppressClickRef.current) { suppressClickRef.current = false; return } selectServer(n) @@ -1061,13 +1556,13 @@ export default function NetworkMapPage() { ))} {/* ── WAN satellite nodes ── */} - {servers.filter(s => s.type === "home-router").flatMap(router => { + {homeRouters.flatMap(router => { const vis = filter === "all" || filter === "home-router" || filter === "online" return (router.wanUplinks ?? []).map((wan, wIdx) => { const sat = effectiveSatPos[router.id]?.[wIdx] if (!sat) return null const color = WAN_COLORS[wIdx] ?? "#888" - const isActive = WAN_JH_EDGES.filter(e => e.homeId === router.id && e.wanIdx === wIdx).some(e => e.active) + const isActive = wanJhEdges.filter(e => e.homeId === router.id && e.wanIdx === wIdx).some(e => e.active) const isSatSel = selected?.id === router.id && selWanIdx === wIdx const satKey = `${router.id}-${wIdx}` return ( @@ -1182,6 +1677,8 @@ export default function NetworkMapPage() { nodes={nodes} greEdges={greEdges} satPos={effectiveSatPos} + wanJhEdges={wanJhEdges} + homeRouters={homeRouters} onClose={() => setShowMinimap(false)} onPan={(x, y) => setPan({ x, y })} /> @@ -1209,9 +1706,201 @@ export default function NetworkMapPage() { )}
- {/* ── Side panel ── */} - {selected && ( + {/* ── Side panel (узел или выбранное GRE-ребро) ── */} + {(selectedGreEdge || selected) && (
+ {selectedGreEdge ? ( + <> +
+
+

{selectedGreEdge.tunnel.name}

+

+ GRE · {selectedGreEdge.fromServer.name} ({TYPE_LABELS[selectedGreEdge.fromServer.type]}) ↔{" "} + {selectedGreEdge.toServer.name} ({TYPE_LABELS[selectedGreEdge.toServer.type]}) +

+
+
+ + +
+
+
+ {(() => { + const edge = selectedGreEdge + const t = edge.tunnel + const localWanName = ifaceNameForGreOuterIp(edge.fromServer, t.localAddress, greResolvedMap) + const remoteWanName = ifaceNameForGreOuterIp(edge.toServer, t.remoteAddress, greResolvedMap) + const baseProbe = greTunnelProbe(t) + const spGr = speedProbeByTunnelId.get(t.id) + const merged = mergeGreMetricsWithSpeedProbe(spGr, baseProbe) + const rttMon = spGr?.lastPingRttMs != null + const bwMon = spGr?.lastTxAvgMbps != null || spGr?.lastRxAvgMbps != null + const ts = TUNNEL_STYLE[t.status] + const tunnelStatusLabel = + t.status === "up" ? "Работает" : t.status === "degraded" ? "Деградация" : "Недоступен" + const tunnelStatusBadge = + t.status === "up" + ? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)]" + : t.status === "degraded" + ? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)]" + : "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)]" + return ( + <> +
+ + + {tunnelStatusLabel} + + + GRE-туннель + +
+ +
+

+ Метрики на карте +

+
+
+ + {rttMon ? "Мониторинг (ping/BT)" : "Модель RTT"} + + + {merged.pingMs == null ? "—" : `${merged.pingMs} мс`} + +
+
+ + {bwMon ? "TX / RX (BT)" : "Скорость (модель)"} + + + {merged.dlMbps != null && merged.ulMbps != null + ? `↓${merged.dlMbps} ↑${merged.ulMbps}` + : "—"} + +
+

+ {merged.hasSpeedMonitor + ? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам." + : "«Модель RTT» и «скорость» — демо до появления подходящей speed-пробы в «Мониторинг → скорость»."} +

+
+
+ +
+
+ Статус туннеля + {t.status} +
+
+ На узле + {edge.fromServer.name} +
+
+ Пир (каталог) + {edge.toServer.name} +
+
+ Включён + {t.enabled ? "да" : "нет"} +
+
+ IPsec + + {t.ipsec ? ( + <> да + ) : ( + <> нет + )} + +
+
+ +
+

+ Адреса и параметры +

+
+
+ Local outer + + {t.localAddress || "—"} + +
+ {localWanName && ( +
+ WAN на узле + {localWanName} +
+ )} +
+ Remote outer + + {t.remoteAddress || "—"} + +
+ {remoteWanName && ( +
+ WAN у пира + {remoteWanName} +
+ )} +
+ Inner + + {t.localInnerIp} ↔ {t.remoteInnerIp} + +
+
+ MTU / Keepalive + + {t.mtu} /{" "} + {t.keepaliveInterval === 0 ? "выкл." : `${t.keepaliveInterval}s×${t.keepaliveRetries}`} + +
+
+
+ + + Открыть раздел GRE-туннелей + + + ) + })()} +
+ + ) : selected ? ( + <>

{selected.name}

@@ -1277,7 +1966,7 @@ export default function NetworkMapPage() {
{selected.wanUplinks.map((wan, wIdx) => { const color = WAN_COLORS[wIdx] ?? "#888" - const myEdges = WAN_JH_EDGES.filter(e => e.homeId === selected.id && e.wanIdx === wIdx) + const myEdges = wanJhEdges.filter(e => e.homeId === selected.id && e.wanIdx === wIdx) const isActive = myEdges.some(e => e.active) const isSel = selWanIdx === wIdx return ( @@ -1313,7 +2002,7 @@ export default function NetworkMapPage() { Подключения к JH

{myEdges.map((e, ei) => { - const jh = servers.find(s => s.id === e.jhId) + const jh = mapServers.find(s => s.id === e.jhId) return (
@@ -1321,7 +2010,7 @@ export default function NetworkMapPage() { - {e.pingMs} мс + {e.pingMs == null ? "—" : `${e.pingMs} мс`}
) @@ -1344,10 +2033,20 @@ export default function NetworkMapPage() {
{connectedTunnels.map(t => { const ts = TUNNEL_STYLE[t.status] - const peer = servers.find(s => s.host === t.remoteAddress && s.id !== selected.id) - || servers.find(s => s.id === t.serverId && s.id !== selected.id) - const probe = TUNNEL_PROBES[t.id] - const pc = pingColor(probe?.pingMs ?? null) + const peer: Server | undefined = + t.serverId === selected.id + ? findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap) + : mapServers.find((s) => s.id === t.serverId) + const fromServer = mapServers.find((s) => s.id === t.serverId) + const toServer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap) + const baseProbe = greTunnelProbe(t) + const spGre = + fromServer && toServer ? speedProbeByTunnelId.get(t.id) : undefined + const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe) + const pc = pingColor(merged.pingMs) + const showMetrics = + merged.pingMs != null || + (merged.dlMbps != null && merged.ulMbps != null) return (
@@ -1357,21 +2056,26 @@ export default function NetworkMapPage() {
- - → {peer?.site ?? t.remoteAddress} + + → {peer ? `${peer.name} · ${peer.site}` : t.remoteAddress} {t.ipsec ? IPsec : Plain}
- {probe?.pingMs != null && ( + {showMetrics && (
- ping / speed + + {merged.hasSpeedMonitor ? "Мониторинг (ping / TX·RX)" : "Модель ping / speed"} + - {probe.pingMs} мс - {probe.dlMbps != null && ( - - ↓{probe.dlMbps} ↑{probe.ulMbps} + {merged.pingMs != null ? `${merged.pingMs} мс` : "—"} + {merged.dlMbps != null && merged.ulMbps != null && ( + + ↓{merged.dlMbps} ↑{merged.ulMbps} )} @@ -1386,16 +2090,26 @@ export default function NetworkMapPage() { {/* Resources */} {(() => { - const res = SERVER_RESOURCES[selected.id] + const res = srvResMap[selected.id] if (!res) return null const cpuPct = res.cpu const ramPct = Math.round(res.ramUsed / res.ramTotal * 100) const hddPct = Math.round(res.hddUsed / res.hddTotal * 100) return (
-

+

Ресурсы

+ {!res.fromPoll && ( +

+ Нет снапшота опроса — показаны демо-значения. Включите опрос серверов или откройте «Серверы». +

+ )} + {res.fromPoll && ( +

+ CPU, RAM и uptime — с последнего poll MikroTik. Диск в опросе не передаётся, шкала условная. +

+ )} {/* CPU with sparkline */}
@@ -1422,10 +2136,15 @@ export default function NetworkMapPage() {
- {/* HDD */} + {/* HDD (нет в GET /api/servers — всегда условная шкала) */}
- HDD + + HDD + {res.fromPoll && ( + (оценка) + )} + {fmtMB(res.hddUsed)} / {fmtMB(res.hddTotal)} @@ -1468,6 +2187,8 @@ export default function NetworkMapPage() { Навести карту на узел
+ + ) : null}
)}
diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index 3d44e8f..f41934d 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -345,10 +345,10 @@ function moveInArray(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({ {panelState}
- {fullCnt > 0 && Full ×{fullCnt}} - {partCnt > 0 && 2-Way ×{partCnt}} + {fullCnt > 0 && Full ×{fullCnt}} + {partCnt > 0 && 2-Way ×{partCnt}}
@@ -630,7 +632,9 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL const [toast, setToast] = useState(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
{toast && ( -
+
{toast}
)} @@ -760,7 +765,7 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL dragging !== item.key && dragOver !== item.key && "hover:bg-muted/40", )}> - + {item.interfaceName} {item.type && item.type !== "broadcast" && ( {item.type} @@ -828,8 +833,8 @@ function NeighborsTab({
{[ { 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 => ( @@ -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", )}> - + Анимация {!selectedId && ( @@ -1024,9 +1029,9 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
{[ { 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 => ( @@ -1094,7 +1099,7 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) { {b.packetsTx.toLocaleString()} - 3 ? "text-amber-500" : "text-muted-foreground")}> + 3 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground")}> {b.stateChanges} @@ -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 }) - .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 }) + .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]) diff --git a/app/(main)/probes/page.tsx b/app/(main)/probes/page.tsx index c152ba6..9686f70 100644 --- a/app/(main)/probes/page.tsx +++ b/app/(main)/probes/page.tsx @@ -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(path: string, init?: RequestInit): Promise { + 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 + } +} + +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 ( setLang(e.target.value)}> @@ -984,7 +1117,7 @@ export default function SettingsPage() {
- setTrafficIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал (сек)" /> + setTrafficRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" /> +
+
+ + +
+
+

Последний сбор: {trafficCollector?.lastCollectedAt ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}

+

Длительность: {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"}

+

{trafficCollector?.lastError ? `Ошибка: ${trafficCollector.lastError}` : "Ошибок нет"}

+
+ + + + + + Сбор uptime + Настройки для `/uptime` + + +
+ Состояние +
+ + +
+
+
+ setUptimeIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал ping-проб (сек)" /> + setUptimeSpeedIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал speed-проб (сек)" /> +
+
+ setUptimeRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" /> +
+
+ + +
+
+

Последний сбор: {uptimeCollector?.lastCollectedAt ? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}

+

Длительность: {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}

+

{uptimeCollector?.lastError ? `Ошибка: ${uptimeCollector.lastError}` : "Ошибок нет"}

+
+
+
+ + )} +
+ ) + + // ── EvoBGP ── + if (section === "EvoBGP") return ( +
+ {(mode !== "live" || backendStatus !== true) && ( + + +

Интеграция доступна в live-режиме

+

+ Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»). +

+
+
+ )} + + + + EvoBGP API + + Control plane EvoBGP: Bearer-ключ и роль viewer+ — см.{" "} + + access.md + + . URL и ключ хранятся в SQLite на сервере бекенда ( + evobgp_settings + ). Каталог —{" "} + GET /v1/router-lists/catalog через прокси. + + + + + setEvoBaseDraft(e.target.value)} + placeholder="http://localhost:8080" + disabled={mode !== "live" || backendStatus !== true || evoSaveBusy} + /> + + +
+ setEvoKeyDraft(e.target.value)} + placeholder={evo.secretConfigured ? "Оставьте пустым, чтобы не менять" : "Bearer-токен"} + disabled={mode !== "live" || backendStatus !== true || evoSaveBusy} + /> + +
+
+ + setEvoEnabledDraft(v)} + /> + +
+ +
+ {evoSaveErr &&

{evoSaveErr}

} +

+ «Проверить ключ» использует поля формы; незаполненное поле подставляется из БД. «Обновить каталог» — только по сохранённым в БД настройкам. +

+
+ + + {evo.snapshot?.fetchedAt && ( + + Загружено: {new Date(evo.snapshot.fetchedAt).toLocaleString("ru-RU")} + + )} +
+ {evoTestResult && ( +

+ {evoTestResult.message} +

+ )} + {evo.error && ( +

{evo.error}

+ )} +
+
) @@ -1306,9 +1803,26 @@ export default function SettingsPage() { - {saved ? : } - {saved ? "Сохранено!" : "Сохранить"} + } /> diff --git a/app/(main)/terminal/page.tsx b/app/(main)/terminal/page.tsx index 0f7fe87..aa16d49 100644 --- a/app/(main)/terminal/page.tsx +++ b/app/(main)/terminal/page.tsx @@ -204,8 +204,8 @@ function Terminal({ const [lines, setLines] = useState([]) const [input, setInput] = useState("") const [history, setHistory] = useState([]) - 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(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) - .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) + .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] diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index d6ed1ec..a7c0f0a 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -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([]) const [liveBusy, setLiveBusy] = useState(false) const [liveError, setLiveError] = useState(null) - const [settingsBusy, setSettingsBusy] = useState(false) - const [trafficSettings, setTrafficSettings] = useState(null) - const [intervalDraft, setIntervalDraft] = useState("30") - const [retentionDraft, setRetentionDraft] = useState("14") const [serverIfaces, setServerIfaces] = useState([]) 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("/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() { > Обновить - {isLive && ( - - )} } @@ -1020,94 +970,6 @@ export default function TrafficPage() {
- {isLive && trafficSettings && ( - - -
-
- Сбор статистики -
- - -
-
- -
- Интервал (сек) - setIntervalDraft(e.target.value)} - className="h-8 w-24 text-xs bg-muted/50 border border-border rounded-md px-2" - /> -
- -
- Хранение (дней) - setRetentionDraft(e.target.value)} - className="h-8 w-24 text-xs bg-muted/50 border border-border rounded-md px-2" - /> -
- - - -
-

Последний сбор: {trafficSettings.lastCollectedAt ? new Date(trafficSettings.lastCollectedAt).toLocaleString("ru-RU") : "—"}

-

Длительность: {trafficSettings.lastDurationMs != null ? `${trafficSettings.lastDurationMs} мс` : "—"}

-

- {trafficSettings.lastError ? `Ошибка: ${trafficSettings.lastError}` : "Ошибок нет"} -

-
-
-
-
- )} - {/* ── summary stat cards ── */}
{[ @@ -1206,11 +1068,11 @@ export default function TrafficPage() { > Все - {visibleIfaces.map((iface) => { + {visibleIfaces.map((iface, index) => { const active = selectedIface === iface.name return ( ) @@ -133,6 +162,393 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr ) } +/** Активный интерфейс RouterOS: не disabled и running */ +function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean { + return i.running === true && i.disabled !== true +} + +function filterActiveInterfaces(list: T[]): T[] { + return list.filter(isActiveRouterOsInterface) +} + +function serverMatchesSearch(server: Server, raw: string): boolean { + const q = raw.trim().toLowerCase() + if (!q) return true + const parts = [ + server.name, + server.host, + server.site, + server.country, + server.asn, + server.type.replace(/-/g, " "), + server.comment ?? "", + server.model, + ] + return parts.some((p) => String(p).toLowerCase().includes(q)) +} + +type RouterInterfaceOption = { + name: string + running?: boolean + disabled?: boolean + addresses?: string[] +} + +function interfaceOptionMatchesSearch(iface: RouterInterfaceOption, raw: string): boolean { + const q = raw.trim().toLowerCase() + if (!q) return true + if (iface.name.toLowerCase().includes(q)) return true + for (const addr of iface.addresses ?? []) { + const t = addr.trim() + const bare = t.includes("/") ? (t.split("/")[0] ?? "").trim() : t + if (bare.toLowerCase().includes(q) || t.toLowerCase().includes(q)) return true + } + return false +} + +function SegmentedControl({ + value, + onChange, + options, +}: { + value: T + onChange: (v: T) => void + options: Array<{ value: T; label: string }> +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ) +} + +function ServerPickerCards({ + options, + selectedId, + onSelect, + blockedId, +}: { + options: Server[] + selectedId: string + onSelect: (serverId: string) => void + blockedId?: string +}) { + const [query, setQuery] = useState("") + const filtered = useMemo(() => { + const q = query.trim() + const base = options.filter((s) => serverMatchesSearch(s, q)) + if (!selectedId) return base + const selected = options.find((s) => s.id === selectedId) + if (!selected || base.some((s) => s.id === selectedId)) return base + return [selected, ...base.filter((s) => s.id !== selectedId)] + }, [options, query, selectedId]) + + return ( +
+
+ + setQuery(e.target.value)} + placeholder="Поиск по имени, хосту, сайту…" + className="h-8 pl-8 pr-8 text-sm" + aria-label="Поиск сервера" + /> + {query ? ( + + ) : null} +
+
+ {filtered.length === 0 ? ( +

Ничего не найдено

+ ) : ( + filtered.map((server) => { + const isSelected = server.id === selectedId + const isBlocked = blockedId === server.id + return ( + + ) + }) + )} +
+
+ ) +} + +function InterfacePickerCards({ + value, + onChange, + options, + autoLabel, + busy, + disabled, +}: { + value: string + onChange: (v: string) => void + options: RouterInterfaceOption[] + autoLabel: string + busy?: boolean + disabled?: boolean +}) { + const [query, setQuery] = useState("") + const effectiveDisabled = disabled || busy + + const filtered = useMemo(() => { + const q = query.trim() + const base = options.filter((i) => interfaceOptionMatchesSearch(i, q)) + if (!value) return base + const selected = options.find((i) => i.name === value) + if (!selected || base.some((i) => i.name === value)) return base + return [selected, ...base.filter((i) => i.name !== value)] + }, [options, query, value]) + + return ( +
+
+ + setQuery(e.target.value)} + placeholder="Поиск по имени или IP…" + className="h-8 pl-8 pr-8 text-sm font-mono" + disabled={effectiveDisabled} + aria-label="Поиск интерфейса" + /> + {query ? ( + + ) : null} +
+ +
+ + +
+ {filtered.length === 0 ? ( +

Ничего не найдено

+ ) : ( + filtered.map((iface) => { + const selected = value === iface.name + const addrPreview = (iface.addresses ?? [])[0] + return ( + + ) + }) + )} +
+
+
+ ) +} + +const LINKED_FILTER_NONE = "—" + +function LinkedFilterPickerCards({ + value, + onChange, + items, +}: { + value: string + onChange: (v: string) => void + items: Filter[] +}) { + const [query, setQuery] = useState("") + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + let base = !q + ? items + : items.filter((f) => + f.name.toLowerCase().includes(q) || + f.id.toLowerCase().includes(q) || + f.gateway.toLowerCase().includes(q) || + (f.communities ?? []).some((c) => c.toLowerCase().includes(q)), + ) + if (value && value !== LINKED_FILTER_NONE) { + const sel = items.find((f) => f.name === value) + if (sel && !base.some((f) => f.name === value)) { + base = [sel, ...base.filter((f) => f.name !== value)] + } + } + return base + }, [items, query, value]) + + const isNone = value === LINKED_FILTER_NONE || !value + + return ( +
+
+ + setQuery(e.target.value)} + placeholder="Поиск по имени, community, gateway…" + className="h-8 pl-8 pr-8 text-sm" + aria-label="Поиск фильтра" + /> + {query ? ( + + ) : null} +
+ +
+ + + {filtered.length === 0 ? ( + query.trim() ? ( +

Ничего не найдено

+ ) : null + ) : ( + filtered.map((f) => { + const selected = value === f.name + return ( + + ) + }) + )} +
+
+ ) +} + // ── Resource monitoring types + helpers ─────────────────────────────────────── interface ServerResource { @@ -176,6 +592,11 @@ interface SpeedTestRun { status: "running" | "done" | "error" command: string lines: string[] + afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null + srcAddress?: string | null + dstAddress?: string | null + srcInterfaceAddress?: string | null + dstInterfaceAddress?: string | null } interface SpeedProbeRow { @@ -188,6 +609,56 @@ interface SpeedProbeRow { direction: "transmit" | "receive" | "both" durationSec: string enabled: boolean + lastRunAt?: string | null + lastTxAvgMbps?: number | null + lastRxAvgMbps?: number | null + lastStatus?: "done" | "error" | null + lastError?: string | null + lastPingRttMs?: number | null + lastPingLossPct?: number | null + lastPingAt?: string | null + lastPingError?: string | null +} + +function stripIpCidr(addr: string): string { + const t = addr.trim() + if (!t) return "" + return t.includes("/") ? (t.split("/")[0] ?? "").trim() : t +} + +function resolveSpeedProbeDstHost( + sp: SpeedProbeRow, + servers: Server[], + ifaces: Record>, +): string { + const dst = servers.find((s) => s.id === sp.dstServerId) + if (!dst) return "" + const iface = sp.dstInterface?.trim() + if (iface) { + const row = ifaces[sp.dstServerId]?.find((i) => i.name === iface) + const raw = row?.addresses?.[0] + const ip = raw ? stripIpCidr(raw) : "" + return (ip || dst.host).trim().toLowerCase() + } + return dst.host.trim().toLowerCase() +} + +function findLinkedSpeedProbe( + ping: PingProbe, + speedList: SpeedProbeRow[], + servers: Server[], + ifaces: Record>, +): SpeedProbeRow | undefined { + const t = ping.target.trim().toLowerCase() + if (!t) return undefined + return speedList.find((sp) => { + if (sp.srcServerId !== ping.srcServerId) return false + if ((sp.srcInterface ?? "").trim() !== (ping.srcInterface ?? "").trim()) return false + const resolved = resolveSpeedProbeDstHost(sp, servers, ifaces) + const dst = servers.find((s) => s.id === sp.dstServerId) + const host = dst?.host.trim().toLowerCase() ?? "" + return (resolved.length > 0 && t === resolved) || t === host + }) } function fmtMB(mb: number): string { @@ -246,24 +717,6 @@ const INIT_RESOURCES: ServerResource[] = mockServers.map(s => { } }) -const DEFAULT_SPEED_PROBE: SpeedProbeRow | null = (() => { - const enabled = mockServers.filter((s) => s.enabled) - if (!enabled.length) return null - const src = enabled[0]?.id ?? "" - const dst = enabled.find((s) => s.id !== src)?.id ?? src - return { - id: "sp-default", - srcServerId: src, - dstServerId: dst, - srcInterface: "", - dstInterface: "", - protocol: "tcp", - direction: "both", - durationSec: "10", - enabled: true, - } -})() - // ── MiniBar ─────────────────────────────────────────────────────────────────── function MiniBar({ pct, warn = 70, crit = 85, className }: { @@ -679,24 +1132,14 @@ export default function UptimePage() { const [collapsed, setCollapsed] = useState>(new Set()) const [sheetOpen, setSheetOpen] = useState(false) const [opError, setOpError] = useState(null) - const [settingsBusy, setSettingsBusy] = useState(false) - const [uptimeSettings, setUptimeSettings] = useState<{ - enabled: boolean - intervalSec: number - retentionDays: number - lastCollectedAt: string | null - lastDurationMs: number | null - lastError: string | null - } | null>(null) - const [intervalDraft, setIntervalDraft] = useState("15") - const [retentionDraft, setRetentionDraft] = useState("14") const [speedBusy, setSpeedBusy] = useState(false) const [speedError, setSpeedError] = useState(null) const [speedRuns, setSpeedRuns] = useState([]) - const [speedProbes, setSpeedProbes] = useState(() => (DEFAULT_SPEED_PROBE ? [DEFAULT_SPEED_PROBE] : [])) + const [speedProbes, setSpeedProbes] = useState([]) const [speedIfaces, setSpeedIfaces] = useState>>({}) const [speedCollapsed, setSpeedCollapsed] = useState>(new Set()) const [speedSheetOpen, setSpeedSheetOpen] = useState(false) + const [editingSpeedProbeId, setEditingSpeedProbeId] = useState(null) const [speedDraft, setSpeedDraft] = useState({ id: "", srcServerId: "", @@ -737,49 +1180,94 @@ export default function UptimePage() { .catch(() => setAllServers([])) }, [isLive, apiFetch]) + useEffect(() => { + if (isLive) return + queueMicrotask(() => setProbes(mockProbesWithSavedStars(INIT_PROBES))) + }, [isLive]) + const isPausedRef = useRef(isPaused) useEffect(() => { isPausedRef.current = isPaused }, [isPaused]) + /** Сбрасывает ответы устаревших GET /overview (гонка: ответ приходит после клика по ★ и затирает showOnDashboard). */ + const overviewReqRef = useRef(0) + const loadLiveOverview = useCallback(async () => { if (!isLive) return + const myReq = ++overviewReqRef.current setOpError(null) try { const data = await apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h") + if (myReq !== overviewReqRef.current) return setProbes(data.probes) setResources(data.resources) } catch (e) { + if (myReq !== overviewReqRef.current) return setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime") } }, [apiFetch, isLive]) - const loadLiveSettings = useCallback(async () => { + useEffect(() => { if (!isLive) return - try { - const s = await apiFetch<{ - enabled: boolean - intervalSec: number - retentionDays: number - lastCollectedAt: string | null - lastDurationMs: number | null - lastError: string | null - }>("/api/uptime/settings") - setUptimeSettings(s) - setIntervalDraft(String(s.intervalSec)) - setRetentionDraft(String(s.retentionDays)) - } catch (e) { - setOpError(e instanceof Error ? e.message : "Не удалось загрузить настройки uptime") - } - }, [apiFetch, isLive]) + queueMicrotask(() => { void loadLiveOverview() }) + }, [isLive, loadLiveOverview]) useEffect(() => { - if (!isLive) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setUptimeSettings(null) - return - } - void loadLiveOverview() - void loadLiveSettings() - }, [isLive, loadLiveOverview, loadLiveSettings]) + if (!isLive) return + void apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes") + .then((data) => setSpeedProbes(data.probes ?? [])) + .catch(() => setSpeedProbes([])) + }, [isLive, apiFetch]) + + useEffect(() => { + if (!isLive) return + void apiFetch<{ + runs: Array<{ + id: string + srcServerId: string + dstServerId: string + srcInterface: string + dstInterface: string + protocol: "tcp" | "udp" + direction: "transmit" | "receive" | "both" + durationSec: number + txAvgMbps: number + rxAvgMbps: number + status: "done" | "error" + error?: string | null + srcAddress?: string | null + dstAddress?: string | null + srcInterfaceAddress?: string | null + dstInterfaceAddress?: string | null + afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null + createdAt: string + }> + }>("/api/uptime/speed-test/runs") + .then((data) => { + const rows = (data.runs ?? []).map((r) => ({ + id: r.id, + startedAt: Date.parse(r.createdAt), + srcServerId: r.srcServerId, + dstServerId: r.dstServerId, + srcInterface: r.srcInterface || undefined, + dstInterface: r.dstInterface || undefined, + protocol: r.protocol, + direction: r.direction, + durationSec: r.durationSec, + txAvgMbps: Math.round(r.txAvgMbps ?? 0), + rxAvgMbps: Math.round(r.rxAvgMbps ?? 0), + status: (r.status === "error" ? "error" : "done") as "error" | "done", + command: "", + lines: r.error ? [`status: error`, r.error] : [], + afterBtPing: r.afterBtPing ?? null, + srcAddress: r.srcAddress ?? null, + dstAddress: r.dstAddress ?? null, + srcInterfaceAddress: r.srcInterfaceAddress ?? null, + dstInterfaceAddress: r.dstInterfaceAddress ?? null, + })) + setSpeedRuns(rows) + }) + .catch(() => setSpeedRuns([])) + }, [isLive, apiFetch]) // add-probe form const [newSrcId, setNewSrcId] = useState("") @@ -809,6 +1297,27 @@ export default function UptimePage() { } }, [apiFetch, isLive, speedIfaces]) + /** После загрузки списков интерфейсов сбросить выбор, если интерфейс не активен или отсутствует в списке */ + useEffect(() => { + if (!speedSheetOpen) return + setSpeedDraft((prev) => { + const rawSrc = speedIfaces[prev.srcServerId] + const rawDst = speedIfaces[prev.dstServerId] + let srcInterface = prev.srcInterface + let dstInterface = prev.dstInterface + if (rawSrc !== undefined) { + const active = filterActiveInterfaces(rawSrc) + if (srcInterface && !active.some((i) => i.name === srcInterface)) srcInterface = "" + } + if (rawDst !== undefined) { + const active = filterActiveInterfaces(rawDst) + if (dstInterface && !active.some((i) => i.name === dstInterface)) dstInterface = "" + } + if (srcInterface === prev.srcInterface && dstInterface === prev.dstInterface) return prev + return { ...prev, srcInterface, dstInterface } + }) + }, [speedSheetOpen, speedIfaces]) + useEffect(() => { if (!sheetOpen) return if (!newSrcId || !selectableSources.some(s => s.id === newSrcId)) { @@ -838,7 +1347,7 @@ export default function UptimePage() { setSrcInterfacesBusy(true) void apiFetch<{ interfaces: Array<{ name: string; running: boolean; disabled: boolean }> }>(`/api/uptime/sources/${serverId}/interfaces`) .then((data) => { - const list = data.interfaces ?? [] + const list = filterActiveInterfaces(data.interfaces ?? []) setSrcInterfaces(list) setNewSrcInterface((prev) => (prev && list.some((i) => i.name === prev) ? prev : "")) }) @@ -966,12 +1475,104 @@ export default function UptimePage() { })) }, [speedProbes, selectableSources]) + const persistProbes = useCallback((rows: PingProbe[]) => { + if (!isLive) return + void apiFetch("/api/uptime/probes", { + method: "PUT", + body: JSON.stringify({ + probes: rows.map((p) => ({ + id: p.id, + srcServerId: p.srcServerId, + srcInterface: p.srcInterface || "", + name: p.name, + target: p.target, + filter: p.filter, + enabled: p.enabled, + showOnDashboard: p.showOnDashboard === true, + })), + }), + }).catch(() => {}) + }, [apiFetch, isLive]) + + const toggleDashboardStar = useCallback((id: string) => { + const cur = probes.find((p) => p.id === id) + if (!cur) return + const nextVal = !cur.showOnDashboard + if (isLive) { + overviewReqRef.current += 1 + } + setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: nextVal } : p))) + if (isLive) { + void apiFetch(`/api/uptime/probes/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ showOnDashboard: nextVal }), + }) + .then(() => loadLiveOverview()) + .then(() => { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED)) + } + }) + .catch(() => { + setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: cur.showOnDashboard } : p))) + }) + } else { + const s = readMockDashboardStarIds() + if (nextVal) s.add(id) + else s.delete(id) + writeMockDashboardStarIds(s) + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED)) + } + } + }, [probes, isLive, apiFetch, loadLiveOverview]) + + const persistSpeedProbes = useCallback((rows: SpeedProbeRow[]) => { + if (!isLive) return + void apiFetch("/api/uptime/speed-probes", { + method: "PUT", + body: JSON.stringify({ + probes: rows.map((p) => ({ + id: p.id, + srcServerId: p.srcServerId, + dstServerId: p.dstServerId, + srcInterface: p.srcInterface || "", + dstInterface: p.dstInterface || "", + protocol: p.protocol, + direction: p.direction, + durationSec: Number.parseInt(p.durationSec, 10) || 10, + enabled: p.enabled, + lastRunAt: p.lastRunAt ?? null, + lastTxAvgMbps: p.lastTxAvgMbps ?? null, + lastRxAvgMbps: p.lastRxAvgMbps ?? null, + lastStatus: p.lastStatus ?? null, + lastError: p.lastError ?? null, + })), + }), + }).catch(() => {}) + }, [apiFetch, isLive]) + // ── actions ── const toggleProbe = (id: string, v: boolean) => - setProbes(p => p.map(x => x.id === id ? { ...x, enabled: v } : x)) + setProbes((prev) => { + const next = prev.map((x) => x.id === id ? { ...x, enabled: v } : x) + persistProbes(next) + return next + }) - const deleteProbe = (id: string) => - setProbes(p => p.filter(x => x.id !== id)) + const deleteProbe = (id: string) => { + if (!isLive) { + const s = readMockDashboardStarIds() + s.delete(id) + writeMockDashboardStarIds(s) + } + setProbes((prev) => { + const next = prev.filter((x) => x.id !== id) + persistProbes(next) + return next + }) + } const toggleCollapse = (serverId: string) => setCollapsed(prev => { @@ -1028,9 +1629,11 @@ export default function UptimePage() { status: "up", series: Array(40).fill(0), enabled: true, + showOnDashboard: false, } if (editingProbeId) { - setProbes(prev => prev.map((p) => { + setProbes((prev) => { + const next = prev.map((p) => { if (p.id !== editingProbeId) return p return { ...p, @@ -1040,9 +1643,16 @@ export default function UptimePage() { target: probeBase.target, filter: probeBase.filter, } - })) + }) + persistProbes(next) + return next + }) } else { - setProbes(p => [...p, probeBase]) + setProbes((prev) => { + const next = [...prev, probeBase] + persistProbes(next) + return next + }) } setEditingProbeId(null) setSheetOpen(false) @@ -1051,12 +1661,69 @@ export default function UptimePage() { const buildSpeedCommand = useCallback((probe: SpeedProbeRow) => { const src = allServers.find((s) => s.id === probe.srcServerId) const dst = allServers.find((s) => s.id === probe.dstServerId) - const dstIp = probe.dstInterface - ? (speedIfaces[probe.dstServerId]?.find((i) => i.name === probe.dstInterface)?.addresses[0] ?? (dst?.host ?? "0.0.0.0")) - : (dst?.host ?? "0.0.0.0") - return `[${src?.name ?? "src"}] /tool bandwidth-test address=${dstIp} user=${dst?.username ?? "admin"} protocol=${probe.protocol} direction=${probe.direction} duration=${Math.max(3, Number.parseInt(probe.durationSec, 10) || 10)}s` + const rawDst = probe.dstInterface + ? speedIfaces[probe.dstServerId]?.find((i) => i.name === probe.dstInterface)?.addresses?.[0] + : undefined + const dstIp = rawDst ? stripIpCidr(rawDst) : (dst?.host ?? "0.0.0.0") + const dur = Math.max(3, Number.parseInt(probe.durationSec, 10) || 10) + return `[${src?.name ?? "src"}] /tool bandwidth-test address=${dstIp} user= protocol=${probe.protocol} direction=${probe.direction} duration=${dur}s` }, [allServers, speedIfaces]) + /** Те же адреса, что при POST /api/uptime/speed-test — для проверки в SSH на узле источника. */ + const speedVerificationRouterOsCli = useMemo(() => { + const probe = speedDraft + const src = allServers.find((s) => s.id === probe.srcServerId) + const dst = allServers.find((s) => s.id === probe.dstServerId) + if (!src || !dst || probe.srcServerId === probe.dstServerId) return "" + + const dstRaw = probe.dstInterface.trim() + ? speedIfaces[probe.dstServerId]?.find((i) => i.name === probe.dstInterface)?.addresses?.[0] + : undefined + const dstIp = dstRaw ? stripIpCidr(dstRaw) : dst.host.trim() + + const srcRaw = probe.srcInterface.trim() + ? speedIfaces[probe.srcServerId]?.find((i) => i.name === probe.srcInterface)?.addresses?.[0] + : undefined + const srcIp = srcRaw ? stripIpCidr(srcRaw) : src.host.trim() + + const dur = Math.max(3, Number.parseInt(probe.durationSec, 10) || 10) + const si = probe.srcInterface.trim() + const di = probe.dstInterface.trim() + + const lines: string[] = [] + lines.push(`# Проверка IP (как backend BTTest: только активные записи /ip/address — без disabled/invalid; статический адрес предпочтительнее dynamic)`) + lines.push(`# Узел источника: ${src.name}`) + lines.push(`# ожидаемый локальный адрес: ${srcIp || "?"}${si ? ` · интерфейс "${si}"` : " · interface не задан — как host/API каталога"}`) + lines.push(`# Узел назначения: ${dst.name}`) + lines.push(`# address для ping и bandwidth-test: ${dstIp || "?"}${di ? ` · интерфейс "${di}" на назначении` : " · host каталога (DNS/API)"}`) + lines.push("") + + if (si) { + lines.push(`/ip address print where name="${si.replace(/"/g, '\\"')}"`) + lines.push("") + } + if (di) { + lines.push(`# Выполнить на узле назначения (${dst.name}) — адрес приёмника BT-сервера:`) + lines.push(`/ip address print where name="${di.replace(/"/g, '\\"')}"`) + lines.push("") + } + + const pingLine = + `/ping address=${dstIp || "?"} count=4` + + (si ? ` interface="${si.replace(/"/g, '\\"')}"` : "") + lines.push(pingLine) + lines.push("") + lines.push( + `/tool bandwidth-test address=${dstIp || "?"} user=<логин_API_назначения> password=<пароль_API_назначения> protocol=${probe.protocol} direction=${probe.direction} duration=${dur}s`, + ) + lines.push("") + lines.push( + `# Подставьте логин/пароль REST API узла «${dst.name}» из раздела «Серверы» (тот же user/password, что для MikrotikClient).`, + ) + + return lines.join("\n") + }, [speedDraft, allServers, speedIfaces]) + const runSpeedTest = async (probe: SpeedProbeRow) => { if (!probe.srcServerId || !probe.dstServerId || probe.srcServerId === probe.dstServerId) return setSpeedError(null) @@ -1075,13 +1742,15 @@ export default function UptimePage() { durationSec, txAvgMbps: 0, rxAvgMbps: 0, - status: "running", + status: "running" as const, command: buildSpeedCommand(probe), lines: ["status: running..."], }, ...prev].slice(0, 20)) try { if (isLive) { const payload = { + runId, + probeId: probe.id, srcServerId: Number.parseInt(probe.srcServerId, 10), dstServerId: Number.parseInt(probe.dstServerId, 10), srcInterface: probe.srcInterface || undefined, @@ -1090,34 +1759,92 @@ export default function UptimePage() { direction: probe.direction, durationSec, } - const res = await apiFetch<{ result: { txAvgMbps: number; rxAvgMbps: number; raw?: Array> } }>("/api/uptime/speed-test", { + const res = await apiFetch<{ + result: { + txAvgMbps: number + rxAvgMbps: number + raw?: Array> + afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } + srcAddress?: string | null + dstAddress?: string | null + srcInterfaceAddress?: string | null + dstInterfaceAddress?: string | null + } + }>("/api/uptime/speed-test", { method: "POST", body: JSON.stringify(payload), }) + const ap = res.result.afterBtPing const lines = (res.result.raw ?? []).flatMap((row) => Object.entries(row).map(([k, v]) => `${k}: ${String(v)}`), ) + const pingNote = ap + ? (ap.error + ? `after-bt-ping: error: ${ap.error}` + : `after-bt-ping: rtt=${ap.rttMs ?? "—"}ms loss=${ap.lossPct ?? "—"}%`) + : "" setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({ ...r, txAvgMbps: Math.round(res.result.txAvgMbps), rxAvgMbps: Math.round(res.result.rxAvgMbps), status: "done", - lines: lines.length ? lines : [`tx-total-average: ${Math.round(res.result.txAvgMbps)}Mbps`, `rx-total-average: ${Math.round(res.result.rxAvgMbps)}Mbps`], + afterBtPing: ap ?? null, + srcAddress: res.result.srcAddress ?? null, + dstAddress: res.result.dstAddress ?? null, + srcInterfaceAddress: res.result.srcInterfaceAddress ?? null, + dstInterfaceAddress: res.result.dstInterfaceAddress ?? null, + lines: [ + ...(lines.length ? lines : [`tx-total-average: ${Math.round(res.result.txAvgMbps)}Mbps`, `rx-total-average: ${Math.round(res.result.rxAvgMbps)}Mbps`]), + ...(pingNote ? [pingNote] : []), + ], }) : r)) + const ts = new Date().toISOString() + setSpeedProbes((prev) => prev.map((p) => p.id === probe.id ? ({ + ...p, + lastRunAt: ts, + lastTxAvgMbps: Math.round(res.result.txAvgMbps), + lastRxAvgMbps: Math.round(res.result.rxAvgMbps), + lastStatus: "done", + lastError: "", + lastPingRttMs: ap?.rttMs ?? null, + lastPingLossPct: ap?.lossPct ?? null, + lastPingAt: ts, + lastPingError: ap?.error ?? "", + }) : p)) } else { const tx = Math.max(10, Math.round(250 + Math.random() * 500)) const rx = Math.max(10, Math.round(tx * (0.85 + Math.random() * 0.2))) + const mockPing = { rttMs: Math.round(8 + Math.random() * 35), lossPct: Math.random() < 0.15 ? Math.round(Math.random() * 25) : 0, error: null as string | null } setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({ ...r, txAvgMbps: tx, rxAvgMbps: rx, status: "done", + afterBtPing: mockPing, + srcAddress: allServers.find((s) => s.id === probe.srcServerId)?.host ?? null, + dstAddress: allServers.find((s) => s.id === probe.dstServerId)?.host ?? null, + srcInterfaceAddress: null, + dstInterfaceAddress: null, lines: [ "status: done testing", `tx-total-average: ${tx}Mbps`, `rx-total-average: ${rx}Mbps`, + `after-bt-ping: rtt=${mockPing.rttMs}ms loss=${mockPing.lossPct}%`, ], }) : r)) + const ts = new Date().toISOString() + setSpeedProbes((prev) => prev.map((p) => p.id === probe.id ? ({ + ...p, + lastRunAt: ts, + lastTxAvgMbps: tx, + lastRxAvgMbps: rx, + lastStatus: "done", + lastError: "", + lastPingRttMs: mockPing.rttMs, + lastPingLossPct: mockPing.lossPct, + lastPingAt: ts, + lastPingError: "", + }) : p)) } } catch (e) { const message = e instanceof Error ? e.message : "Не удалось выполнить speed test" @@ -1127,23 +1854,34 @@ export default function UptimePage() { lines: [`status: error`, message], }) : r)) setSpeedError(message) + setSpeedProbes((prev) => prev.map((p) => p.id === probe.id ? ({ + ...p, + lastRunAt: new Date().toISOString(), + lastStatus: "error", + lastError: message, + }) : p)) } finally { setSpeedBusy(false) } } const updateSpeedProbe = (id: string, patch: Partial) => { - setSpeedProbes((prev) => prev.map((p) => { - if (p.id !== id) return p - const next = { ...p, ...patch } - if (patch.srcServerId && patch.srcServerId === next.dstServerId) { - next.dstServerId = selectableSources.find((s) => s.id !== patch.srcServerId)?.id ?? patch.srcServerId - } - return next - })) + setSpeedProbes((prev) => { + const nextRows = prev.map((p) => { + if (p.id !== id) return p + const next = { ...p, ...patch } + if (patch.srcServerId && patch.srcServerId === next.dstServerId) { + next.dstServerId = selectableSources.find((s) => s.id !== patch.srcServerId)?.id ?? patch.srcServerId + } + return next + }) + persistSpeedProbes(nextRows) + return nextRows + }) } const openAddSpeedSheet = () => { + setEditingSpeedProbeId(null) const src = selectableSources[0]?.id ?? "" const dst = selectableSources.find((s) => s.id !== src)?.id ?? src setSpeedDraft({ @@ -1162,17 +1900,52 @@ export default function UptimePage() { void loadSpeedInterfaces(dst) } - const addSpeedProbe = () => { + const openEditSpeedSheet = (probe: SpeedProbeRow) => { + setEditingSpeedProbeId(probe.id) + setSpeedDraft({ + ...probe, + durationSec: String(probe.durationSec ?? "10"), + }) + setSpeedSheetOpen(true) + void loadSpeedInterfaces(probe.srcServerId) + void loadSpeedInterfaces(probe.dstServerId) + } + + const saveSpeedProbe = () => { if (!speedDraft.srcServerId || !speedDraft.dstServerId) return - setSpeedProbes((prev) => [{ - ...speedDraft, - id: `sp-${Date.now()}`, - }, ...prev]) + if (editingSpeedProbeId) { + setSpeedProbes((prev) => { + const next = prev.map((p) => p.id === editingSpeedProbeId + ? { + ...p, + ...speedDraft, + id: editingSpeedProbeId, + durationSec: String(speedDraft.durationSec ?? "10"), + } + : p) + persistSpeedProbes(next) + return next + }) + } else { + setSpeedProbes((prev) => { + const next = [{ + ...speedDraft, + id: `sp-${Date.now()}`, + }, ...prev] + persistSpeedProbes(next) + return next + }) + } + setEditingSpeedProbeId(null) setSpeedSheetOpen(false) } const deleteSpeedProbe = (id: string) => { - setSpeedProbes((prev) => prev.filter((p) => p.id !== id)) + setSpeedProbes((prev) => { + const next = prev.filter((p) => p.id !== id) + persistSpeedProbes(next) + return next + }) } const toggleSpeedCollapse = (serverId: string) => { @@ -1183,27 +1956,6 @@ export default function UptimePage() { }) } - useEffect(() => { - if (!isLive) return - const t = setTimeout(() => { - void apiFetch("/api/uptime/probes", { - method: "PUT", - body: JSON.stringify({ - probes: probes.map((p) => ({ - id: p.id, - srcServerId: p.srcServerId, - srcInterface: p.srcInterface || "", - name: p.name, - target: p.target, - filter: p.filter, - enabled: p.enabled, - })), - }), - }).catch(() => {}) - }, 250) - return () => clearTimeout(t) - }, [isLive, probes, apiFetch]) - // ── render ── return (
@@ -1215,7 +1967,7 @@ export default function UptimePage() { {tab === "probes" && ( @@ -1253,15 +2001,17 @@ export default function UptimePage() { {/* ── Live indicator ── */} {!isPaused && ( -
- +
+ {isLive - ? `Live (backend) · сбор каждые ${uptimeSettings?.intervalSec ?? 15}с` + ? "Live (backend)" : "Live · проба обновляется каждые 3с · ресурсы каждые 5с"}
)} {isPaused && ( -
+
Обновление приостановлено
@@ -1307,7 +2057,7 @@ export default function UptimePage() { Скорость - {speedRuns.length} + {speedProbes.length} )} @@ -1315,142 +2065,92 @@ export default function UptimePage() {
- {isLive && uptimeSettings && ( -
- - -
-
- Сбор uptime -
- - -
-
- -
- Интервал (сек) - setIntervalDraft(e.target.value)} /> -
-
- Хранение (дней) - setRetentionDraft(e.target.value)} /> -
- - -
-

Последний сбор: {uptimeSettings.lastCollectedAt ? new Date(uptimeSettings.lastCollectedAt).toLocaleString("ru-RU") : "—"}

-

Длительность: {uptimeSettings.lastDurationMs != null ? `${uptimeSettings.lastDurationMs} мс` : "—"}

- {uptimeSettings.lastError &&

Ошибка: {uptimeSettings.lastError}

} -
-
-
-
-
- )} - {/* ── resources tab ── */} {tab === "resources" && } {/* ── speed tab ── */} {tab === "speed" && ( -
- - -
-

Speed-пробы между серверами

-

{speedProbes.length} проб

-
-

Создание через модалку, запуск/удаление — в строках ниже.

- {speedError && ( -

{speedError}

- )} -
-
+
- {speedGrouped.length === 0 && ( - - - Нет speed-проб. Нажми `Новая speed-проба`. - - + {/* ── KPI strip ── */} + {(() => { + const doneRuns = speedRuns.filter(r => r.status === "done") + const runningCnt = speedRuns.filter(r => r.status === "running").length + const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null + const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null + return ( +
+ {[ + { label: "Speed-пробы", value: speedProbes.length, unit: "шт", color: "" }, + { label: "Тестов выполнено", value: doneRuns.length, unit: "run", color: "" }, + { label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" }, + { label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" }, + ].map(k => ( + + +

{k.label}

+
+ {k.value} + {k.unit && {k.unit}} +
+ {runningCnt > 0 && k.label === "Тестов выполнено" && ( +

+ {runningCnt} выполняется +

+ )} +
+
+ ))} +
+ ) + })()} + + {/* ── error ── */} + {speedError && ( +
+ {speedError} +
)} - {speedGrouped.map(({ server, probes: srvProbes }) => { - const isCollapsed = speedCollapsed.has(server.id) - return ( - - +
- {!isCollapsed && ( - <> -
- - Назначение - If src - If dst - Proto - Dir - Sec - Команда - Действия -
+ {/* ── empty state ── */} + {speedGrouped.length === 0 && ( +
+
+ +
+

Нет speed-проб

+

+ Создайте пробу через кнопку «Новая speed-проба» в шапке страницы +

+ +
+ )} + {/* ── probe groups ── */} + {speedGrouped.map(({ server, probes: srvProbes }) => { + const isCollapsed = speedCollapsed.has(server.id) + return ( + + {/* server header */} + + + {!isCollapsed && (
{srvProbes.map((probe) => { const dst = allServers.find((s) => s.id === probe.dstServerId) @@ -1460,83 +2160,265 @@ export default function UptimePage() { (r.srcInterface ?? "") === probe.srcInterface && (r.dstInterface ?? "") === probe.dstInterface, ) + const viewStatus = run?.status ?? (probe.lastStatus === "error" ? "error" : probe.lastRunAt ? "done" : null) + const viewTx = run?.txAvgMbps ?? Math.round(probe.lastTxAvgMbps ?? 0) + const viewRx = run?.rxAvgMbps ?? Math.round(probe.lastRxAvgMbps ?? 0) + const maxVal = Math.max(viewTx, viewRx, 1) + const canRun = probe.enabled && !!probe.srcServerId && !!probe.dstServerId && probe.srcServerId !== probe.dstServerId + const viewAfterBtPing = + run?.status === "done" && run.afterBtPing + ? run.afterBtPing + : probe.lastPingAt + ? { + rttMs: probe.lastPingRttMs ?? null, + lossPct: probe.lastPingLossPct ?? null, + error: probe.lastPingError?.trim() ? probe.lastPingError : null, + } + : null + return (
- updateSpeedProbe(probe.id, { enabled: v })} /> -
-

{dst?.name ?? probe.dstServerId}

- {run && ( -

- {run.status === "running" ? "running..." : `TX ${run.txAvgMbps} / RX ${run.rxAvgMbps} Mbps`} -

- )} -
- {probe.srcInterface || "auto"} - {probe.dstInterface || "auto"} - {probe.protocol.toUpperCase()} - {probe.direction} - {probe.durationSec}s - {buildSpeedCommand(probe)} -
- - + className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}> +
+ {/* enable toggle */} + updateSpeedProbe(probe.id, { enabled: v })} /> + + {/* route: src → dst */} +
+ {server.name} + + + {dst?.name ?? probe.dstServerId} +
+ + {/* param chips */} +
+ + {probe.protocol.toUpperCase()} + + + {probe.direction === "both" ? "↕" : probe.direction === "transmit" ? "↑" : "↓"} {probe.direction} + + + {probe.durationSec}s + + {(probe.srcInterface || probe.dstInterface) && ( + + {probe.srcInterface || "auto"} → {probe.dstInterface || "auto"} + + )} +
+ + {/* actions */} +
+ + + +
+ + {/* result row */} + {viewStatus && ( +
+ {viewStatus === "running" ? ( +

+ Тест выполняется… +

+ ) : viewStatus === "error" ? ( +

+ {probe.lastError || "Ошибка выполнения теста"} +

+ ) : ( +
+
+ {[ + { label: "TX", val: viewTx, color: "bg-[var(--chart-tx)]", textColor: "text-[var(--chart-tx)]" }, + { label: "RX", val: viewRx, color: "bg-[var(--chart-rx)]", textColor: "text-[var(--chart-rx)]" }, + ].map(r => ( +
+ {r.label} +
+
0 ? 3 : 0)}%` }} /> +
+ + {r.val} Мбит/с + +
+ ))} +
+ {viewAfterBtPing && ( + viewAfterBtPing.error ? ( +

+ Ping после BT: {viewAfterBtPing.error} +

+ ) : ( +
+ Ping +
+
+
+ + {viewAfterBtPing.rttMs == null ? "timeout" : `${viewAfterBtPing.rttMs} мс`} + +
+ ) + )} +
+ )} +
+ )}
) })}
- - )} - - ) - })} - - -
- - - - - - - - - - - - {speedRuns.length === 0 && ( - - - )} - {speedRuns.map((run) => { - const src = allServers.find((s) => s.id === run.srcServerId) - const dst = allServers.find((s) => s.id === run.dstServerId) - return ( - - - - - - + + ) + })} + + {/* ── history ── */} + {speedRuns.length > 0 && ( + +
+

История тестов

+ {speedRuns.length} запусков +
+
+
ВремяПутьПараметрыTX avgRX avg
Запусти первый BTTest
{new Date(run.startedAt).toLocaleString("ru-RU")}{src?.name ?? run.srcServerId} → {dst?.name ?? run.dstServerId}{run.protocol.toUpperCase()} · {run.direction} · {run.durationSec}s{run.txAvgMbps} Mbps{run.rxAvgMbps} Mbps
+ + + {["Время", "Маршрут", "Параметры", "Статус", "TX avg", "RX avg", "Ping после BT"].map(h => ( + + ))} - ) - })} - -
{h}
-
-
+ + + {speedRuns.map((run) => { + const src = allServers.find((s) => s.id === run.srcServerId) + const dst = allServers.find((s) => s.id === run.dstServerId) + const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1) + return ( + + + {new Date(run.startedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", day: "2-digit", month: "2-digit" })} + + +
+ + {src?.name ?? run.srcServerId} + + + {dst?.name ?? run.dstServerId} +
+
+ {run.srcInterfaceAddress && run.dstInterfaceAddress + ? `${run.srcInterfaceAddress} → ${run.dstInterfaceAddress}` + : "внутренние IP: auto/не указаны"} +
+ + +
+ + {run.protocol.toUpperCase()} + + · + {run.direction} + · + {run.durationSec}s +
+ + + {run.status === "running" ? ( + + running + + ) : run.status === "error" ? ( + error + ) : ( + done + )} + + +
+
+
+
+ + {run.txAvgMbps} Мбит/с + +
+ + +
+
+
+
+ + {run.rxAvgMbps} Мбит/с + +
+ + + {run.status !== "done" ? "—" : run.afterBtPing?.error ? ( + + ошибка + + ) : run.afterBtPing?.rttMs != null ? ( + + {run.afterBtPing.rttMs} мс + {run.afterBtPing.lossPct != null && run.afterBtPing.lossPct > 0 && ( + · {run.afterBtPing.lossPct}% + )} + + ) : ( + + timeout + {run.afterBtPing?.lossPct != null && · {run.afterBtPing.lossPct}%} + + )} + + + ) + })} + + +
+ + )} + +
)} @@ -1615,6 +2497,10 @@ export default function UptimePage() {

{filtered.length} из {probes.length} проб + · + + звезда — блок «Активные пробы» +

@@ -1676,7 +2562,7 @@ export default function UptimePage() {
+ style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}> Интерфейс @@ -1684,17 +2570,21 @@ export default function UptimePage() { Фильтр RTT Потери + BT · ping График + Действия
- {group.probes.map((p) => ( + {group.probes.map((p) => { + const linkedSp = findLinkedSpeedProbe(p, speedProbes, allServers, speedIfaces) + return (
+ style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}> toggleProbe(p.id, v)} /> {p.loss}% +
+ {!linkedSp?.lastRunAt ? ( + + ) : linkedSp.lastStatus === "error" && linkedSp.lastError ? ( + + BT: ошибка + + ) : ( + <> + + {linkedSp.lastTxAvgMbps != null && linkedSp.lastRxAvgMbps != null + ? `${Math.round(linkedSp.lastTxAvgMbps)}/${Math.round(linkedSp.lastRxAvgMbps)} Мбит/с` + : "—"} + + {linkedSp.lastPingError?.trim() ? ( + + ping: сбой + + ) : linkedSp.lastPingRttMs != null ? ( + + ping {linkedSp.lastPingRttMs} мс + {linkedSp.lastPingLossPct != null && linkedSp.lastPingLossPct > 0 && ( + · {linkedSp.lastPingLossPct}% + )} + + ) : linkedSp.lastPingAt ? ( + + ping timeout + {linkedSp.lastPingLossPct != null && · {linkedSp.lastPingLossPct}%} + + ) : null} + + )} +
+
+ +
- ))} + ) + })}
{/* ── add speed-probe sheet ── */} - setSpeedSheetOpen(v)}> + { + if (!v) { + setSpeedSheetOpen(false) + setEditingSpeedProbeId(null) + return + } + setSpeedSheetOpen(true) + }}>
- Новая speed-проба + {editingSpeedProbeId ? "Редактирование speed-пробы" : "Новая speed-проба"} - BTTest между выбранными серверами + {editingSpeedProbeId ? "Измените параметры BT-пробы" : "BTTest между выбранными серверами"}
+
+
+                  {speedVerificationRouterOsCli}
+                
+

+ Команды выполняйте на узле источника (SSH или Terminal в MikrotikManager). Если IP показываются как «?», перезагрузите список интерфейсов (смените сервер или закройте и снова откройте форму) — подтянутся адреса с RouterOS. +

+
+ )}
- - + {/* ── add probe sheet ── */} - { + { if (!v) { setSheetOpen(false) setEditingProbeId(null) + return } + setSheetOpen(true) }}> @@ -1909,42 +2895,21 @@ export default function UptimePage() {
- + s.enabled)} + selectedId={newSrcId} + onSelect={(id) => setNewSrcId(id)} + /> - + onChange={setNewSrcInterface} + options={filterActiveInterfaces(srcInterfaces)} + autoLabel="авто (по маршруту)" + busy={srcInterfacesBusy} + /> @@ -1958,15 +2923,11 @@ export default function UptimePage() { - + onChange={setNewFilter} + items={filters} + />
@@ -1977,7 +2938,8 @@ export default function UptimePage() { diff --git a/app/globals.css b/app/globals.css index 4d73f96..b69a40c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 на — без этого нативные контролы (select) дают светлый popup и белый текст */ + html.dark { + color-scheme: dark; + } + + /* + Нативный