Compare commits

...
787 Commits
Author SHA1 Message Date
Denozordec 5efd0339fb feat(logs): добавить раздел логов с историей и живым потоком
Docker images / frontend-image (push) Successful in 3m29s
Docker images / updater-image (push) Successful in 41s
Docker images / netflow-agent-image (push) Successful in 34s
Docker images / backend-image (push) Successful in 1m44s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 4m35s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 22:13:48 +07:00
Denozordec e461dc21bd fix(traffic): строить непрерывную историю коротких периодов
Docker images / frontend-image (push) Successful in 2m13s
Docker images / updater-image (push) Successful in 39s
Docker images / netflow-agent-image (push) Successful in 31s
Docker images / backend-image (push) Successful in 1m37s
Docker images / publish-release (push) Successful in 13s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 4m3s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 19:27:30 +07:00
Denozordec 2852b11234 test(netflow): зафиксировать приёмку на двух маршрутизаторах
Docker images / frontend-image (push) Successful in 2m19s
Docker images / updater-image (push) Successful in 39s
Docker images / netflow-agent-image (push) Successful in 1m6s
Docker images / backend-image (push) Successful in 1m40s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m27s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 15:47:50 +07:00
Denozordec 0abdac82de fix(netflow): показывать текущие счётчики приёмника 2026-10-16 15:45:10 +07:00
Denozordec db81cf0f97 feat(netflow): автоматизировать настройку туннеля коллектора 2026-10-16 15:03:23 +07:00
Denozordec fcca92706b chore(docs): сохранить проверку защиты от переполнения очереди подключений 2026-10-16 09:58:16 +07:00
Denozordec 4881306f0b chore(docs): сохранить диагностику задержек подключения к серверу 2026-10-16 09:53:53 +07:00
Denozordec ef229ca9c4 chore(docs): сохранить результат отключения небезопасных служб 2026-10-16 01:56:22 +07:00
Denozordec 25ae76dc3f fix(api): уточнить причины отказа входа на маршрутизатор 2026-10-16 01:32:29 +07:00
Denozordec 552d5f831f chore(docs): зафиксировать приёмку обновления в рабочем окружении 2026-10-16 00:53:30 +07:00
Denozordec e6a2e4009d fix(routes): читать рекурсивные маршруты с незаданными свойствами
Docker images / frontend-image (push) Successful in 2m46s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 1m54s
Docker images / publish-release (push) Successful in 15s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 5m2s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 00:28:45 +07:00
Denozordec eb24a08981 fix(map): восстановить легенду и убрать лишние рамки фокуса 2026-10-15 23:36:19 +07:00
Denozordec 1c11a133f3 chore(docs): зафиксировать обновление карты в рабочем окружении 2026-10-15 23:24:54 +07:00
Denozordec 0f0d3aa060 fix(filters): восстановить связь рекурсивного шлюза после опроса 2026-10-15 23:17:42 +07:00
Denozordec 042407f03e fix(ci): подключить тесты к базе в сети задания
Docker images / frontend-image (push) Successful in 2m15s
Docker images / updater-image (push) Successful in 41s
Docker images / backend-image (push) Successful in 1m41s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 5m11s
Docker images / notify-webhook (push) Successful in 2s
2026-10-15 22:14:14 +07:00
Denozordec b2cf71fcc7 fix(db): индексировать историю для очистки планировщика
Docker images / backend-image (push) Skipped
Docker images / frontend-image (push) Successful in 2m39s
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Skipped
Docker images / prepare-release (push) Successful in 14s
Docker images / backend-test (push) Failing after 2m49s
Docker images / notify-webhook (push) Skipped
2026-10-15 22:04:38 +07:00
Denozordec fda7f353ad fix(map): сохранить узлы при датах опроса из базы данных 2026-10-15 21:51:43 +07:00
Denozordec 72bf648ca8 fix(ci): исправить условие уведомления для новой версии сборщика
Docker images / backend-image (push) Successful in 3m2s
Docker images / updater-image (push) Successful in 1m16s
Docker images / frontend-image (push) Successful in 3m7s
Docker images / publish-release (push) Successful in 11s
Docker images / prepare-release (push) Successful in 13s
Docker images / backend-test (push) Successful in 3m58s
Docker images / notify-webhook (push) Successful in 3s
2026-10-15 20:54:29 +07:00
Denozordec 8613749de1 chore(release)!: подготовить вторую основную версию
docker.yml (push) Invalid workflow file
2026-10-15 20:49:58 +07:00
Denozordec 6210f34740 fix(tests): завершить лабораторную приёмку и обновить фикстуру удаления 2026-10-15 20:30:07 +07:00
Denozordec 481688e72a fix(tests): согласовать полную регрессию с фокусом выбранного сервера 2026-10-15 20:19:07 +07:00
Denozordec c88d31fa45 chore(rest): подтвердить устойчивость повторных параллельных чтений 2026-10-15 14:39:29 +07:00
Denozordec 3d51bba86d fix(ui): сделать сетевые показатели и настройки читаемыми в обеих темах 2026-10-15 14:37:30 +07:00
Denozordec 3c9096804b fix(ui): исправить фокус списка серверов и контраст общих элементов 2026-10-15 14:15:56 +07:00
Denozordec 9b0d7bb6e8 chore(certificates): подтвердить реальное продление и полный откат 2026-10-15 13:47:24 +07:00
Denozordec 8c5cfa0706 fix(certificates): заменять сертификат уникальным поколением 2026-10-15 01:32:21 +07:00
Denozordec c683e1b2c1 fix(routeros): подтверждать импорт сертификата и ограничить REST-сессии 2026-10-15 00:28:23 +07:00
Denozordec 9178ff2ccb fix(certificates): продлевать по подтверждённому сроку и проверить реальный импорт 2026-10-15 00:05:03 +07:00
Denozordec 0e86628d58 fix(certificates): учитывать подтверждённый часовой пояс устройства 2026-10-14 21:50:11 +07:00
Denozordec e78bc2d7cb chore(test): зафиксировать выпуск сертификата и препятствие продлению 2026-10-14 21:43:57 +07:00
Denozordec 37a9c828d3 chore(test): подготовить проверку реального продления сертификата 2026-10-14 21:31:17 +07:00
Denozordec 3d23ae4be4 fix(certificates): подтверждать результат продления и исключить выключенные серверы 2026-10-14 21:01:22 +07:00
Denozordec 0563dd7cbd fix(ui): раскрыть последствия выпуска и исправить доступность мастеров 2026-10-14 20:43:16 +07:00
Denozordec 0374b57a30 chore(test): проверить реальный выпуск сертификата и восстановление стенда 2026-10-14 19:45:24 +07:00
Denozordec dc7b22940a fix(containers): исключить опрос выключенных серверов и ложные пустые данные 2026-10-14 19:25:14 +07:00
Denozordec 74151176d7 fix(containers): подтверждать остановку и удаление контейнера 2026-10-14 19:03:58 +07:00
Denozordec b3d11276dd fix(containers): учитывать фактические состояния контейнеров 2026-10-14 18:35:07 +07:00
Denozordec 0cc8fe5572 chore(test): подготовить пакет из указанного источника 2026-10-14 15:36:44 +07:00
Denozordec 61e6671685 ci(test): включить проверку загрузки баз в общий профиль 2026-10-14 15:33:30 +07:00
Denozordec 4f6de4b08e chore(test): подготовить пакет и процедуру проверки контейнеров 2026-10-14 15:19:16 +07:00
Denozordec 044002e7eb chore(test): уточнить предпосылки внешних задач и проверить релизы 2026-10-14 15:14:51 +07:00
Denozordec 48cc65ec2b fix(geoip): проверить локальный кеш и ограничить загрузку баз 2026-10-14 15:04:20 +07:00
Denozordec 3c3d152c2d chore(test): проверить большую историю со статусами файлов 2026-10-14 14:48:58 +07:00
Denozordec 026a358ca6 chore(test): проверить хранение последних копий и частичное удаление 2026-10-14 14:46:25 +07:00
Denozordec 4b028be241 chore(test): проверить ограниченное восстановление из хранилища через интерфейс 2026-10-14 14:41:26 +07:00
Denozordec 0216415953 chore(test): проверить REST за десятиминутным окном сессий 2026-10-14 14:31:21 +07:00
Denozordec 6617b776bb fix(backups): показать отсутствие файлов и защитить удаление записей 2026-10-14 14:30:33 +07:00
Denozordec e452e528e1 chore(test): сопоставить REST клиенты и проверить сессии RouterOS 2026-10-14 11:35:59 +07:00
Denozordec ab48973eb9 fix(deploy): сохранять локальные резервные копии при пересоздании контейнера 2026-10-14 11:21:48 +07:00
Denozordec 659e658f18 fix(backups): отклонять восстановление старых частичных экспортов 2026-10-14 11:16:29 +07:00
Denozordec faf452d149 fix(backups): защитить подключение S3 и согласовать операции каталога 2026-10-14 11:05:38 +07:00
Denozordec 7cef0cc133 chore(test): проверить сохранение S3 через реальный интерфейс 2026-10-14 10:46:18 +07:00
Denozordec 41a76f1163 fix(backups): читать экспорт RouterOS порциями без потери данных 2026-10-14 10:38:49 +07:00
Denozordec c3fb66dcbf fix(backups): получать нативный экспорт и проверить восстановление из S3 2026-10-14 10:31:14 +07:00
Denozordec 03b0896369 chore(test): проверить Selectel S3 и операции резервных копий 2026-10-14 10:01:35 +07:00
Denozordec 2277a76196 chore(test): подготовить безопасную проверку тестового S3 2026-10-14 09:27:09 +07:00
Denozordec cdb528276b fix(test): сохранять снимки GRE и VXLAN в результатах проверки 2026-10-14 09:22:32 +07:00
Denozordec af07c59ffa chore(test): подтвердить Android IPsec и очистить стенд 2026-10-14 09:06:25 +07:00
Denozordec baf81c0c7d chore(test): подготовить профиль Android для проверки IPsec 2026-10-14 08:56:06 +07:00
Denozordec 0d08c21bb9 chore(test): сопоставить диагностику REST двух тестовых стендов 2026-10-14 08:37:20 +07:00
Denozordec cbb4f9f0fc chore(test): сохранить короткое наблюдение REST тестовых CHR 2026-10-14 03:09:09 +07:00
Denozordec c21deba804 chore(docs): согласовать текущий план и остаток внешней приёмки 2026-10-14 03:00:59 +07:00
Denozordec 19ad294ded fix(netflow): запускать TypeScript worker с собственным загрузчиком 2026-10-14 02:57:24 +07:00
Denozordec 7a38bb36aa fix(gre): отклонять повреждённые и повторяющиеся native идентификаторы 2026-10-14 02:09:13 +07:00
Denozordec 51a579d370 fix(gre): не подменять неизвестные флаги состоянием туннеля 2026-10-14 01:39:35 +07:00
Denozordec 9c4a0992d7 fix(alerts): учитывать ошибки реального источника GRE 2026-10-14 01:16:19 +07:00
Denozordec 8b60ab040a fix(ui): повысить контраст заполненных таблиц BGP и OSPF 2026-10-14 01:05:45 +07:00
Denozordec d91cc77cda fix(api): проверять достоверность WAN и не опрашивать выключенные серверы 2026-10-14 00:47:29 +07:00
Denozordec 4f084222ad fix(api): сохранять ошибки наблюдения BGP для оповещений 2026-10-14 00:40:41 +07:00
Denozordec 5ac5a25dfc fix(api): показывать ошибки опроса старых OSPF и BFD API 2026-10-14 00:31:50 +07:00
Denozordec eaf724d6cc fix(ui): исправить контраст метки IPsec в таблице GRE 2026-10-14 00:19:54 +07:00
Denozordec 0c5d7d3207 fix(network): выбрать IPv6-команду в экспорте GRE 2026-10-13 22:01:46 +07:00
Denozordec 1be4a490b5 fix(network): согласовать GRE PSK с FastPath и экспортом 2026-10-13 21:42:21 +07:00
Denozordec 7d86af7e8b fix(auth): закрыть обход JWT через закодированные URL 2026-10-13 21:23:51 +07:00
Denozordec 727e35f985 fix(network): исправить keepalive GRE и каталог IPsec 2026-10-13 21:15:49 +07:00
Denozordec 3f3f06f147 fix(network): скрыть GRE PSK и сохранить признак IPsec 2026-10-13 20:45:27 +07:00
Denozordec a404da768d fix(network): защитить ключи WireGuard и режим просмотра операций 2026-10-13 20:12:33 +07:00
Denozordec c8f9c83f45 fix(ui): учитывать права и состояние устройства при откате истории 2026-10-13 19:45:39 +07:00
Denozordec 5ce20e8892 chore(tests): проверить клавиатуру и фокус живых панелей 2026-10-13 19:22:54 +07:00
Denozordec b9000b16e4 fix(ui): улучшить контраст трафика и предупреждений целей 2026-10-13 16:50:12 +07:00
Denozordec 6cc0094591 chore(tests): проверить живую навигацию разделов под двумя ролями 2026-10-13 16:37:55 +07:00
Denozordec 7a58f472b3 chore(tests): подтвердить маскирование заполненных секретов интеграций 2026-10-13 16:32:58 +07:00
Denozordec 3c30c5c7cb chore(tests): проверить секреты в ответах административного API 2026-10-13 16:28:10 +07:00
Denozordec bf39b3de18 chore(tests): подтвердить цельную доставку очереди Telegram 2026-10-13 16:24:54 +07:00
Denozordec cbe44e8072 fix(traffic): останавливать сбор при потере соединения блокировки 2026-10-13 16:20:46 +07:00
Denozordec 46de87e213 fix(auth): восстанавливать раздел и фильтры после входа Portal 2026-10-13 16:09:27 +07:00
Denozordec 199910eceb chore(tests): проверить срок JWT и границы выхода Portal 2026-10-13 15:59:20 +07:00
Denozordec f8d2b21cf6 chore(tests): проверить доставку Telegram и сохранить границы повтора 2026-10-13 15:53:06 +07:00
Denozordec 608f743003 fix(auth): скрыть пароли от читателя и согласовать Portal 2026-10-13 15:35:31 +07:00
Denozordec a15917d08b chore(tests): подключить тестовый Portal и подтвердить Telegram 2026-10-13 15:14:45 +07:00
Denozordec efdfc6f224 chore(tests): подтвердить общую регрессию интерфейса и backend 2026-10-13 14:40:20 +07:00
Denozordec b525e70655 fix(traffic): согласовать опрос между процессами backend 2026-10-13 14:35:00 +07:00
Denozordec a68bd61e58 fix(traffic): объединять чтения истории общего сервера 2026-10-13 14:15:44 +07:00
Denozordec 7b88416415 fix(traffic): считать общий пик по совпадающим интервалам 2026-10-13 13:58:56 +07:00
Denozordec dbc3b2c947 chore(docs): обновить дату и состояние матрицы приёмки 2026-10-13 02:17:31 +07:00
Denozordec a9f19e1f4b chore(docs): сохранить пример несовпадающих пиков привязок 2026-10-13 02:16:15 +07:00
Denozordec 7c667f8036 fix(traffic): учитывать пик только подтверждённых измерений 2026-10-13 02:14:03 +07:00
Denozordec 504f62fc0b fix(traffic): прекращать опрос изменённого источника 2026-10-13 02:08:26 +07:00
Denozordec 5d19b6a258 fix(traffic): учитывать состав каждой волны измерений 2026-10-13 01:52:50 +07:00
Denozordec a8b80d9914 chore(tests): проверить архивы с идентичностью счётчиков 2026-10-13 01:31:02 +07:00
Denozordec b57ae316ba fix(traffic): подтверждать идентичность соседних счётчиков 2026-10-13 01:19:22 +07:00
Denozordec 493c30bebc fix(traffic): ограничивать доверие скорости после пропусков 2026-10-13 00:54:42 +07:00
Denozordec 9603048bd8 chore(docs): зафиксировать границу доверия скорости после пропусков 2026-10-12 23:02:31 +07:00
Denozordec a40b26c3c5 fix(traffic): сохранять историю отключённых интерфейсов 2026-10-12 23:00:23 +07:00
Denozordec c69d99ba80 fix(traffic): показывать причину неполного опроса peers 2026-10-12 22:54:35 +07:00
Denozordec d8f8d3c7a4 fix(traffic): принимать измерения peers только полным срезом 2026-10-12 22:41:39 +07:00
Denozordec b4d0733cfa fix(traffic): подтверждать свежесть статусов привязанных интерфейсов 2026-10-12 22:35:17 +07:00
Denozordec 2b3d583e3b fix(traffic): сохранять точность объёмов и обозначать бинарные единицы 2026-10-12 21:59:50 +07:00
Denozordec 2358ecef60 fix(traffic): проверять live-срез и прекращать опрос выключенных устройств 2026-10-12 21:35:55 +07:00
Denozordec 1275dd7b9b fix(traffic): сохранять историю и показывать достоверную свежесть 2026-10-12 20:23:07 +07:00
Denozordec ebe62ae2ef fix(uptime): проверить RTT через SQL и обновить тестовый стенд 2026-10-12 18:21:50 +07:00
Denozordec ca2b634743 fix(uptime): точно разбирать составные длительности RTT 2026-10-12 18:17:10 +07:00
Denozordec 737e04a6a9 fix(traffic): проверить сбор трафика через HTTP и PostgreSQL 2026-10-12 18:13:48 +07:00
Denozordec 4527e5537c fix(traffic): исключить ложные нули из суммы трафика 2026-10-12 18:06:05 +07:00
Denozordec 8fb46b1552 fix(uptime): отличать ошибки опроса от измеренных потерь Ping 2026-10-12 17:55:16 +07:00
Denozordec ca23284486 fix(alerts): исключить неподтверждённые проблемы из журнала мониторинга 2026-10-12 16:45:26 +07:00
Denozordec d3f9a120e6 fix(alerts): объяснить отключённые и неоднозначные цели оповещений 2026-10-12 15:47:16 +07:00
Denozordec db7105119d fix(alerts): учитывать свежесть и настройки источников оповещений 2026-10-12 15:12:15 +07:00
Denozordec 8ffb8b5f00 fix(alerts): исключить неоднозначные и отключённые устройства из сигналов 2026-10-12 14:14:45 +07:00
Denozordec 65d4a17882 chore(ux): зафиксировать аудит свежести и контекста источников оповещений 2026-10-12 12:13:23 +07:00
Denozordec 13618e7efe fix(alerts): не подтверждать события устаревшими GRE BGP снимками 2026-10-12 12:03:11 +07:00
Denozordec 7ad66f178b chore(tests): проверить backend на PostgreSQL и убрать ожидание idle пула 2026-10-12 11:48:44 +07:00
Denozordec fdddc52019 fix(collectors): сохранять частичный результат и актуальные GRE BGP сигналы 2026-10-12 11:09:17 +07:00
Denozordec 9fbe27c1b0 fix(bgp): сохранять неизвестные показатели без вымышленных чисел 2026-10-12 10:38:05 +07:00
Denozordec c557a8c8cb fix(alerts): исключить переходы между неоднозначными BGP peers 2026-10-12 10:27:41 +07:00
Denozordec 22b0e8ae8e chore(ux): подтвердить четырёхбайтовые ASN в IPv6 BGP 2026-10-12 10:19:49 +07:00
Denozordec fef7bc5520 fix(bgp): сохранять неизвестное состояние и достоверную историю оповещений 2026-10-12 10:14:39 +07:00
Denozordec 8cb0fbd195 chore(ux): подтвердить снятие IPv6 BGP маршрутов при потере BFD 2026-10-12 10:01:24 +07:00
Denozordec cf7f4edf78 chore(ux): подтвердить потерю и восстановление BFD для OSPFv3 2026-10-12 09:54:08 +07:00
Denozordec e1617f1472 fix(ospf): сопоставлять IPv6 адреса и исключать неоднозначный BFD 2026-10-12 09:48:06 +07:00
Denozordec 1b63e3ec43 chore(ux): зафиксировать общую регрессию интерфейса и контекста 2026-10-12 09:38:52 +07:00
Denozordec d3d8afaa0c fix(api): сохранять большие идентификаторы серверов без округления 2026-10-11 23:39:02 +07:00
Denozordec 90e6c7be25 chore(ux): подтвердить сессии и маршруты BGP IPv6 2026-10-11 23:16:48 +07:00
Denozordec f6b9c6941d fix(ospf): показывать IPv6 маршруты и фактический шлюз 2026-10-11 23:05:19 +07:00
Denozordec 12ead0b6c2 fix(ospf): показать конфликт состояний OSPF и BFD 2026-10-11 22:42:14 +07:00
Denozordec 4d3cb659c1 fix(wireguard): поддержать IPv6 и исключить выключенные серверы 2026-10-11 22:08:10 +07:00
Denozordec f3cbc32720 fix(gre): сохранять и восстанавливать статические IPv6 адреса 2026-10-11 21:37:12 +07:00
Denozordec 655ea9727f fix(ui): сделать названия колонок потоков понятнее 2026-10-11 18:41:06 +07:00
Denozordec 555bd31596 fix(netflow): проверять месяц и точные календарные границы 2026-10-11 18:27:41 +07:00
Denozordec 0c14da190b fix(netflow): подтверждать выбранный срез перед запросами 2026-10-11 18:16:29 +07:00
Denozordec 64df472202 fix(netflow): проверять ответы аналитики и выбранный месяц 2026-10-11 17:58:18 +07:00
Denozordec 979dc3cfb0 fix(ui): уточнить подписи ICMPv6 и портов IPv6 2026-10-11 17:31:06 +07:00
Denozordec d569932b2a chore(ux): подтвердить настоящий IPv6 IPFIX и восстановление стенда 2026-10-11 17:11:32 +07:00
Denozordec 11813179a7 fix(netflow): сохранить IPv6 в смешанных шаблонах IPFIX 2026-10-11 16:44:00 +07:00
Denozordec c718654bc7 fix(netflow): сохранить неизвестные координаты RIPE без ложных нулей 2026-10-11 16:28:54 +07:00
Denozordec af66faaae4 fix(netflow): исправить классификацию IPv6 и кеш префиксов RIPE 2026-10-11 16:21:43 +07:00
Denozordec 59938118bd fix(traffic): дождаться каталога перед запросами выбранного сервера 2026-10-11 13:10:37 +07:00
Denozordec b619ef208e fix(netflow): исключить специальные IPv4 из внешнего обогащения 2026-10-11 12:59:57 +07:00
Denozordec f3156b44cf chore(test): завершить общую регрессию и сверить production outputs 2026-10-11 12:28:59 +07:00
Denozordec c242b611e2 chore(test): подтвердить цепочку промежуточного CA и повторный IKE 2026-10-11 12:08:39 +07:00
Denozordec c331558e31 chore(test): проверить внешний отзыв сертификата при установленном IKE 2026-10-11 12:02:59 +07:00
Denozordec 8af5c7a080 chore(test): подтвердить отказы IKE с повреждённым и истёкшим CRL 2026-10-11 11:55:44 +07:00
Denozordec 7c0cd50334 chore(test): подтвердить отказ IKE без внешнего CRL и восстановление 2026-10-11 11:51:26 +07:00
Denozordec 07499cd340 chore(test): проверить внешнюю загрузку CRL и отказ HTTP 2026-10-11 11:40:16 +07:00
Denozordec bd023b8aaa fix(terminal): показывать последние записи журнала RouterOS 2026-10-11 11:26:59 +07:00
Denozordec 1e4d3b4678 chore(test): подтвердить изоляцию диагностических буферов RouterOS 2026-10-11 11:15:11 +07:00
Denozordec 3b908a9e4e chore(test): подтвердить ECDSA IPsec и итоговую очистку стенда 2026-10-11 03:31:34 +07:00
Denozordec 63ee8a4549 chore(test): подтвердить сроки сертификатов в отдельных клиентах 2026-10-11 03:27:06 +07:00
Denozordec e17ddfdc20 chore(test): сохранить отказ положительного контроля сертификатов 2026-10-11 03:20:22 +07:00
Denozordec b190412af1 chore(test): зафиксировать тайм-ауты сертификатов с неверными сроками 2026-10-11 03:06:45 +07:00
Denozordec 7867914cbc fix(ipsec): проверять CRL-цепочку до удаления identity 2026-10-11 02:56:09 +07:00
Denozordec a31a65fbe6 chore(test): подтвердить отзыв сертификата при включённом CRL 2026-10-11 02:46:41 +07:00
Denozordec aed1963eef chore(test): зафиксировать поведение IPsec при отключённом CRL 2026-10-11 02:36:47 +07:00
Denozordec 07396d9385 fix(ipsec): пояснить границу онлайн-статуса IKE 2026-10-11 02:35:57 +07:00
Denozordec cad01853e1 chore(test): подтвердить границы аутентификации сертификатов IPsec 2026-10-11 02:24:53 +07:00
Denozordec 67644c50cb chore(test): проверить смену ключей и восстановление IPsec 2026-10-11 02:23:23 +07:00
Denozordec d4f5746325 fix(ipsec): учитывать уникальный DNS SAN в активных сессиях 2026-10-11 02:18:09 +07:00
Denozordec b4e56d1998 chore(test): проверить кодирование DN и FQDN в реальном IPsec 2026-10-11 02:11:34 +07:00
Denozordec d379045d20 chore(test): подтвердить две IKE-сессии одного сертификата 2026-10-10 23:14:37 +07:00
Denozordec 08fcebd549 fix(ipsec): экспортировать сертификат только по прямой привязке 2026-10-10 22:29:51 +07:00
Denozordec 9da5e55367 fix(tests): ждать окончания анимации кнопок резервных копий 2026-10-10 22:00:04 +07:00
Denozordec 057b530364 fix(ipsec): удалять сертификат только по явной привязке 2026-10-10 21:26:48 +07:00
Denozordec d52c48e5ed fix(ipsec): сохранять общий сертификат при удалении identity 2026-10-10 21:11:04 +07:00
Denozordec 58f5c48a97 fix(ipsec): показывать все привязки общего сертификата 2026-10-10 20:59:24 +07:00
Denozordec 60b2e099f0 fix(ipsec): учитывать несколько сессий без ложного офлайн 2026-10-10 20:42:58 +07:00
Denozordec cb6ec09312 fix(ipsec): показывать неопределённый статус при общих Remote ID 2026-10-10 10:33:54 +07:00
Denozordec 7815e00a1c fix(ipsec): исключить чужие сессии при сопоставлении CN 2026-10-10 09:56:21 +07:00
Denozordec 2aa93ad365 fix(ipsec): исправить статус клиента с полным DN сертификата 2026-10-10 09:42:57 +07:00
Denozordec 1d61b50591 chore(test): подтвердить IPv6 в GRE и WireGuard 2026-10-10 09:06:40 +07:00
Denozordec 2a804876b0 chore(test): проверить IPv6 внутри VXLAN на стенде 2026-10-10 09:02:31 +07:00
Denozordec e7aaec926d chore(test): подтвердить мост и обучение MAC в VXLAN 2026-10-10 08:59:20 +07:00
Denozordec a58756c421 chore(docs): зафиксировать готовность внешних интеграций 2026-10-10 03:49:57 +07:00
Denozordec 78b0c07b88 chore(docs): сохранить итоговую сверку стенда и плана 2026-10-10 03:45:04 +07:00
Denozordec beb72ceea1 chore(test): подтвердить таймер BGP без BFD 2026-10-10 03:34:04 +07:00
Denozordec 2d4e8baa13 chore(test): сверить потери Ping на GRE и public пути 2026-10-10 03:25:04 +07:00
Denozordec cd8613fab6 fix(api): отклонять некорректное подтверждение PATCH 2026-10-10 03:15:59 +07:00
Denozordec 89bde27f19 fix(ui): улучшить контраст и доступность прокрутки 2026-10-10 02:50:53 +07:00
Denozordec 94bdb2a8a1 chore(test): сохранить общую регрессию и её границы 2026-10-10 02:47:22 +07:00
Denozordec 7f0c2aad0f fix(api): завершать запрос при обрыве ответа RouterOS 2026-10-10 02:06:56 +07:00
Denozordec 5944e5e751 fix(uptime): объяснить неизвестный результат теста скорости 2026-10-10 01:41:15 +07:00
Denozordec dbe1e7c917 chore(test): проверить обрыв ответа сетевого теста 2026-10-10 01:32:21 +07:00
Denozordec 64bf0b4f6e chore(test): проверить время истории speed в разных поясах 2026-10-10 01:27:05 +07:00
Denozordec 890a5cba09 chore(test): сверить исходники и образ тестового стенда 2026-10-10 01:18:34 +07:00
Denozordec 9d6cd000ef chore(docs): свести оставшуюся внешнюю приёмку 2026-10-10 01:12:59 +07:00
Denozordec e23038d409 chore(test): подтвердить резервный путь BGP на стенде 2026-10-10 01:11:06 +07:00
Denozordec 40f730b5be chore(docs): сохранить итоговый контроль окна UX/UI 2026-10-09 22:45:42 +07:00
Denozordec f319eb947a chore(test): подтвердить обмен и отзыв собственного BGP-префикса 2026-10-09 22:40:30 +07:00
Denozordec c9bf5abe55 fix(ui): сохранить целый CIDR в таблице маршрутов OSPF 2026-10-09 22:26:08 +07:00
Denozordec ee704a86f2 chore(test): подтвердить резервный путь OSPF и сохранить наблюдения потерь 2026-10-09 21:59:46 +07:00
Denozordec 8b7bff165a fix(terminal): запретить выключенные устройства и исправить средний RTT 2026-10-09 21:35:25 +07:00
Denozordec 5b3133ce73 fix(routes): подтвердить применение и восстановление рекурсивных маршрутов 2026-10-09 21:08:10 +07:00
Denozordec 36c6a21ca5 fix(ospf): подтвердить права и результат оптимизации на RouterOS 2026-10-09 20:40:24 +07:00
Denozordec 3bfd4ae6cf chore(test): разделить наблюдения BFD и удаления маршрута OSPF 2026-10-09 20:12:31 +07:00
Denozordec 2e1586e4d4 chore(test): подтвердить BFD и восстановление OSPF на стенде 2026-10-09 18:00:45 +07:00
Denozordec c35272273b chore(test): подтвердить OSPF через GRE на реальном стенде 2026-10-09 17:41:08 +07:00
Denozordec 8795969871 fix(routing): исключить опрос выключенных устройств BGP и OSPF 2026-10-09 17:22:41 +07:00
Denozordec 89de7e134b fix(ipsec): обеспечить NAT и изоляцию политик для передачи данных 2026-10-09 17:10:33 +07:00
Denozordec 43b00e0740 fix(ipsec): различать частичный и неподтверждённый результат операций 2026-10-09 16:06:33 +07:00
Denozordec a83e4734df fix(ipsec): восстановить mode-config с проверкой зависимостей 2026-10-09 15:49:50 +07:00
Denozordec 5d9ef9dcf3 fix(ipsec): подтвердить сертификатный клиент и сообщать сбой истории 2026-10-09 15:32:10 +07:00
Denozordec b9a8615b8b fix(ipsec): показывать подключённых клиентов и адрес mode-config 2026-10-09 15:14:41 +07:00
Denozordec f80d9b7637 fix(ipsec): подтвердить достоверность операций и приёмку раздела 2026-10-09 12:46:22 +07:00
Denozordec e2d81f9657 fix(vxlan): подтвердить достоверность опроса и экспорта раздела 2026-10-09 10:38:24 +07:00
Denozordec 6b068ac39a chore(test): проверить VXLAN на двух реальных CHR 2026-10-09 09:44:54 +07:00
Denozordec 0067c2eeff chore(test): проверить отказ записи ревизии на стенде 2026-10-09 09:36:06 +07:00
Denozordec 8eaf78b686 fix(wireguard): учитывать неподтверждённую ревизию импорта 2026-10-09 09:21:02 +07:00
Denozordec 91ee424b42 chore(test): подтвердить компенсацию импорта на двух CHR 2026-10-09 09:15:48 +07:00
Denozordec e01454b720 fix(wireguard): очищать подтверждённые объекты при отказе импорта 2026-10-09 09:04:05 +07:00
Denozordec f9980d7d7e chore(test): подтвердить безопасный импорт conf на стенде 2026-10-09 08:53:59 +07:00
Denozordec 479f2adc78 fix(wireguard): проверять конфигурацию до импорта 2026-10-09 08:39:36 +07:00
Denozordec 7ce43adc07 chore(test): подтвердить импорт WireGuard на двух CHR 2026-10-09 08:31:09 +07:00
Denozordec d13c773856 chore(test): подтвердить экспорт шаблона WireGuard на стенде 2026-10-09 08:19:26 +07:00
Denozordec c16fc0c5b5 fix(ci): вынести межслойный тест из frontend Docker контекста 2026-10-08 23:28:37 +07:00
Denozordec c82e8fa12b fix(wireguard): исправить endpoint и поля клиентского шаблона 2026-10-08 23:21:45 +07:00
Denozordec 206255b856 chore(test): проверить экспорт WireGuard без приватных ключей 2026-10-08 23:12:43 +07:00
Denozordec a1183f2ec9 chore(test): подтвердить мобильные панели WireGuard на стенде 2026-10-08 23:03:00 +07:00
Denozordec bf43263e43 fix(wireguard): расширить мобильные панели и сохранить видимость кнопок 2026-10-08 22:50:50 +07:00
Denozordec ae0162db53 chore(test): подтвердить полный сценарий WireGuard через интерфейс 2026-10-08 22:45:16 +07:00
Denozordec 6a46fb1e40 fix(wireguard): сохранять меню действий при перерисовке 2026-10-08 22:32:57 +07:00
Denozordec 4c4ba02b44 chore(test): развернуть и подтвердить полное удаление WireGuard 2026-10-08 22:12:33 +07:00
Denozordec 3722fad329 fix(wireguard): удалять связанные пиры и IP вместе с интерфейсом 2026-10-08 21:59:53 +07:00
Denozordec fe0ca3fa46 chore(test): проверить реальную связность WireGuard и интерфейс 2026-10-08 21:48:30 +07:00
Denozordec 02cdfd869a chore(test): развернуть и проверить мобильную форму GRE 2026-10-08 21:36:36 +07:00
Denozordec fd521f6ef8 fix(gre): расширить поля внутренних IP на телефоне 2026-10-08 21:24:44 +07:00
Denozordec ed80aef195 chore(test): развернуть и проверить устойчивость меню GRE 2026-10-08 21:16:37 +07:00
Denozordec 731803bade fix(gre): сохранять меню действий при обновлении таблицы 2026-10-08 16:27:17 +07:00
Denozordec 26e8225815 chore(test): развернуть и проверить сохранение GRE peer IP 2026-10-08 16:18:56 +07:00
Denozordec bd3f996f55 fix(gre): сохранять удалённый внутренний IP туннеля 2026-10-08 15:50:11 +07:00
Denozordec 56f29a60d1 chore(test): проверить реальную GRE пару и очистку туннелей 2026-10-08 15:36:55 +07:00
Denozordec 37bc356ff7 chore(test): зафиксировать сетевую конфигурацию тестовых CHR 2026-10-08 15:14:51 +07:00
Denozordec 2ead73c30a chore(test): свести результаты и ограничения приёмки очереди 2026-10-08 15:07:20 +07:00
Denozordec edc459382b chore(test): проверить доступность очереди на production frontend 2026-10-08 15:04:27 +07:00
Denozordec a4ae10d7f5 chore(test): проверить изоляцию повтора при смене источника 2026-10-08 14:56:39 +07:00
Denozordec bb7b940bfc chore(test): подтвердить права очереди в браузере с JWT 2026-10-08 14:47:49 +07:00
Denozordec 1d6f2d598c chore(test): развернуть и проверить пояснение повтора на стенде 2026-10-08 14:41:40 +07:00
Denozordec b55588f29b fix(ui): объяснить неподтверждённый результат повтора сообщения 2026-10-08 14:30:54 +07:00
Denozordec e80cfeb4a9 chore(test): проверить браузерный повтор при потере подтверждения 2026-10-08 14:28:14 +07:00
Denozordec ca8eafa1ff chore(test): подтвердить права очереди на настоящих JWT 2026-10-08 14:22:03 +07:00
Denozordec 22a4e2f36e chore(test): проверить очередь в браузере на реальной базе 2026-10-08 14:18:28 +07:00
Denozordec 693cce2535 chore(test): проверить задержку и исчерпание попыток отправки 2026-10-08 14:12:40 +07:00
Denozordec ffe44dc938 chore(test): проверить удержание отправок с неизвестным исходом 2026-10-08 14:09:17 +07:00
Denozordec 507c7010ad chore(test): подтвердить обработку ошибок сохранения отправки 2026-10-08 14:06:23 +07:00
Denozordec d44511da8e chore(test): подтвердить конкурентный захват очереди на PostgreSQL 2026-10-08 14:00:45 +07:00
Denozordec 7c4b21be19 chore(test): подтвердить откат некорректного receipt очереди 2026-10-08 10:58:39 +07:00
Denozordec 87fc1be657 chore(test): проверить конкурентное разрешение unknown в PostgreSQL 2026-10-08 10:55:55 +07:00
Denozordec 06d73c50cc chore(test): проверить конкурентную дедупликацию очереди в PostgreSQL 2026-10-08 10:53:03 +07:00
Denozordec 49af17178e chore(test): подтвердить UDP-тест и историю в реальном интерфейсе 2026-10-08 10:49:26 +07:00
Denozordec 800c6f69ae chore(test): проверить receive и both после восстановления CHR2 2026-10-08 10:46:12 +07:00
Denozordec e1457fbc77 chore(test): подтвердить восстановленный доступ CHR2 и дашборд 2026-10-08 10:43:06 +07:00
Denozordec 8a1c36f358 chore(test): сопоставить CLI и REST при отказе доступа CHR2 2026-10-08 10:29:34 +07:00
Denozordec 34096cbfd5 chore(test): сохранить диагностику направленного bandwidth-тайм-аута 2026-10-08 10:19:25 +07:00
Denozordec a178becdcd chore(test): подтвердить новую форму speed и обновить frontend на стенде 2026-10-08 10:09:35 +07:00
Denozordec f194e7269b fix(ui): уточнить интерфейсы speed и исправить CLI-подсказку 2026-10-08 10:00:42 +07:00
Denozordec 47f747586c chore(test): подтвердить исправление proxy timeout на production-стенде 2026-10-08 09:46:25 +07:00
Denozordec aa7dbdf608 fix(web): дождаться ответа backend при длительной speed-команде 2026-10-08 09:38:17 +07:00
Denozordec fc1a60d413 chore(test): проверить отказ bandwidth-аутентификации на реальном CHR 2026-10-08 09:30:52 +07:00
Denozordec c0a32e8ed2 chore(test): подтвердить смену speed-контекста вторым клиентом в браузере 2026-10-08 09:24:45 +07:00
Denozordec 01061b4406 chore(test): проверить конфликты speed и блокировки PostgreSQL на стенде 2026-10-08 09:19:43 +07:00
Denozordec 9ccfc7f2b6 chore(test): подтвердить новые образы и неопределённый исход speed на стенде 2026-10-08 09:10:50 +07:00
Denozordec 8782054078 fix(ui): показывать неопределённый исход speed и сверять историю 2026-10-08 08:57:54 +07:00
Denozordec 96fa910804 fix(ui): сохранить snapshot speed при неизвестном исходе записи 2026-10-08 08:48:58 +07:00
Denozordec f408afba20 fix(api): сохранить неизвестный исход записи speed без ложной ошибки 2026-10-08 00:21:31 +07:00
Denozordec c337fddac9 chore(test): проверить права и восстановление базы в браузере 2026-10-08 00:03:13 +07:00
Denozordec 1adb82c9a2 chore(test): проверить остановку записей и обновить backend стенда 2026-10-07 23:50:18 +07:00
Denozordec 4c12c55c9d fix(api): исключить выключенные серверы из мониторинга 2026-10-07 23:37:06 +07:00
Denozordec f3e205950f fix(api): восстановить разделы PostgreSQL в одной транзакции 2026-10-07 23:28:16 +07:00
Denozordec c24ad7810c chore(test): выявить ошибку восстановления разделов PostgreSQL 2026-10-07 23:20:12 +07:00
Denozordec f1aac49dfc fix(api): сбросить кэши после восстановления базы 2026-10-07 23:12:32 +07:00
Denozordec 2943032462 fix(api): дождаться фоновых записей перед операцией с БД 2026-10-07 23:07:10 +07:00
Denozordec 8682f2a7a5 fix(api): дождаться запросов перед операцией с базой 2026-10-07 23:00:39 +07:00
Denozordec 9264b6636a chore(test): проверить настоящий UDP collector и завершение записи 2026-10-07 22:55:33 +07:00
Denozordec f8da3e08a0 fix(api): подтвердить остановку NetFlow перед операцией с БД 2026-10-07 22:48:19 +07:00
Denozordec 1b89d56ea3 fix(api): дождаться заданий планировщика перед операцией с БД 2026-10-07 22:41:50 +07:00
Denozordec 6720ad3539 chore(test): проверить архив PostgreSQL, права и конфликт операций 2026-10-07 22:36:24 +07:00
Denozordec 66bed28382 chore(test): сохранить реальные тесты скорости на паре CHR 2026-10-07 22:24:59 +07:00
Denozordec 476dbec204 chore(test): зафиксировать подключение второго CHR 2026-10-07 22:10:24 +07:00
Denozordec 83593756ce chore(test): зафиксировать подключение тестового CHR 2026-10-07 21:59:35 +07:00
Denozordec 65d04baf2f chore(deploy): зафиксировать проверенное развёртывание тестового стенда 2026-10-07 21:39:59 +07:00
Denozordec 673e5ba444 fix(api): нормализовать дату сохранённого снимка Internet path 2026-10-07 21:26:23 +07:00
Denozordec 4d88b97d49 chore(deploy): подготовить отдельный тестовый Docker-стенд 2026-10-07 21:04:28 +07:00
Denozordec 6255f44975 chore(ux): сохранить итоговую проверку контекста и точку внешней приёмки 2026-10-07 18:42:00 +07:00
Denozordec 683148d620 chore(ux): сохранить общую регрессию после исправлений скорости 2026-10-07 18:32:17 +07:00
Denozordec 9680a6422b fix(ui): отличать неизвестный RTT от отсутствия ответов Ping 2026-10-07 17:58:11 +07:00
Denozordec 30cc99c68e fix(ui): использовать серверное время и не подменять отсутствующий Ping 2026-10-07 17:52:33 +07:00
Denozordec 7f18fcb6db fix(ui): сбрасывать снимок при изменении параметров speed-пробы 2026-10-07 17:41:14 +07:00
Denozordec 090206c893 fix(api): сохранять результат только в пробу с теми же параметрами 2026-10-07 17:33:48 +07:00
Denozordec b59944b573 fix(api): проверять параметры запуска теста скорости до операции 2026-10-07 17:30:52 +07:00
Denozordec b0e114b56d fix(docs): указать даты выполнения в заголовках этапов 2026-10-07 17:10:51 +07:00
Denozordec a56f555c25 fix(docs): восстановить недостающие записи журнала UX/UI 2026-10-07 17:09:26 +07:00
Denozordec 65c63e2860 fix(ui): убрать неиспользуемый код прежней таблицы мониторинга 2026-10-07 15:57:53 +07:00
Denozordec e4f951175c fix(uptime): сбросить снимок при смене пары и сохранить историю 2026-10-07 15:52:58 +07:00
Denozordec 8627459bfa chore(docs): зафиксировать общую проверку 221 контекста 2026-10-07 15:25:46 +07:00
Denozordec a3fcba0d43 fix(uptime): защитить пару устройств и атомарно сохранить результат 2026-10-07 15:12:41 +07:00
Denozordec 08ea416989 chore(docs): зафиксировать общую регрессию 1176 сценариев 2026-10-07 15:03:54 +07:00
Denozordec 5fedf7ae68 chore(docs): подготовить протокол внешней приёмки UX/UI 2026-10-07 14:37:13 +07:00
Denozordec 579e816bbb fix(ui): показать измерения рядом с закреплённым WAN на телефоне 2026-10-07 14:28:07 +07:00
Denozordec 5919af7f9b fix(ospf): сопоставить направления измерений и модели 2026-10-07 14:19:13 +07:00
Denozordec fb32f87d33 fix(uptime): проверять завершение теста перед записью успеха 2026-10-07 14:17:36 +07:00
Denozordec 9d5959601f fix(uptime): сохранить неизвестную скорость и проверить подтверждение теста 2026-10-07 14:15:56 +07:00
Denozordec 1904c86657 fix(ui): пояснить область скоростных снимков WAN 2026-10-07 13:59:20 +07:00
Denozordec 4ba7f82ccf fix(optimizer): правильно сопоставить направления DL и UL 2026-10-07 13:44:36 +07:00
Denozordec 7d331aff41 chore(ux): зафиксировать аудит скоростных измерений 2026-10-07 13:26:05 +07:00
Denozordec 4fee9e371f fix(optimizer): убрать лишние поиски и расчёты маршрутов 2026-10-07 13:16:12 +07:00
Denozordec 057f0d352a chore(ux): измерить генерацию всех комбинаций маршрутов 2026-10-07 12:48:22 +07:00
Denozordec 53e36f2a03 fix(ui): сделать сводки площадок краткими и достоверными 2026-10-07 12:42:20 +07:00
Denozordec 52d8a05480 fix(optimizer): ускорить точный выбор проб из большого пула 2026-10-07 12:24:07 +07:00
Denozordec c6dfa5267b chore(ux): измерить большой пул проб оптимизатора 2026-10-04 23:47:35 +07:00
Denozordec 8e3b7a65ce fix(ui): сократить сводку большого списка WAN 2026-10-04 23:43:57 +07:00
Denozordec 0355343f9f chore(ux): разделить исторические отчёты контекстных проверок 2026-10-04 23:39:18 +07:00
Denozordec ba27d2ade0 chore(ux): сохранить полную контекстную регрессию 2026-10-04 23:36:52 +07:00
Denozordec b1f8326304 fix(ui): возвращать фокус терминала после включения поля 2026-10-04 23:19:14 +07:00
Denozordec 55df77296a chore(ux): сохранить общую регрессию и сбой фокуса терминала 2026-10-04 23:12:35 +07:00
Denozordec 991f2b3871 fix(ui): учитывать весь каталог Community в оптимизаторе 2026-10-04 22:15:00 +07:00
Denozordec 53603f17c1 chore(ux): зафиксировать скрытый срез Community 2026-10-04 22:09:40 +07:00
Denozordec 833225d169 fix(optimizer): отсечь заведомо худшие назначения проб 2026-10-04 22:08:32 +07:00
Denozordec 1ad6b4a6b6 fix(ui): ограничить отображение матрицы WAN и Jump Host 2026-10-04 21:58:57 +07:00
Denozordec b3da4216e6 chore(ux): измерить матрицу WAN и расчёт назначения проб 2026-10-04 21:52:05 +07:00
Denozordec 28e2d38ee6 chore(ui): уточнить риски матрицы и расчёта оптимизатора 2026-10-04 18:15:22 +07:00
Denozordec a16bfae805 fix(ui): ограничить полные маршруты с сохранением индексов 2026-10-04 18:13:45 +07:00
Denozordec 82f620ac0b chore(ui): зафиксировать профиль полной таблицы маршрутов 2026-10-04 18:05:56 +07:00
Denozordec 059fd93520 fix(ui): ограничить карточки оптимизатора с сохранением URL 2026-10-04 18:03:59 +07:00
Denozordec 7265709b1c chore(ui): зафиксировать профиль тысячи карточек оптимизатора 2026-10-04 17:53:24 +07:00
Denozordec be54666661 fix(ui): ограничить preview OSPF без изменения применения 2026-10-04 17:50:45 +07:00
Denozordec 9f74cfebfe fix(ui): объединить параллельные связи OSPF в читаемый граф 2026-10-04 17:37:42 +07:00
Denozordec 086f118204 fix(ui): ограничить списки деталей OSPF без потери данных 2026-10-04 17:32:01 +07:00
Denozordec 8d86c5cbb0 fix(ui): ограничить граф OSPF с сохранением полного контекста 2026-10-04 17:25:55 +07:00
Denozordec 1a82c1b41f fix(ui): ограничить таблицу соседей OSPF и сохранить URL узла 2026-10-04 17:09:16 +07:00
Denozordec 2554c3572a chore(ui): зафиксировать профиль большой топологии OSPF 2026-10-04 16:58:53 +07:00
Denozordec ab56225a1a fix(ui): сохранить контекст и фокус форм Firewall без эффектов 2026-10-04 16:56:15 +07:00
Denozordec 21dc646553 fix(ui): ограничить трассировку и защитить повторную симуляцию 2026-10-04 16:45:39 +07:00
Denozordec 9231597d88 fix(ui): ограничить редактор сценария без потери правил 2026-10-04 16:38:19 +07:00
Denozordec 2e648dcf1c fix(ui): блокировать операции Firewall после ошибки чтения 2026-10-04 16:29:31 +07:00
Denozordec 1e9889f892 fix(ui): ограничить таблицу Firewall и сохранить порядок правил 2026-10-04 16:23:29 +07:00
Denozordec 4a5ae8baa1 fix(ui): ограничить список Filters с сохранением порядка 2026-10-04 16:12:22 +07:00
Denozordec 06850169ba fix(ui): ограничить форму маршрута без потери конечных точек 2026-10-04 16:01:41 +07:00
Denozordec 9124d451a5 fix(ui): ограничить группы и детали рекурсивных маршрутов 2026-10-04 15:52:07 +07:00
Denozordec f515818254 fix(ui): подтверждать копирование community после записи 2026-10-04 15:45:56 +07:00
Denozordec adfb2fc4c3 fix(ui): ограничить отображение каталога Communities 2026-10-04 15:41:21 +07:00
Denozordec 9d4d9f308f fix(ui): пояснить выборку сессий и исправить перенос вкладок 2026-10-04 13:16:17 +07:00
Denozordec 9bbecfbafe chore(ui): уточнить карту производительности оставшихся таблиц 2026-10-04 13:06:18 +07:00
Denozordec 97936aa81e fix(ui): ограничить отображение каталога GRE-туннелей 2026-10-04 13:03:27 +07:00
Denozordec dcc8550482 fix(ui): ограничить отображение каталога VXLAN-туннелей 2026-10-04 12:57:34 +07:00
Denozordec c7aec1500c fix(ui): защищать чтение WireGuard при смене источника 2026-10-04 12:48:24 +07:00
Denozordec e51964b4d2 fix(ui): восстанавливать фильтры WireGuard из ссылки 2026-10-04 12:30:43 +07:00
Denozordec c6702efcc4 fix(ui): показывать сведения о рукопожатии без ложного статуса 2026-10-04 12:21:53 +07:00
Denozordec 837298a171 fix(ui): ограничить раскрытие большого списка пиров WireGuard 2026-10-04 12:16:34 +07:00
Denozordec 747f544462 fix(ui): ограничить отображение каталога пиров WireGuard 2026-10-04 12:10:38 +07:00
Denozordec a6be18c1d6 fix(ui): ограничить отображение каталога интерфейсов WireGuard 2026-10-04 12:05:08 +07:00
Denozordec 7693b32372 fix(ui): различать нулевые и неизвестные счётчики WireGuard 2026-10-04 11:58:10 +07:00
Denozordec 53c1bfbd3b fix(api): сохранять каталог пинг-проб атомарно 2026-10-04 11:53:48 +07:00
Denozordec 9c22fa8a68 chore(ux): зафиксировать общую регрессию последних изменений 2026-10-04 11:28:54 +07:00
Denozordec 21859be580 chore(ux): зафиксировать повтор контекстной регрессии 2026-10-04 11:00:27 +07:00
Denozordec 5b2a2917c1 fix(ui): ограничить отображение каталога BFD-сессий 2026-10-04 10:51:59 +07:00
Denozordec 586da55c24 fix(ui): ограничить отображение каталога OSPF-маршрутов 2026-10-04 10:48:11 +07:00
Denozordec 908974302e fix(api): сохранять каталог speed-проб атомарно 2026-10-04 10:41:03 +07:00
Denozordec d4f2961f76 fix(api): проверить каталог speed-проб и изолировать источник 2026-10-04 10:31:02 +07:00
Denozordec f30319ac55 chore(test): сохранить проверку контекста пользователей и бэкапов 2026-10-04 05:22:24 +07:00
Denozordec a558412a27 fix(api): сохранить неизвестные значения истории скорости 2026-10-04 05:19:34 +07:00
Denozordec e5ff69e243 fix(ui): сохранить историю скорости при частичном сбое 2026-10-04 05:09:20 +07:00
Denozordec 611e542ea7 fix(ui): ограничить каталог бэкапов и вернуть фокус действий 2026-10-04 05:01:29 +07:00
Denozordec 5d9a6f3757 fix(ui): ограничить каталог пользователей и добавить поиск 2026-10-04 04:48:06 +07:00
Denozordec e0f83bdf42 fix(ui): сохранить имя строки при прокрутке таблицы 2026-10-04 04:38:23 +07:00
Denozordec 5fbfc752fb fix(ui): ограничить каталог сертификатов и сохранить детали 2026-10-04 04:29:30 +07:00
Denozordec 5488d86b12 chore(test): проверить отказ большого каталога проб 2026-10-04 04:22:25 +07:00
Denozordec e5cf05759e fix(ui): ограничить строки истории тестов скорости 2026-10-04 04:19:56 +07:00
Denozordec 488f2fe482 fix(ui): открыть крайние колонки сводной статистики 2026-10-04 04:11:53 +07:00
Denozordec 8d6d82082c chore(test): сохранить общую регрессию смены контекста 2026-10-04 04:04:12 +07:00
Denozordec 91c1194550 fix(test): актуализировать данные общей UX регрессии 2026-10-04 03:55:30 +07:00
Denozordec c4110fa323 fix(ui): сохранять ручную страницу выбранной сессии BGP 2026-10-04 03:27:41 +07:00
Denozordec 1cce712833 fix(ui): ограничить боковой каталог устройств 2026-10-04 03:22:39 +07:00
Denozordec 2bdcc1621f fix(ui): очищать недоступные интерфейсы при загрузке 2026-10-04 03:06:47 +07:00
Denozordec ab334764cf fix(ui): раскрывать предупреждения ресурсов порциями 2026-10-04 03:00:32 +07:00
Denozordec 2762f073a0 fix(ui): ограничить строки ресурсов мониторинга 2026-10-04 02:54:33 +07:00
Denozordec 76a5e81fd4 fix(ui): ограничить строки таблицы проб скорости 2026-10-04 02:44:52 +07:00
Denozordec 057281c246 fix(test): проверить большой каталог проб скорости 2026-10-04 00:56:57 +07:00
Denozordec ef457b471a fix(ui): сохранять страницу компактной таблицы при опросе 2026-10-04 00:53:28 +07:00
Denozordec 160b39186a fix(api): проверять обязательные поля снимка RouterOS 2026-10-04 00:50:42 +07:00
Denozordec b658f7127f fix(test): обновить данные сквозных проверок дашборда 2026-10-04 00:45:30 +07:00
Denozordec 39789765cb fix(ui): ограничить каталог BGP и сохранить страницу при обновлении 2026-10-04 00:38:44 +07:00
Denozordec 604b2b0b51 fix(ui): раскрывать большую очередь проблем порциями 2026-10-04 00:33:25 +07:00
Denozordec 5e5cf70980 fix(ui): ограничить отрисовку больших компактных таблиц 2026-10-04 00:30:09 +07:00
Denozordec 12c376b4c5 fix(ui): ограничить отрисовку большого каталога серверов 2026-10-04 00:24:33 +07:00
Denozordec c3a9c00aed fix(ui): показывать подтверждённые метрики участков пути 2026-10-04 00:18:21 +07:00
Denozordec d4ae1c3127 fix(api): подтверждать полный сбор пути без скрытых ошибок 2026-10-04 00:11:17 +07:00
Denozordec 5fdd6ef58c fix(ui): сохранять подтверждённый снимок пути при ошибке API 2026-10-04 00:07:21 +07:00
Denozordec 55c9349677 fix(ui): учитывать ошибки устройств в сводке туннелей 2026-10-03 23:51:16 +07:00
Denozordec 9d3129372b fix(uptime): показывать реальные наблюдения и пропуски RTT на дашборде 2026-10-03 23:42:45 +07:00
Denozordec 8f65d1620d fix(ui): проверять каталоги дашборда и сохранять неизвестный статус серверов 2026-10-03 23:20:37 +07:00
Denozordec 9e0f695684 fix(traffic): отличать измеренный ноль от отсутствия наблюдений 2026-10-03 23:10:39 +07:00
Denozordec a28f871fd4 fix(ui): проверять данные трафика без подстановки нулей в серии 2026-10-03 23:00:02 +07:00
Denozordec f0eaa94e02 fix(ui): сохранить неизвестные данные и ошибки BGP на дашборде 2026-10-03 22:55:15 +07:00
Denozordec c343ce62c8 fix(ui): исправить доступность оболочки и проверку состояния сервера 2026-10-03 22:49:52 +07:00
Denozordec 1a9966ec95 fix(ui): завершить локальную приёмку администрирования 2026-10-03 22:32:53 +07:00
Denozordec 271291a910 fix(probes): проверять расписание и сохранять подтверждённые данные 2026-10-03 22:12:08 +07:00
Denozordec 1d27127b52 fix(releases): проверять манифест и достоверность сведений о сборке 2026-10-03 21:59:55 +07:00
Denozordec d5edfe5ef6 fix(ui): повысить приоритет правил и исправить мобильные строки 2026-10-03 21:49:05 +07:00
Denozordec c7e1f75e2e fix(alerts): обозначить недоступность событий префиксов 2026-10-03 19:55:31 +07:00
Denozordec f3ec6b31ce fix(alerts): сделать выбор объектов доступным с клавиатуры 2026-10-03 19:50:31 +07:00
Denozordec 1deccf862c fix(ui): не подменять неполные сетевые счётчики нулями 2026-10-03 19:44:13 +07:00
Denozordec 587dd94f82 fix(ui): проверять права каждого источника дашборда 2026-10-03 19:38:30 +07:00
Denozordec 1999e8160e fix(ui): проверять права и сохранять достоверность общей сводки 2026-10-03 19:33:10 +07:00
Denozordec b109532f33 fix(alerts): подтвердить риск ручного повтора сообщения 2026-10-03 19:21:19 +07:00
Denozordec 7d0608bbce fix(alerts): защитить ручной повтор очереди проверкой версии 2026-10-03 19:14:27 +07:00
Denozordec 89c31289bd fix(alerts): показать подтверждённое состояние очереди сообщений 2026-10-03 19:09:42 +07:00
Denozordec b1ad9515fa fix(alerts): удерживать неизвестные отправки без автоматического повтора 2026-10-03 18:56:08 +07:00
Denozordec 46be201d10 fix(alerts): различать отказ и неизвестный результат Telegram 2026-10-03 18:48:22 +07:00
Denozordec 382e318b97 fix(alerts): отклонять запись устаревшего снимка правил 2026-10-03 18:41:48 +07:00
Denozordec c5c1c2812d fix(alerts): читать согласованный снимок и реальные цели проб 2026-10-03 18:27:22 +07:00
Denozordec dc5594ca7c fix(alerts): защитить тест правила общим замком Telegram 2026-10-03 18:20:29 +07:00
Denozordec 9c1d14635a fix(alerts): подтверждать запись правил и сохранять формы при сбое 2026-10-03 18:13:57 +07:00
Denozordec 875819c577 fix(alerts): сохранить черновик Telegram и подтвердить операции 2026-10-03 18:00:01 +07:00
Denozordec a5c8e614bd fix(test): учесть общий опрос в проверке прав каталогов 2026-10-03 17:51:49 +07:00
Denozordec 526f305aa6 fix(alerts): разделить загрузку каталогов целей и правил 2026-10-03 17:50:23 +07:00
Denozordec a8a3eb59fb fix(alerts): проверять каталоги объектов и сохранять частичные сбои 2026-10-03 17:34:33 +07:00
Denozordec 3710fc6aab fix(alerts): отличать неизвестные данные от пустого списка правил 2026-10-03 17:30:46 +07:00
Denozordec 9bc3a5db77 fix(alerts): подтверждать конфигурацию внутри транзакции сохранения 2026-10-03 17:22:41 +07:00
Denozordec 6093442bcf fix(alerts): проверять правила и группы до замены конфигурации 2026-10-03 17:16:21 +07:00
Denozordec 3556a378f2 fix(alerts): проверять доступ и использовать единый источник тестового сообщения 2026-10-03 17:11:39 +07:00
Denozordec 98319c9d50 fix(alerts): сохранять параметры выборочно и проверять результат отправки 2026-10-03 17:06:44 +07:00
Denozordec 0bcac655ea fix(alerts): проверять ответы и подтверждать сохранённые параметры 2026-10-03 17:00:53 +07:00
Denozordec 596fea4814 fix(settings): проверить контекст и завершить локальную приёмку базы 2026-10-03 16:55:41 +07:00
Denozordec 57293479d1 fix(settings): защищать восстановление базы от повторов и смены контекста 2026-10-03 16:52:02 +07:00
Denozordec 92b65a30ce fix(settings): сохранять результат операций базы при вторичных сбоях 2026-10-03 16:43:52 +07:00
Denozordec 738c98b9d3 chore(docs): закрепить актуальную точку продолжения UX/UI плана 2026-10-03 16:39:06 +07:00
Denozordec af5dbb0519 fix(settings): восстанавливать базу транзакционно и сохранять результат операции 2026-10-03 14:45:51 +07:00
Denozordec ff8b04bddf fix(settings): проверять резервные копии и исключить секреты из журнала 2026-10-03 14:36:12 +07:00
Denozordec e5842b415b fix(settings): проверять доступность текущего источника без гонок 2026-10-03 14:30:41 +07:00
Denozordec b6f1b72500 fix(settings): убрать фиктивные операции и подключить реальные параметры 2026-10-03 14:21:17 +07:00
Denozordec 2b6d3ab2e0 fix(evolution): проверить серверный каталог и завершить локальную приёмку 2026-10-03 14:12:06 +07:00
Denozordec d272ef57f0 fix(evolution): исключить моковые данные из живого каталога 2026-10-03 14:08:44 +07:00
Denozordec 118cd31d65 fix(evolution): сохранять черновики и подтверждать операции формы 2026-10-03 13:59:24 +07:00
Denozordec 7e3cb754b0 fix(evolution): проверять каталог и сохранять неизвестные счётчики 2026-10-03 13:51:07 +07:00
Denozordec 9db1471b2c fix(evolution): разделить состояния настроек и каталога и защитить контекст 2026-10-03 13:42:00 +07:00
Denozordec 554dba55ec fix(evolution): подтверждать сохранение ключа и ответ проверки API 2026-10-03 13:16:09 +07:00
Denozordec 2b62e0e956 fix(evolution): проверять настройки и подтверждение сохранённых полей 2026-10-03 13:11:45 +07:00
Denozordec 2215007c91 fix(netflow): завершить локальную приёмку и объединить сценарий сброса 2026-10-03 13:07:39 +07:00
Denozordec d9e09281e8 fix(netflow): генерировать файлы хоста из согласованного снимка настроек 2026-10-03 13:02:34 +07:00
Denozordec b80fa042d5 fix(netflow): согласовать проверку сетевых параметров формы и сервера 2026-10-03 12:59:05 +07:00
Denozordec 32b7812169 fix(netflow): отклонять конкурентный сброс до удаления данных 2026-10-03 12:54:27 +07:00
Denozordec a575795ff5 fix(netflow): проверить смену источника и адаптивность формы 2026-10-03 12:51:40 +07:00
Denozordec fcd8de2a96 fix(netflow): сохранять черновики и показывать подтверждённые результаты 2026-10-03 12:48:56 +07:00
Denozordec c9b0f9266e fix(netflow): выполнять сброс данных одной транзакцией и сохранять результат 2026-10-03 12:37:40 +07:00
Denozordec e2aae3897d fix(netflow): сохранять отдельные поля и защищать созданную пару ключей 2026-10-03 12:33:31 +07:00
Denozordec 8b017bab3f fix(netflow): защитить приватный экспорт и разделить результаты сохранения 2026-10-03 12:31:51 +07:00
Denozordec 4af4fdc544 fix(netflow): проверять ответы настроек и подтверждения операций 2026-10-03 12:29:05 +07:00
Denozordec 52cb93bf24 fix(geoip): проверить смену источника и читаемость на телефоне 2026-10-03 12:19:54 +07:00
Denozordec 57e7dcf990 fix(geoip): сохранять черновик и показывать частичный результат обновления 2026-10-03 12:13:25 +07:00
Denozordec f0d9f488e6 fix(geoip): ограничить изменение баз правами записи трафика 2026-10-03 12:05:48 +07:00
Denozordec 65aeedd3b0 fix(scheduler): завершить локальную приёмку контекста и адаптивности 2026-10-03 12:03:38 +07:00
Denozordec 5df337f6d7 fix(scheduler): подтверждать ручные операции и учитывать частичные ошибки 2026-10-03 11:56:08 +07:00
Denozordec cb377fb60c fix(collectors): сохранять только изменённые настройки с учётом прав 2026-10-03 11:50:23 +07:00
Denozordec 117aa9a21a fix(collectors): проверять параметры и подтверждения сохранения 2026-10-03 11:43:35 +07:00
Denozordec d453f2aec3 fix(collectors): разделить загрузку настроек и сохранить черновики 2026-10-03 11:41:00 +07:00
Denozordec dd387cf379 fix(evobgp): требовать подтверждение проверки соединения 2026-10-01 22:17:42 +07:00
Denozordec 0d2affaf6b fix(evobgp): проверять каталог и сохранять навигацию при ошибках API 2026-10-01 22:11:10 +07:00
Denozordec a0263c22a5 fix(collectors): проверять реальные ответы настроек без подстановки defaults 2026-10-01 21:59:40 +07:00
Denozordec 5d5ea680aa fix(scheduler): показывать подтверждённый статус независимо от настроек uptime 2026-10-01 21:55:03 +07:00
Denozordec 1f75bc5cea fix(scheduler): различать неизвестную историю и подтверждённые запуски 2026-10-01 21:47:10 +07:00
Denozordec 933a4eaf57 fix(scheduler): исключить поздние ответы после смены фильтра и источника 2026-10-01 21:39:04 +07:00
Denozordec 1b51d2af01 fix(geoip): отклонять неполные ответы и проверять подтверждения операций 2026-10-01 21:31:36 +07:00
Denozordec 638c17caef fix(scheduler): проверять подтверждения и контекст API планировщика 2026-10-01 21:24:37 +07:00
Denozordec 92b7399ddb fix(scheduler): читать снимки JSONB и показывать недоступные результаты 2026-10-01 21:19:36 +07:00
Denozordec 63b2f75012 fix(scheduler): отделить результат запуска от сбоев журналирования 2026-10-01 21:09:29 +07:00
Denozordec 49b8a1a19c chore(ui): завершить локальную приёмку резервных копий и этапа 11А 2026-10-01 20:59:42 +07:00
Denozordec 5ce080baf3 fix(backups): сохранить достоверность плановых операций при сбоях 2026-10-01 20:54:18 +07:00
Denozordec 98b4c39a00 fix(backups): показывать частичный результат внутри формы 2026-10-01 20:39:35 +07:00
Denozordec ad457b90a9 fix(backups): показывать ошибки диалогов на узких экранах 2026-10-01 16:48:20 +07:00
Denozordec b74084b67c fix(backups): адаптировать историю и формы к узким экранам 2026-10-01 16:45:41 +07:00
Denozordec 11e357cc94 fix(backups): восстанавливать вкладку и фильтры из ссылки 2026-10-01 16:37:34 +07:00
Denozordec 6f152c8dfe fix(backups): сохранять неизвестный результат восстановления 2026-10-01 16:32:06 +07:00
Denozordec 39570656c9 fix(backups): не повторять импорт после потери ответа 2026-10-01 16:22:06 +07:00
Denozordec 99ff5aebaf fix(backups): сверять сервер и файл подтверждения импорта 2026-10-01 16:18:37 +07:00
Denozordec af86d2c3cd fix(backups): сохранять запись при неполной очистке файлов 2026-10-01 16:14:00 +07:00
Denozordec 4c5604bf87 fix(backups): подтверждать операции в текущем контексте 2026-10-01 16:11:26 +07:00
Denozordec 714af7e872 fix(backups): продолжать наблюдение без повторного создания 2026-10-01 15:58:47 +07:00
Denozordec 4aee2a34f1 fix(backups): сохранить идентичность фоновой задачи 2026-10-01 15:44:02 +07:00
Denozordec 30bd479a6f fix(backups): подтверждать настройки и последнюю проверку S3 2026-10-01 15:37:00 +07:00
Denozordec 8e01666e0e fix(backups): сохранять расписание и хранилище независимо 2026-10-01 15:25:48 +07:00
Denozordec 5ce21e9a47 fix(backups): отделить результат операций от ошибок журнала 2026-10-01 15:11:32 +07:00
Denozordec 3dcc7c9ea6 fix(backups): разделить загрузку каталогов и права действий 2026-10-01 15:06:52 +07:00
Denozordec b460997cbf fix(test): указать хранилище в фикстуре резервных копий 2026-10-01 14:56:21 +07:00
Denozordec 4ae2954f97 fix(backups): проверять ответы и прогресс резервного копирования 2026-10-01 14:55:03 +07:00
Denozordec 46fe38321a fix(certificates): сохранить достоверный результат выпуска 2026-10-01 14:50:44 +07:00
Denozordec 16fa6eb5d8 fix(ui): проверить доступность и адаптивность сертификатов 2026-10-01 14:42:11 +07:00
Denozordec f5a71728ac fix(ui): восстанавливать задачу выпуска без повторной операции 2026-10-01 14:29:56 +07:00
Denozordec 97526bf06f fix(api): исключить повторное создание задачи выпуска 2026-10-01 14:16:10 +07:00
Denozordec 299f1da90b fix(api): проверять цель и отменять наблюдение выпуска 2026-10-01 14:10:33 +07:00
Denozordec 1d50ec8d17 fix(api): согласовать задачу выпуска с контрактом сертификатов 2026-10-01 14:07:03 +07:00
Denozordec 594e60a1f4 fix(ui): сохранять черновики и результат настроек ACME 2026-10-01 14:03:30 +07:00
Denozordec 1c407b77de fix(ui): сохранять подтверждённые настройки автообновления 2026-10-01 13:56:42 +07:00
Denozordec df89e88f4e fix(api): проверять ответы настроек и выпуска сертификатов 2026-10-01 13:48:50 +07:00
Denozordec adb6589816 fix(ui): сохранять контекст и источник сертификатов 2026-10-01 13:46:12 +07:00
Denozordec b184922f61 fix(ui): сохранять достоверные снимки сертификатов 2026-10-01 13:40:32 +07:00
Denozordec 17f03e7af1 fix(ui): показывать неизвестные поля сертификатов явно 2026-10-01 13:32:41 +07:00
Denozordec bb74eae64c fix(ui): убрать недостоверную шкалу срока сертификата 2026-10-01 13:28:19 +07:00
Denozordec f99612f6aa fix(api): изолировать ошибки создания клиента устройства 2026-10-01 11:26:22 +07:00
Denozordec c7b4981dff fix(api): сохранить последние снимки сертификатов 2026-10-01 11:23:45 +07:00
Denozordec 26869632a0 fix(api): передавать наблюдаемые состояния сертификатов 2026-10-01 11:18:32 +07:00
Denozordec 71cc6fa3a7 fix(ui): сохранить контекст и фокус контейнеров 2026-10-01 11:13:29 +07:00
Denozordec 3ceb8c038c fix(ui): показывать достоверный снимок контейнера 2026-10-01 11:02:31 +07:00
Denozordec c419a46955 fix(ui): подтверждать команды контейнеров ответом API 2026-10-01 10:53:07 +07:00
Denozordec 860add8edf fix(ui): сохранить достоверные состояния контейнеров 2026-10-01 10:47:11 +07:00
Denozordec 644ddb64b3 fix(api): показать частичные отказы каталога контейнеров 2026-10-01 10:39:37 +07:00
Denozordec 1e0fc4cd2d fix(ui): завершить доступность формы пользователей 2026-10-01 10:35:34 +07:00
Denozordec 9320c11d58 fix(ui): различать снимок историю и неизвестный каталог пользователей 2026-10-01 10:18:42 +07:00
Denozordec c37498512f fix(api): показывать источник и частичные сбои каталога интерфейсов 2026-10-01 10:10:41 +07:00
Denozordec ecd72d9750 fix(ui): сохранять контекст каталога и выбирать клиента IPsec 2026-10-01 10:01:09 +07:00
Denozordec 4896d8ef6f fix(ui): продолжать частичное сохранение пользователя без дублей 2026-10-01 09:53:23 +07:00
Denozordec 02ca48ef9c fix(ui): подтверждать удаление пользователя и сохранять ошибку 2026-10-01 09:39:39 +07:00
Denozordec 2d5fb3598f fix(ui): показывать подтверждённые каталоги пользователей 2026-10-01 09:34:51 +07:00
Denozordec 0a332ce341 chore(docs): сохранить карту реализации администрирования 2026-10-01 09:33:40 +07:00
Denozordec 517e0bca67 chore(test): зафиксировать локальную приёмку маршрутизации и диагностики 2026-10-01 09:33:02 +07:00
Denozordec 3c41de136e fix(ui): сохранять явный выбор туннеля диагностики 2026-10-01 09:21:05 +07:00
Denozordec d28d7a2cdb fix(ui): улучшить доступность и мобильный вывод диагностики 2026-10-01 09:17:25 +07:00
Denozordec 440e10c646 fix(api): прекращать диагностику при отмене HTTP 2026-10-01 09:00:19 +07:00
Denozordec 9d2101d700 fix(api): сохранять неизвестные показатели диагностики 2026-10-01 08:54:31 +07:00
Denozordec 33185fddae fix(api): проверять параметры и подтверждать результат MTU 2026-10-01 08:49:54 +07:00
Denozordec 68ccc49135 fix(api): отделить отказ диагностики от успешного вывода 2026-10-01 08:42:06 +07:00
Denozordec b60b7ac8e8 fix(ui): проверять источник IPv4 для диагностики 2026-10-01 08:36:31 +07:00
Denozordec d0c715e93c fix(ui): сохранять устройство диагностики в URL 2026-10-01 08:31:50 +07:00
Denozordec 1b91ba4e1d fix(ui): показывать реальный источник и ошибки DNS 2026-10-01 04:24:28 +07:00
Denozordec 155dfc5716 fix(ui): подтверждать GRE перед тестом пропускной способности 2026-10-01 04:20:19 +07:00
Denozordec b856215c03 fix(ui): согласовать права диагностики с API 2026-10-01 04:14:52 +07:00
Denozordec cba3db1508 fix(ui): сохранять каталог устройств диагностики при отказе 2026-10-01 04:11:43 +07:00
Denozordec 6fa8e5bfd1 fix(ui): различать результат и отмену диагностики 2026-10-01 04:07:08 +07:00
Denozordec 8bda85129f fix(ui): проверить терминал и мобильный выбор устройства 2026-10-01 03:59:47 +07:00
Denozordec ea4095d1f1 fix(api): отличать отказ терминала от пустого вывода 2026-10-01 03:43:35 +07:00
Denozordec a2eae189be fix(ui): подтверждать выполнение команд терминала 2026-10-01 03:36:14 +07:00
Denozordec bc20e5b9f4 fix(ui): завершить доступное управление картой сети 2026-10-01 03:26:31 +07:00
Denozordec bc83b8dbbf fix(ui): вписывать топологию в область карты 2026-10-01 03:18:02 +07:00
Denozordec 89e48c2424 fix(ui): адаптировать панели и действия карты сети 2026-10-01 03:06:28 +07:00
Denozordec 8bc4f22af7 fix(ui): сохранять контекст карты сети в ссылке 2026-10-01 02:26:03 +07:00
Denozordec 0cdb43a29d fix(ui): обновлять детали и пояснять модель карты сети 2026-10-01 02:15:52 +07:00
Denozordec 6cb27b2aab chore(test): проверить смену источника карты сети 2026-10-01 02:00:21 +07:00
Denozordec 1bce4e1ea2 fix(ui): показывать ошибки и свежесть NetFlow карты 2026-10-01 01:56:39 +07:00
Denozordec e47e2b4029 fix(ui): убрать выдуманные измерения из карты сети 2026-10-01 01:46:29 +07:00
Denozordec 329d6b82f7 fix(ui): показывать неизвестный статус карты отдельно 2026-10-01 01:39:53 +07:00
Denozordec 1215cbe8ae fix(ui): проверять целостность снимка карты сети 2026-10-01 01:33:50 +07:00
Denozordec 66fde3fc62 fix(ui): завершить локальную приёмку оптимизатора 2026-10-01 01:14:18 +07:00
Denozordec 5a23dfcfe6 fix(ui): согласовать настройки и контекст Community 2026-10-01 01:04:02 +07:00
Denozordec 6e6558bde9 fix(ui): исключить подстановку моковых WAN в live расчёт 2026-10-01 00:58:59 +07:00
Denozordec 338335ddc0 fix(api): проверять однозначность Router ID оптимизатора 2026-10-01 00:53:40 +07:00
Denozordec 7077844655 fix(api): передавать происхождение метрик оптимизатора OSPF 2026-10-01 00:50:17 +07:00
Denozordec 255f272fab fix(ui): отделить измерения цепочек от модели 2026-10-01 00:41:54 +07:00
Denozordec 9404bd5136 fix(ui): пояснить расчётные метрики preview OSPF 2026-09-30 23:40:56 +07:00
Denozordec d68c11e58a chore(test): проверить смену источника оптимизатора 2026-09-30 23:37:17 +07:00
Denozordec c82434c435 fix(ui): сохранять устройство оптимизатора в адресе 2026-09-30 23:34:05 +07:00
Denozordec 2cdfa2cb13 fix(ui): не подменять неизвестный индекс маршрута нулём 2026-09-30 23:27:14 +07:00
Denozordec fb70d80220 fix(ui): завершить адаптивность оптимизатора маршрутов 2026-09-30 23:23:58 +07:00
Denozordec 0ee7f02e87 fix(api): проверять исходные шаблоны и план оптимизации OSPF 2026-09-30 23:03:48 +07:00
Denozordec c88e1400bf fix(ui): подтверждать применение OSPF и согласовать права 2026-09-30 22:56:31 +07:00
Denozordec bf2815b5a4 fix(ui): проверять контекст расчёта OSPF оптимизатора 2026-09-30 22:47:04 +07:00
Denozordec f93dfe34c2 fix(ui): сохранять согласованные данные оптимизатора 2026-09-30 22:38:11 +07:00
Denozordec 59f0f82176 fix(ui): обозначить расчёт и симуляцию оптимизатора 2026-09-30 22:29:46 +07:00
Denozordec e58604c3c0 fix(ui): завершить адаптивность рекурсивных маршрутов 2026-09-30 22:19:21 +07:00
Denozordec 54ef752a5b fix(api): сохранять достоверные параметры рекурсивных маршрутов 2026-09-30 22:08:22 +07:00
Denozordec 50fad3ac80 fix(ui): сохранять контекст и блокировку рекурсивных маршрутов 2026-09-30 21:43:41 +07:00
Denozordec 52493debc2 fix(api): валидировать применение и восстановление маршрутов 2026-09-30 21:15:23 +07:00
Denozordec f5fc9282e2 fix(ui): подтверждать операции рекурсивных маршрутов 2026-09-30 21:08:01 +07:00
Denozordec 79a8d95e7d fix(ui): завершить адаптивность и приёмку OSPF 2026-09-30 20:43:10 +07:00
Denozordec 259c5c3014 fix(ui): связывать OSPF узлы по подтверждённым ID 2026-09-30 20:19:37 +07:00
Denozordec 7a68ae8184 fix(ui): сохранять поиск и выбранный узел OSPF 2026-09-30 20:09:00 +07:00
Denozordec 1d522527b4 fix(ui): восстанавливать контекст OSPF из URL 2026-09-30 19:58:43 +07:00
Denozordec af05ad5cda fix(api): показывать подтверждённые метрики OSPF 2026-09-30 19:49:47 +07:00
Denozordec e3791f3496 fix(api): сохранять неизвестные показатели BFD и OSPF 2026-09-30 19:42:07 +07:00
Denozordec 47dcd89b3c fix(api): закрепить неизвестное состояние BFD в строгом каталоге 2026-09-30 18:13:51 +07:00
Denozordec b1080b0a05 fix(ui): учитывать область в стоимости соседа OSPF 2026-09-30 18:09:10 +07:00
Denozordec 2199950681 fix(ui): проверять диапазон AS в каталоге BGP 2026-09-30 18:06:18 +07:00
Denozordec 748a3c00ad fix(ui): не подставлять выдуманный cost соседа OSPF 2026-09-30 18:04:14 +07:00
Denozordec 3dc25628af fix(ui): сохранять переходные и неизвестные состояния OSPF 2026-09-30 18:02:11 +07:00
Denozordec c18c148a23 fix(ui): разделить время опроса и подтверждения OSPF 2026-09-30 17:50:47 +07:00
Denozordec baa43b7ad4 fix(ui): защищать оптимизацию OSPF и подтверждение результата 2026-09-30 17:46:56 +07:00
Denozordec 4254904c3a fix(ui): сохранять снимки OSPF при отказе устройств 2026-09-30 17:37:23 +07:00
Denozordec a89cefcbf2 fix(api): показывать ошибки строгого OSPF-каталога 2026-09-30 17:27:44 +07:00
Denozordec 72a63b4a4f fix(ui): улучшить мобильную сводку и детали BGP 2026-09-30 17:23:18 +07:00
Denozordec aa678b9d5d fix(ui): восстанавливать поиск и фильтры BGP из URL 2026-09-30 17:15:37 +07:00
Denozordec 5c307a5b6c fix(bgp): сохранять неизвестные AS и тип сессии 2026-09-30 17:09:32 +07:00
Denozordec 2e17cbe926 fix(ui): сохранять контекст и свежесть опроса BGP 2026-09-30 17:05:37 +07:00
Denozordec 8fdc4b917b fix(bgp): отображать только подтверждённые показатели сессий 2026-09-30 16:56:28 +07:00
Denozordec 7046808429 fix(ui): сохранять BGP-сессии при частичных отказах 2026-09-30 16:48:46 +07:00
Denozordec 85e701960e fix(api): показывать ошибки опроса в каталоге BGP 2026-09-30 16:41:45 +07:00
Denozordec f9cfb4e532 chore(docs): сохранить карту маршрутизации и диагностики 2026-09-30 16:38:14 +07:00
Denozordec 919d0c4714 fix(api): проверить контекст GRE и завершить локальную приёмку этапа 9 2026-09-30 16:29:59 +07:00
Denozordec a29ee31052 fix(ui): улучшить мобильные экраны и сводку GRE и VXLAN 2026-09-30 16:27:10 +07:00
Denozordec fc3d588cec fix(ui): убрать неподтверждённые данные и действия GRE-пулов 2026-09-30 16:10:21 +07:00
Denozordec 337d14abdf fix(gre): экспортировать поддерживаемые параметры и очищать PSK 2026-09-30 16:06:32 +07:00
Denozordec a21252fb02 fix(ui): показывать свежесть GRE и закреплять экспорт 2026-09-30 16:00:48 +07:00
Denozordec ceb2113209 fix(ui): изолировать операции GRE и готовность устройств 2026-09-30 15:56:51 +07:00
Denozordec fb009344e1 fix(ui): сохранять подтверждённые данные GRE при отказах 2026-09-30 15:49:26 +07:00
Denozordec 6ea3cdad0e fix(ui): учитывать неизвестный статус устройств VPN 2026-09-30 15:41:45 +07:00
Denozordec 6f00596e33 fix(api): сообщать об отказах общего GRE-каталога 2026-09-30 15:31:09 +07:00
Denozordec d112935c73 fix(api): отклонять несогласованные объекты IPsec и VXLAN 2026-09-30 15:25:29 +07:00
Denozordec 0e0f98e0f4 fix(vxlan): корректно экспортировать параметры RouterOS 2026-09-30 15:21:55 +07:00
Denozordec d200e87e3d fix(ui): показывать достоверное состояние и экспорт VXLAN 2026-09-30 15:15:13 +07:00
Denozordec 6fc342cd2c fix(ui): проверить клавиатуру и адаптивность IPsec 2026-09-30 15:06:31 +07:00
Denozordec ce43b2d762 fix(ui): показывать свежесть опроса и изменений IPsec 2026-09-30 14:58:55 +07:00
Denozordec 921aba3054 fix(ui): изолировать контекст и операции IPsec 2026-09-30 14:55:20 +07:00
Denozordec f0071a495c fix(ui): учитывать готовность устройства в действиях IPsec 2026-09-30 14:48:21 +07:00
Denozordec b8923d075d fix(ui): сохранять данные IPsec при сбоях опроса 2026-09-30 14:43:22 +07:00
Denozordec 8fab2f7fd2 chore(docs): зафиксировать общую регрессию Filters 2026-09-30 12:44:31 +07:00
Denozordec 29a4ea2e5b fix(ui): улучшить клавиатурный ввод Community в Filters 2026-09-30 12:39:45 +07:00
Denozordec 4ae5f2edcd fix(ui): показывать свежесть данных Filters 2026-09-30 12:25:42 +07:00
Denozordec 0ab5a23eb3 fix(ui): сохранять недоступные шлюзы Filters 2026-09-30 12:21:00 +07:00
Denozordec 860e88ed3e fix(ui): читать актуальную цель копирования Filters 2026-09-30 12:10:20 +07:00
Denozordec 365ab2173c fix(ui): изолировать источники данных Filters 2026-09-30 12:05:28 +07:00
Denozordec 24cc66d8d2 fix(ui): отклонять устаревшее чтение Filters 2026-09-30 11:57:22 +07:00
Denozordec 1c7ffa07a8 fix(ui): проверять данные загрузки Filters 2026-09-30 11:50:36 +07:00
Denozordec 1fba16e1f2 fix(ui): изолировать операции восстановления Filters 2026-09-30 11:42:57 +07:00
Denozordec 7554f1804b fix(ui): подтверждать перемещение правил Filters 2026-09-30 11:35:36 +07:00
Denozordec c4011cfaff fix(ui): подтверждать удаление правил Filters 2026-09-30 11:29:54 +07:00
Denozordec 8412ba9bbe fix(ui): подтверждать копирование правил Filters 2026-09-30 11:24:48 +07:00
Denozordec 1cf7297d64 fix(ui): подтверждать сохранение правил Filters 2026-09-30 11:17:32 +07:00
Denozordec 4b01f20558 fix(ui): показывать свежесть срезов Firewall 2026-09-30 11:04:57 +07:00
Denozordec c2159508d5 fix(ui): изолировать операции Firewall по источнику 2026-09-30 10:56:49 +07:00
Denozordec 5e6057842f fix(ui): сохранять недоступные срезы Firewall 2026-09-30 10:46:36 +07:00
Denozordec 59440555cd fix(ui): сохранять данные Firewall при сбоях загрузки 2026-09-30 10:33:54 +07:00
Denozordec 92ba474ca9 fix(ui): исключить конфликты восстановления Firewall 2026-09-30 10:24:35 +07:00
Denozordec 14f8b1ee9a fix(ui): подтверждать перемещение правил Firewall 2026-09-30 10:18:34 +07:00
Denozordec b47c5645d5 fix(ui): подтверждать сохранение форм Firewall 2026-09-30 10:13:11 +07:00
Denozordec 14a9e6d26c fix(ui): подтверждать удаление объектов Firewall 2026-09-30 10:07:07 +07:00
Denozordec 0c460d4767 fix(ui): подтверждать переключение адресов Firewall 2026-09-30 09:59:36 +07:00
Denozordec 131ecb8668 fix(ui): подтверждать переключение правил Firewall 2026-09-30 09:52:29 +07:00
Denozordec 14be83a869 fix(ui): проверять данные VXLAN и гонки загрузки 2026-09-30 09:47:51 +07:00
Denozordec e163a9ed9d fix(ui): показывать частичные сбои опроса VXLAN 2026-09-30 09:42:43 +07:00
Denozordec 4dc2ee2129 fix(ui): различать ошибку и пустые данные VXLAN 2026-09-30 09:37:31 +07:00
Denozordec 3d518ff902 fix(ui): подтверждать сохранение формы GRE 2026-09-30 09:34:12 +07:00
Denozordec 66b439cc2e fix(ui): подтверждать переключение туннелей GRE 2026-09-30 00:42:28 +07:00
Denozordec 780d5e9237 fix(ui): подтверждать удаление туннеля GRE 2026-09-30 00:39:39 +07:00
Denozordec f5b64ea154 fix(ui): подтверждать сохранение peer IPsec 2026-09-29 19:31:55 +07:00
Denozordec 611f31875e fix(ui): подтверждать удаление объектов IPsec 2026-09-29 19:28:26 +07:00
Denozordec 74e7cd257d fix(ui): сохранять контекст экспорта сертификатов IPsec 2026-09-29 19:23:34 +07:00
Denozordec 86ecf764ff fix(ui): подтверждать сохранение пользователей IPsec 2026-09-29 19:15:15 +07:00
Denozordec f9413d5100 fix(ui): подтверждать инициализацию IPsec и сохранять форму 2026-09-29 19:09:22 +07:00
Denozordec 4a8d696e4e fix(ui): проверять ревизии и сохранять ошибки истории 2026-09-29 19:04:00 +07:00
Denozordec 46d62c98b8 fix(ui): сохранять ошибку загрузки истории IPsec 2026-09-29 18:55:46 +07:00
Denozordec 3dd4ad34ee fix(ui): проверять подтверждение восстановления сетевых настроек 2026-09-29 13:58:26 +07:00
Denozordec d0f5649725 fix(ui): различать ошибки и пустую историю WireGuard 2026-09-29 13:42:00 +07:00
Denozordec 1d1de12c58 fix(ui): сохранять подтверждение восстановления при ошибке 2026-09-29 13:37:32 +07:00
Denozordec 221d6db10c fix(ui): проверять подтверждение восстановления WireGuard 2026-09-29 08:43:31 +07:00
Denozordec 425a77a1ca fix(ui): сохранять точный выбор пира при экспорте WireGuard 2026-09-29 08:40:36 +07:00
Denozordec 4aa4726c6b fix(ui): защитить экспорт WireGuard от устаревших ответов 2026-09-29 08:37:53 +07:00
Denozordec d3ae2598fb fix(ui): подтверждать переключение интерфейсов WireGuard 2026-09-29 08:33:10 +07:00
Denozordec 8fff3d7fff fix(ui): подтверждать создание интерфейса и пира WireGuard 2026-09-29 08:30:07 +07:00
Denozordec ec6ec52965 fix(ui): защитить форму импорта WireGuard во время запроса 2026-09-29 08:22:58 +07:00
Denozordec efc6da1249 fix(ui): проверять подтверждение импорта WireGuard 2026-09-28 23:58:59 +07:00
Denozordec f980619046 fix(ui): подтверждать удаление WireGuard и блокировать повторы 2026-09-28 23:52:43 +07:00
Denozordec 165f40acd7 fix(ui): завершить локальную приёмку трафика и статистики 2026-09-28 23:45:48 +07:00
Denozordec 48b7a061c1 fix(ui): обозначить выбранные состояния и поиск трафика 2026-09-28 17:02:16 +07:00
Denozordec 460cf67313 fix(ui): выбирать неактивные серверы для месячного отчёта 2026-09-28 16:59:40 +07:00
Denozordec 7dd377d7c3 fix(ui): выбирать календарный месяц отчёта потоков 2026-09-28 16:54:17 +07:00
Denozordec 7a01e8d545 test(ui): проверить ожидание первого сообщения SSE 2026-09-28 16:49:56 +07:00
Denozordec 88a8b5f02f fix(ui): отображать одиночные измерения на графике трафика 2026-09-28 16:47:17 +07:00
Denozordec ed491c313f fix(ui): распознавать CRLF и комментарии в потоке SSE 2026-09-28 11:38:11 +07:00
Denozordec f50a29f116 fix(ui): снимать статус live при отсутствии обновлений 2026-09-28 11:35:29 +07:00
Denozordec 5734340db2 fix(ui): исключить подмену выбранного среза в сводке трафика 2026-09-28 11:31:24 +07:00
Denozordec c6d146d38f fix(ui): показывать временные отметки трафика серверов 2026-09-28 11:27:20 +07:00
Denozordec 65241c94e9 fix(ui): показывать время получения аналитики потоков 2026-09-28 11:22:20 +07:00
Denozordec 7318c5286b test(ui): проверить поздний фрагмент открытого потока 2026-09-28 11:12:06 +07:00
Denozordec 53bca894bb chore(docs): сохранить общий план реализации UX/UI 2026-09-24 21:13:05 +07:00
Denozordec c3ce626b47 test(ui): проверить разрывы основного графика трафика 2026-09-24 21:10:42 +07:00
Denozordec ee3dcdacf2 test(ui): проверить трафик после смены источника данных 2026-09-24 21:09:39 +07:00
Denozordec 2c2a3ef078 test(ui): проверить отмену потока при смене сервера 2026-09-24 21:07:44 +07:00
Denozordec c563583ccb fix(ui): привязать фильтр интерфейса к серверу потоков 2026-09-24 16:04:08 +07:00
Denozordec 1d98a2a66e fix(ui): сохранять пропуски в мини-графиках измерений 2026-09-24 15:58:23 +07:00
Denozordec f3568d836b fix(ui): корректно отображать историю в карточках трафика 2026-09-24 15:54:53 +07:00
Denozordec ccbc1aa029 fix(ui): пояснять отсутствие отдельного направления трафика 2026-09-24 15:51:32 +07:00
Denozordec ec8e28d15d fix(ui): отличать пустую историю трафика от нулевой 2026-09-24 10:06:46 +07:00
Denozordec 7c6f937cec fix(ui): отличать неизвестные flow-метрики от нулевых 2026-09-24 10:02:50 +07:00
Denozordec a03909e504 fix(ui): отклонять повреждённые данные live-потока 2026-09-24 09:58:32 +07:00
Denozordec 094c812c37 fix(ui): адаптировать длинные данные и пустые списки трафика 2026-09-24 09:53:10 +07:00
Denozordec cb16c03e01 test(ui): проверить период трафика на телефоне 2026-09-24 01:40:34 +07:00
Denozordec 3dc85a22e9 fix(ui): адаптировать панели трафика для телефона 2026-09-24 01:39:32 +07:00
Denozordec 8ac40ba78a fix(ui): показывать подтверждённые данные месячного отчёта 2026-09-24 01:36:36 +07:00
Denozordec 24313340d5 fix(ui): привязать flow-аналитику к контексту запроса 2026-09-24 01:31:43 +07:00
Denozordec 94dd453b33 fix(ui): защитить списки трафика от устаревших ответов 2026-09-24 01:25:20 +07:00
Denozordec 480aa82f78 fix(ui): привязать детали трафика к выбранному контексту 2026-09-24 01:18:13 +07:00
Denozordec 9eb6a3fad1 fix(ui): показывать остановку потока live-трафика 2026-09-24 01:13:11 +07:00
Denozordec e05b3a38c8 fix(ui): сохранять и проверять контекст отчёта статистики 2026-09-23 20:30:11 +07:00
Denozordec 94ad73212b fix(ui): проверять период статистики до запроса 2026-09-23 20:26:23 +07:00
Denozordec 167e19cd91 fix(ui): отделить ошибки статистики от пустых данных 2026-09-23 20:22:18 +07:00
Denozordec 3898881d8a test(ui): завершить локальную приёмку серверов и мониторинга 2026-09-23 20:18:35 +07:00
Denozordec ddb47a0ee4 test(ui): проверить смену режима во время опроса 2026-09-23 20:12:42 +07:00
Denozordec 27f8323624 test(ui): проверить опрос сервера из очереди проблем 2026-09-23 14:50:16 +07:00
Denozordec ca9474ac83 fix(ui): адаптировать формы серверов и прокрутку ресурсов 2026-09-23 14:48:13 +07:00
Denozordec 9cd15528c9 fix(ui): исправить адаптивность форм и вкладок мониторинга 2026-09-23 14:38:52 +07:00
Denozordec a9ed459481 fix(ui): убрать неработающий экспорт серверов 2026-09-23 14:28:39 +07:00
Denozordec 0734679fd6 fix(ui): сохранять фильтры ресурсов в URL 2026-09-23 09:28:03 +07:00
Denozordec fd7a08d5dc fix(ui): сохранять вкладку и фильтры Ping в URL 2026-09-23 09:24:18 +07:00
Denozordec f7e0e20673 fix(ui): сериализовать ручной опрос и ограничить ожидание Speed-test 2026-09-23 09:17:20 +07:00
Denozordec cc40a5e33d fix(ui): блокировать изменение проб во время Speed-test 2026-09-21 21:14:13 +07:00
Denozordec ce14f5de3e fix(ui): подтверждать удаление проб и изменение звезды 2026-09-21 21:08:06 +07:00
Denozordec 4f235058be fix(ui): подтверждать переключение проб через API 2026-09-21 21:03:14 +07:00
Denozordec 2f5b617e8c fix(ui): сохранять формы проб после подтверждения API 2026-09-21 16:24:07 +07:00
Denozordec bb9278fe58 fix(ui): учитывать права на действия мониторинга 2026-09-21 16:16:16 +07:00
Denozordec 61950638bc fix(ui): учитывать права на операции с серверами 2026-09-21 15:00:13 +07:00
Denozordec e9e5030047 fix(ui): адаптировать строки Ping-проб для телефона 2026-09-21 09:58:43 +07:00
Denozordec dfb993e1a3 fix(ui): сохранять фильтры серверов в адресе страницы 2026-09-21 09:50:47 +07:00
Denozordec b64e6834a9 chore(docs): подготовить этап серверов и мониторинга 2026-09-20 22:11:27 +07:00
Denozordec d25afa4b9e fix(ui): завершить переходы к объектам дашборда через VXLAN 2026-09-20 22:08:00 +07:00
Denozordec af84f0135d fix(ui): открывать интерфейс WireGuard с контекстом сервера 2026-09-20 21:59:57 +07:00
Denozordec 67a152b7c8 fix(ui): открывать GRE-туннель с контекстом сервера 2026-09-20 17:09:33 +07:00
Denozordec 6c8e66693b fix(ui): открывать выбранную проверку из очереди проблем 2026-09-20 17:00:30 +07:00
Denozordec 668c7c6823 fix(ui): открывать выбранный сервер из очереди проблем 2026-09-20 16:51:28 +07:00
Denozordec 4ec32765df fix(ui): поднять очередь проблем и уточнить сводку дашборда 2026-09-20 12:04:45 +07:00
Denozordec fe9df167ec fix(ui): объединить навигацию и сделать поиск доступным 2026-09-20 12:04:21 +07:00
Denozordec 9b3196363c fix(ui): адаптировать шапку страницы к узким экранам 2026-09-20 11:49:44 +07:00
Denozordec 6a726b13b4 fix(ui): сохранить данные дашборда при сбоях и гонках запросов 2026-09-20 00:58:58 +07:00
Denozordec 0d418e2e39 chore(ui): завершить миграцию Filters и удалить старый API 2026-09-20 00:47:33 +07:00
Denozordec 4fd844f977 fix(ui): подключить фильтры статистики с проверкой срезов 2026-09-14 22:27:12 +07:00
Denozordec 52af82af42 fix(ui): подготовить проверку срезов статистики для FilterQuery 2026-09-14 22:12:15 +07:00
Denozordec fca42aaa81 fix(ui): перенести фильтры BGP и бэкапов на FilterQuery 2026-09-14 22:09:48 +07:00
Denozordec a475b5c6df chore(ui): удалить старую основу Data Grid и TanStack v8 2026-09-14 22:05:50 +07:00
Denozordec bdba83b2b6 fix(ui): перенести таблицы оптимизатора маршрутов на ReUI v9 2026-09-14 22:01:11 +07:00
Denozordec 1ea07f65b2 fix(ui): перенести таблицу рекурсивных маршрутов на ReUI v9 2026-09-14 13:54:05 +07:00
Denozordec ddf1851e92 fix(ui): перенести таблицы сборщика и проб на ReUI v9 2026-09-14 13:49:54 +07:00
Denozordec f81b35308b fix(ui): перенести таблицы Firewall и проверить порядок правил 2026-09-14 13:39:47 +07:00
Denozordec 45530cdb99 fix(ui): перенести таблицу сетевых фильтров на ReUI v9 2026-09-14 08:43:08 +07:00
Denozordec d47f7ab162 fix(ui): перенести таблицы администрирования и сортировать бэкапы по байтам 2026-09-14 08:38:09 +07:00
Denozordec 583b23ded1 fix(ui): перенести таблицы IPsec и GRE на ReUI v9 2026-09-14 08:31:16 +07:00
Denozordec 8b9b584513 fix(ui): перенести таблицу VXLAN на ReUI v9 2026-09-13 21:30:52 +07:00
Denozordec e7d45d194d fix(ui): перенести таблицы WireGuard на ReUI v9 2026-09-13 21:17:48 +07:00
Denozordec d3db82fd1c fix(ui): перенести таблицы BGP и OSPF на ReUI v9 2026-09-13 21:10:06 +07:00
Denozordec e66762ca2e fix(ui): перенести основные таблицы на ReUI v9 2026-09-13 21:01:23 +07:00
Denozordec 3d3c45e4d2 fix(ui): завершить основу таблиц и фильтров серверов 2026-09-13 14:46:17 +07:00
Denozordec f9a4a07996 fix(ui): исправить зависимости мемоизации таблицы 2026-09-13 09:56:02 +07:00
Denozordec e7755a4d16 chore(ui): сохранить основу поэтапной миграции таблиц 2026-09-13 09:46:41 +07:00
Denozordec c30f817926 fix(servers): исправить сохранение и опрос серверов 2026-09-13 09:32:37 +07:00
Denozordec 88326d9851 chore(ui): зафиксировать исходную базу улучшений интерфейса 2026-09-13 01:48:05 +07:00
DenozordecandCursor f8e7437210 fix(ui): убрать горизонтальный скролл в шторке сертификата
Docker images / frontend-image (push) Successful in 3m9s
Docker images / backend-image (push) Successful in 2m46s
Docker images / publish-release (push) Successful in 11s
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-test (push) Successful in 1m56s
Docker images / updater-image (push) Successful in 49s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-13 01:22:36 +07:00
DenozordecandCursor e0364d45df test(ipsec): закрепить отсутствие proposal-ключей в .sswan
Co-authored-by: Cursor <[email protected]>
2026-09-13 01:22:36 +07:00
DenozordecandCursor bbff586da2 fix(ipsec): убрать невалидный ike-proposal из .sswan-профиля
Co-authored-by: Cursor <[email protected]>
2026-09-13 01:22:36 +07:00
DenozordecandCursor fbd1be8952 test(ipsec): покрыть формы ret и print в декодере файла RouterOS
Docker images / frontend-image (push) Successful in 3m4s
Docker images / backend-image (push) Successful in 2m25s
Docker images / publish-release (push) Successful in 9s
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m28s
Docker images / updater-image (push) Successful in 51s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-13 01:01:42 +07:00
DenozordecandCursor 0bf5d6065c fix(ipsec): читать содержимое .p12 из поля ret ответа /file/get
Co-authored-by: Cursor <[email protected]>
2026-09-13 01:01:31 +07:00
DenozordecandCursor 67c3a4fd73 test(ipsec): покрыть декодер содержимого файла RouterOS
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 3m19s
Docker images / publish-release (push) Successful in 11s
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-test (push) Successful in 1m53s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:44:53 +07:00
DenozordecandCursor 9beb24273b fix(ipsec): скачивать .p12 через /file/get вместо несуществующего REST-пути
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:44:44 +07:00
DenozordecandCursor 5cc6128086 fix(ipsec): уточнить подсказку про политику sensitive у группы пользователя
Docker images / frontend-image (push) Successful in 3m13s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m44s
Docker images / publish-release (push) Successful in 7s
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m13s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:15:33 +07:00
DenozordecandCursor 569b6be2b4 fix(ipsec): не блокироваться на пароле .p12 и подсказать политику sensitive
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m43s
Docker images / publish-release (push) Successful in 14s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m18s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:15:05 +07:00
DenozordecandCursor 752256f12e fix(ipsec): требовать пароль .p12 не короче 8 символов
Docker images / frontend-image (push) Successful in 2m19s
Docker images / updater-image (push) Successful in 39s
Docker images / backend-image (push) Successful in 2m40s
Docker images / publish-release (push) Successful in 11s
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:10:10 +07:00
DenozordecandCursor a3502b9755 fix(ipsec): убрать неподдерживаемый параметр при подписи сертификата
Docker images / frontend-image (push) Successful in 2m17s
Docker images / updater-image (push) Successful in 38s
Docker images / backend-image (push) Successful in 2m20s
Docker images / publish-release (push) Successful in 9s
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-test (push) Successful in 1m37s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:21:19 +07:00
DenozordecandCursor 0e1524c600 fix(ipsec): клиенты через общий IKEv2-peer и отдельный клиентский сертификат
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m11s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:06:35 +07:00
DenozordecandCursor 11ae7593f4 fix(ipsec): убрать неподдерживаемый параметр при добавлении сертификата
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:06:27 +07:00
Denozordec 3eb75ea0b8 feat(ipsec): add certificate export functionality by name
Docker images / frontend-image (push) Successful in 2m57s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m48s
Docker images / publish-release (push) Successful in 13s
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m41s
Docker images / notify-webhook (push) Skipped
- Implemented a new backend route for exporting existing client certificates in .p12 format by name.
- Enhanced the frontend to support exporting certificates directly from the IPsec server grid.
- Updated the IPsec page to manage certificate states and handle exports effectively.
- Introduced new utility functions for certificate handling and improved data structures to accommodate the changes.
- Added tests to ensure the reliability of the new certificate export feature.
2026-09-12 21:39:14 +07:00
Denozordec 7365d8d8fb feat(ipsec): enhance IPsec management with peer and certificate handling
Docker images / frontend-image (push) Successful in 2m56s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m35s
Docker images / publish-release (push) Successful in 15s
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m39s
Docker images / notify-webhook (push) Skipped
- Added support for managing IPsec peers, including editing and deleting functionality.
- Introduced a new UI component for displaying peers and their details within the IPsec server grid.
- Updated the IPsec user creation form to allow binding identities to specific peers.
- Enhanced backend routes and services to handle peer patching and deletion requests.
- Improved data structures to accommodate multiple peers and certificates for each server.
- Added tests to ensure proper functionality of new peer management features.
2026-09-12 21:04:49 +07:00
Denozordec 7755d77340 feat(ipsec): управление IKEv2/IPsec VPN и клиентами из одного окна
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / notify-webhook (push) Skipped
- мастер инициализации сервера: CA и серверный сертификаты, peer/profile/proposal, пул, mode-config, policy-template, managed NAT masquerade
- клиенты по сертификату (RSA) и PSK: статический IP или из пула, онлайн-статус по active-peers
- скачивание .p12 и strongSwan .sswan с инструкцией, перекачка с новой passphrase
- история изменений (config_revisions, секция ipsec) и restore только managed-объектов
- привязка IPsec-клиентов к пользователям приложения по Common Name
- страница /ipsec с KPI и вкладками Клиенты/Сервер/CLI, сайдбар, command palette
2026-09-12 20:20:56 +07:00
Denozordec 564aae21f0 fix(statistics): выровнять трафик-данные с картой сети
Docker images / frontend-image (push) Successful in 3m28s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 3m5s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
- Окно периода клампится к текущему моменту: «Сегодня»/«7 д» больше не делят байты на ещё не наступившие часы суток (avgBps/bps были занижены).
- Пресет «24 ч» шлёт точные ISO-границы «сейчас−24ч … сейчас» вместо дат «вчера…сегодня» (~48 ч); подпись диапазона и подсветка пресета сверяются по длительности окна.
- Таксономия сервисов как на карте: DNS/SSH/BGP/WireGuard/GRE и пустые метки сворачиваются в «Прочее» (normalizeFactService) в разбивке сервисов и pivot country×service.
- Тесты: кламп окна, ISO-24ч, будущее «to», невалидный диапазон, нормализация меток.
2026-09-12 13:22:06 +07:00
Denozordec 0fdd2fc1e1 feat(traffic-flow): раскрытие сервисов страны на карте сети
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m44s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
- Контракт: countryServiceGroups (группа сервисов страны) в flowMapHopsDto, тип FlowMapCountryServiceGroup.
- Бэкенд: вложенная агрегация brand×ISO в том же проходе hops; рёбра страна→сервис, пути с реальным EN; cap 8 / min 4, доля от байтов страны.
- Хелпер mapCountryServiceNodeId: id вида cc:us|svc:google, сервисы разных стран не смешиваются.
- Карта сети: клик по стране сдвигает колонку стран и раскрывает столбец её сервисов; остальные страны приглушены; панели страны и вложенного сервиса.
- Тесты: группы US/US+NL, рёбра от страны, cap/min вложенного слоя; mock countryServiceGroups для демо-режима.
2026-09-12 12:47:30 +07:00
DenozordecandCursor f15a7348db feat(traffic-flow): enhance country handling in flow analytics
Docker images / frontend-image (push) Successful in 3m26s
Docker images / updater-image (push) Successful in 47s
Docker images / backend-image (push) Successful in 2m23s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m19s
Docker images / notify-webhook (push) Skipped
- Introduced country-specific handling in traffic flow analytics, including new mappings for country nodes and edges.
- Added mock data for countries and their respective service paths to improve visualization in the Network Map.
- Updated the `countryName` function to utilize `Intl.DisplayNames` for better localization of country names.
- Enhanced tests to validate country handling and ensure accurate representation in flow analytics.

Co-authored-by: Cursor <[email protected]>
2026-09-12 11:38:37 +07:00
DenozordecandCursor f3c846201c feat(traffic-flow): enhance Instagram service handling and classification
Docker images / frontend-image (push) Successful in 3m20s
Docker images / backend-image (push) Successful in 2m25s
Docker images / publish-release (push) Successful in 13s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m34s
Docker images / updater-image (push) Successful in 43s
Docker images / notify-webhook (push) Skipped
- Added support for Instagram in traffic flow resolution and classification, including new test cases to validate its behavior.
- Introduced `INSTAGRAM` brand handling in the traffic flow logic, ensuring accurate service identification for Instagram-related traffic.
- Updated existing tests to cover various scenarios involving Instagram, including IP resolution and service categorization.
- Enhanced the service brand icon component to include an Instagram icon for better visual representation.

Co-authored-by: Cursor <[email protected]>
2026-09-12 11:20:16 +07:00
DenozordecandCursor ee9804f1bb feat(traffic-flow): enhance flow analytics and IP handling
Docker images / frontend-image (push) Successful in 3m20s
Docker images / updater-image (push) Successful in 51s
Docker images / backend-image (push) Successful in 2m40s
Docker images / publish-release (push) Successful in 13s
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m29s
Docker images / notify-webhook (push) Skipped
- Introduced `canonicalIp` function to standardize IP address formats across the application, improving consistency in flow processing.
- Updated traffic flow analytics to utilize new endpoint resolution logic, enhancing accuracy in traffic classification.
- Enhanced tests for traffic flow analytics and IP handling, ensuring comprehensive coverage for new functionalities.
- Improved traffic flow destination resolution with additional test cases for various internet services, including Google and Fastly.

Co-authored-by: Cursor <[email protected]>
2026-09-12 01:51:23 +07:00
DenozordecandCursor 4ce6169d14 feat(traffic-flow): enhance traffic classification and map display
Docker images / frontend-image (push) Successful in 2m42s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m30s
Docker images / publish-release (push) Successful in 13s
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m16s
Docker images / notify-webhook (push) Skipped
- Added a new state variable `mapAsnLoaded` to track ASN loading status in the NetworkMapPage.
- Updated the page to conditionally display a message when ASN data is not loaded, improving user feedback.
- Enhanced traffic classification tests to include additional scenarios for Google and YouTube services, ensuring comprehensive coverage.
- Introduced a new function `pickMapInternetDest` to improve destination selection logic in traffic flow processing.
- Updated the traffic flow destination classification to handle new conditions and improve accuracy.

Co-authored-by: Cursor <[email protected]>
2026-09-12 01:07:26 +07:00
DenozordecandCursor 60a0970d73 feat(traffic-flow): enhance traffic flow analytics and overlay configuration
Docker images / frontend-image (push) Successful in 3m22s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 2m55s
Docker images / publish-release (push) Successful in 12s
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m42s
Docker images / notify-webhook (push) Skipped
- Updated the traffic flow analytics to include new state variables for named bytes, total bytes, and window seconds, improving data granularity.
- Modified the NetworkMapPage to display classified traffic data, including a new section for showing classified and total bytes.
- Enhanced the applyFlowOverlay function to support a new option for disabling GRE fast path, allowing for more flexible traffic flow configurations.
- Added a toggle in the FlowOverlaySheet component to enable or disable the GRE fast path setting, improving user control over traffic processing.
- Updated tests to validate the new functionalities and ensure accurate traffic flow analytics.

Co-authored-by: Cursor <[email protected]>
2026-09-12 00:03:13 +07:00
DenozordecandCursor fc29dcede7 feat(traffic-flow): add NAT fields to flow processing and analytics
Docker images / frontend-image (push) Successful in 3m30s
Docker images / backend-image (push) Successful in 2m51s
Docker images / publish-release (push) Successful in 9s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 45s
Docker images / notify-webhook (push) Skipped
- Introduced new fields for NAT source and destination IPs, as well as their respective ports, in the flow data model.
- Updated database schema and migration scripts to accommodate the new NAT fields in the `flow_buckets` table.
- Enhanced flow analytics and processing functions to utilize the new NAT fields, improving accuracy in traffic flow analysis.
- Added tests to validate the handling of NAT data in various scenarios, ensuring robustness in flow processing.

Co-authored-by: Cursor <[email protected]>
2026-09-11 23:01:48 +07:00
DenozordecandCursor 5f774ce26e feat(gre): enhance GRE tunnel status handling and IPsec configuration
Docker images / frontend-image (push) Successful in 3m1s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m31s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m18s
Docker images / notify-webhook (push) Skipped
- Introduced a new `greStatusMeta` function to streamline status retrieval and default handling.
- Updated GRE tunnel command generation to improve IPsec configuration handling, including optional fields for encryption and authentication algorithms.
- Enhanced the GRE tunnels data grid to display IPsec settings more clearly, including handling cases where certain parameters may be undefined.
- Refactored status display logic in the GRE page to utilize the new status meta function for consistency.

Co-authored-by: Cursor <[email protected]>
2026-09-11 15:39:32 +07:00
DenozordecandCursor 25b82997b6 feat(config-revisions): implement configuration history for Firewall, GRE, and WireGuard
Docker images / frontend-image (push) Successful in 2m51s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m49s
Docker images / publish-release (push) Successful in 12s
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m20s
Docker images / notify-webhook (push) Skipped
- Added configuration history management for Firewall, GRE, and WireGuard pages, enabling users to view and restore previous configurations.
- Introduced new components for displaying configuration history and integrated them into the respective pages.
- Enhanced API routes to support fetching and restoring configuration revisions, ensuring data consistency across the application.
- Updated state management to handle loading and restoring states effectively, improving user experience during data operations.
- Enhanced tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <[email protected]>
2026-09-11 14:03:00 +07:00
DenozordecandCursor b4a3c3a925 feat(filters, recursive-routes): enhance configuration history and live data handling
- Introduced configuration history management in Filters and Recursive Routes pages, allowing users to view and restore previous configurations.
- Updated state management to handle live data loading and error states more effectively, improving user experience during data fetching.
- Added new components for displaying configuration history and integrated them into existing pages.
- Enhanced API interactions to support fetching and applying configuration revisions, ensuring data consistency across the application.
- Updated tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <[email protected]>
2026-09-11 12:15:36 +07:00
DenozordecandCursor 9c0ee7940e feat(bgp, vxlan, ospf): enhance server data handling and introduce new routes
Docker images / frontend-image (push) Successful in 2m50s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m40s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m40s
Docker images / notify-webhook (push) Skipped
- Added support for fetching and displaying server data in BGP, VXLAN, and OSPF pages, improving the overall user experience.
- Introduced new backend routes for OSPF and VXLAN, allowing for better data management and retrieval.
- Implemented mapping functions for backend server data to frontend types, ensuring consistency across components.
- Enhanced the sidebar to display counts for BGP sessions, VXLAN tunnels, and containers, providing users with quick insights into their network status.
- Updated tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <[email protected]>
2026-09-11 11:18:04 +07:00
DenozordecandCursor 5750590b68 feat(traffic-flow): add rebuild facts endpoint and enhance traffic flow analytics
Docker images / frontend-image (push) Successful in 3m15s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m50s
Docker images / publish-release (push) Successful in 9s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m6s
Docker images / notify-webhook (push) Skipped
- Introduced a new endpoint `/traffic/flow/rebuild-facts` to rebuild flow facts from buckets, improving data accuracy and management.
- Updated traffic flow analytics to utilize the new `resolveInternetDest` function for better destination resolution.
- Enhanced tests for traffic flow IP handling and added new utility functions for managing internet destinations.

Co-authored-by: Cursor <[email protected]>
2026-09-11 10:32:51 +07:00
DenozordecandCursor 3c42c114f5 refactor(statistics): improve component structure and enhance state management
Docker images / frontend-image (push) Successful in 4m11s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m18s
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m17s
Docker images / notify-webhook (push) Skipped
- Introduced Suspense for lazy loading in StatisticsPage to optimize rendering.
- Refactored StatisticsPage to separate inner logic into StatisticsPageInner for better readability.
- Updated PeriodSelector to utilize useCallback for handling state changes, improving performance and clarity.

Co-authored-by: Cursor <[email protected]>
2026-09-11 09:38:31 +07:00
DenozordecandCursor 5aef419582 fix(statistics): считать уникальный payload без дублей hops
Docker images / frontend-image (push) Successful in 3m13s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 2m52s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m10s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-11 01:27:40 +07:00
DenozordecandCursor 0c0dfa1df7 fix(statistics): не двоить overlay и транзит в кубе
Docker images / frontend-image (push) Successful in 3m25s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 3m9s
Docker images / publish-release (push) Successful in 14s
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m18s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-11 00:46:25 +07:00
DenozordecandCursor c162a41bc0 fix(network-map): подписать unbound-пути парой серверов
Docker images / frontend-image (push) Successful in 3m29s
Docker images / updater-image (push) Successful in 52s
Docker images / backend-image (push) Successful in 2m41s
Docker images / publish-release (push) Successful in 12s
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-10 22:19:05 +07:00
DenozordecandCursor 97e43b2335 feat(statistics): enhance interface handling and data aggregation
Docker images / frontend-image (push) Successful in 3m24s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m39s
Docker images / publish-release (push) Successful in 15s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m29s
Docker images / notify-webhook (push) Skipped
Updated the statistics aggregation service to improve interface resolution and data handling. Introduced new functions for managing interface aliases and collapsing server interface rows, ensuring accurate data representation. Enhanced test coverage for interface resolution and added checks for new functionality.

- Implemented `factIfaceAliases` and `collapseServerIfaceRows` for better interface data management.
- Updated `resolveIfaceName` to handle additional cases for interface indexing.
- Enhanced tests for interface resolution and aggregation logic.

Co-authored-by: Cursor <[email protected]>
2026-09-10 21:49:50 +07:00
DenozordecandCursor db21e1217c feat(network-map): enhance service selection logic and UI updates
Docker images / frontend-image (push) Successful in 2m52s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 2m20s
Docker images / publish-release (push) Successful in 11s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m19s
Docker images / notify-webhook (push) Skipped
Refactor the service selection mechanism to utilize a live reference for selected services, ensuring accurate display of service details. Update UI components to reflect changes in service selection, including dynamic updates for service metrics and paths. Additionally, introduce new utility functions for deduplicating flow rows across exporters in the backend, improving data handling in traffic flow analysis.

- Updated service selection logic in NetworkMapPage component.
- Enhanced UI to display live service data.
- Added deduplication functions in traffic-flow-dedup module for improved data integrity.

Co-authored-by: Cursor <[email protected]>
2026-09-10 19:54:16 +07:00
DenozordecandCursor 5bb9066be8 feat(statistics): добавить BI-разрез и сводную матрицу
Docker images / frontend-image (push) Successful in 3m30s
Docker images / updater-image (push) Successful in 52s
Docker images / backend-image (push) Successful in 3m8s
Docker images / publish-release (push) Successful in 14s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m23s
Docker images / notify-webhook (push) Skipped
Сопоставить клиентов с ifIndex как на карте трафика, чтобы KPI пользователей не обнулялся. На экране — разрез остальных измерений и сводная матрица.

Co-authored-by: Cursor <[email protected]>
2026-09-10 16:07:25 +07:00
DenozordecandCursor b1fd259f10 fix(statistics): убрать параметры хранения с родительских партиций
Docker images / frontend-image (push) Successful in 3m26s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m32s
Docker images / publish-release (push) Successful in 13s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m25s
Docker images / notify-webhook (push) Skipped
PostgreSQL 42809: FILLFACTOR и autovacuum нельзя задавать на partitioned parent — только на листьях.

Co-authored-by: Cursor <[email protected]>
2026-09-10 13:36:35 +07:00
DenozordecandCursor 3687bb8fa2 feat(statistics): добавить раздел статистики трафика
Docker images / frontend-image (push) Successful in 4m47s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m53s
Docker images / publish-release (push) Successful in 13s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m19s
Docker images / notify-webhook (push) Skipped
Куб IPFIX час+день с AND-слайсами и экраном отчётности /statistics, живой /traffic не меняем.

Co-authored-by: Cursor <[email protected]>
2026-09-10 11:57:47 +07:00
Denozordec a80caf5676 feat(netflow): локальные GeoLite2-базы для стран и ASN потоков
Docker images / frontend-image (push) Successful in 4m31s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 3m1s
Docker images / publish-release (push) Successful in 15s
Docker images / prepare-release (push) Successful in 13s
Docker images / backend-test (push) Successful in 2m13s
Docker images / notify-webhook (push) Skipped
Lookup страны/ASN при ingest теперь идёт сначала по локальным mmdb
MaxMind GeoLite2 (Country + ASN, зеркало P3TERX, без ключей), с
мгновенным синхронным ответом для IPv4/IPv6; RIPEstat остаётся
fallback до первой загрузки баз и при промахе.

- backend/src/services/traffic-flow-geoip.ts: ридеры maxmind, фасад
  resolveFlowIp (geoip-first → RIPE-кэш), статус ридеров
- geoip-update-collector.ts: conditional GET по ETag, валидация пробоем
  8.8.8.8 (US / AS15169), атомарная подмена с .prev-откатом
- джоба планировщика geoip_update (по умолчанию раз в 7 дней),
  настройки geoip_settings + миграция 0004, API /api/geoip
  (GET/PUT/update), контракты @mmapp/contracts/geoip
- engine/analytics/map-hops переведены на resolveFlowIp
- секция «GeoIP-базы» в настройках NetFlow, метки джобы на странице
  сбора данных, тесты test:geoip
2026-09-09 23:33:31 +07:00
DenozordecandCursor d39e3454aa feat(network-map): показывать ASN-бренды по доле среди сервисов
Docker images / frontend-image (push) Successful in 3m15s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m44s
Docker images / publish-release (push) Successful in 15s
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m5s
Docker images / notify-webhook (push) Skipped
Долю считать среди именованных брендов, а не от всего окна. На карте всегда оставлять топ-8 и добавить иконки новых брендов.

Co-authored-by: Cursor <[email protected]>
2026-09-09 16:53:06 +07:00
DenozordecandCursor a8f2055c77 feat(netflow): расширить каталог ASN популярных сервисов
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 4m2s
Docker images / publish-release (push) Successful in 11s
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m5s
Docker images / notify-webhook (push) Skipped
В аналитике потока видны сети со своим AS (Apple, Steam, VK, Epic и другие), а не только Cloudflare/AWS. Holder и порты Steam не перебивают Cloudflare CIDR.

Co-authored-by: Cursor <[email protected]>
2026-09-09 13:18:58 +07:00
DenozordecandCursor cdeb97d841 feat(certificates): добавить отключение автообновления на странице сертификатов
Docker images / frontend-image (push) Successful in 3m19s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 2m45s
Docker images / publish-release (push) Successful in 15s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m7s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-08 16:21:50 +07:00
DenozordecandCursor 36c5305db7 feat(dashboard): собрать ops-дашборд на ReUI Frame с живыми данными
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 47s
Docker images / backend-image (push) Successful in 2m44s
Docker images / publish-release (push) Successful in 14s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m13s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-08 15:13:27 +07:00
Denozordec 5f2b4e2d40 feat(backups): replace button with checkbox for server selection in backup settings
Docker images / frontend-image (push) Successful in 3m10s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m29s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m4s
Docker images / notify-webhook (push) Skipped
Updated the BackupCreateSheet and BackupsSettings components to use checkboxes for selecting servers instead of buttons. This improves user interaction by allowing for easier selection and toggling of server states. The Checkbox component is now integrated for better accessibility and functionality.
2026-09-08 14:47:44 +07:00
DenozordecandCursor cff26813b9 feat(backups): добавить S3-хранилище и обновить экран бэкапов
Docker images / frontend-image (push) Successful in 4m37s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m6s
Docker images / publish-release (push) Successful in 16s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m18s
Docker images / notify-webhook (push) Skipped
Сохранять снимки в S3-compatible бакет, скачивать и удалять их вместе с локальными файлами.
Привести /backups к DNA /servers: Frame, KPI, фильтры и реальное восстановление.

Co-authored-by: Cursor <[email protected]>
2026-09-08 14:06:27 +07:00
DenozordecandCursor 39c8ec4a02 fix(netflow): починить live-аналитику и снизить запись в PostgreSQL
Docker images / frontend-image (push) Successful in 3m20s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 2m44s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m9s
Docker images / notify-webhook (push) Skipped
Коллектор снова отдаёт назначения в живом потоке. Ошибки записи видны в статусе, лишние перезаписи минутных агрегатов убраны.

Co-authored-by: Cursor <[email protected]>
2026-09-08 12:46:37 +07:00
DenozordecandCursor c6c859a495 perf(db): сжать схему PostgreSQL 18 и повторно загрузить SQLite
Docker images / frontend-image (push) Successful in 3m23s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m47s
Docker images / publish-release (push) Successful in 11s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m2s
Docker images / notify-webhook (push) Skipped
При первом рестарте wipe всех таблиц и импорт из SQLite в компактную схему. Retention через DROP PARTITION, lz4 и AIO worker.

Co-authored-by: Cursor <[email protected]>
2026-09-08 10:54:36 +07:00
DenozordecandCursor bf5cfff12c ci(backend): убрать конфликт контейнера PostgreSQL в тестах
Docker images / frontend-image (push) Successful in 3m16s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m0s
Docker images / publish-release (push) Successful in 13s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m4s
Docker images / notify-webhook (push) Skipped
На общем Docker-раннере имя mm-pg и порт 5432 сталкивались с прошлым запуском. Контейнер и порт теперь уникальны, старый mm-pg снимается.

Co-authored-by: Cursor <[email protected]>
2026-09-08 09:34:29 +07:00
DenozordecandCursor 30a8ec4420 fix(db): исправить импорт jsonb из sqlite
Docker images / backend-image (push) Skipped
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Skipped
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Failing after 12s
Docker images / notify-webhook (push) Skipped
JSON-колонки передаются строкой с приведением ::jsonb, иначе node-pg кодирует массивы как PostgreSQL array и ETL падает. Повтор импорта идёт, пока нет маркера. Том PG18 монтируется в /var/lib/postgresql.

Co-authored-by: Cursor <[email protected]>
2026-09-08 09:28:03 +07:00
2463 changed files with 305766 additions and 19580 deletions

No files matched your search

+4 -4
View File
@@ -1,17 +1,17 @@
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `fe6742e29c`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+4 -4
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project) ## Registry setup (one-time, per project)
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`: Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json ```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } } { "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -31,7 +31,7 @@ REUI_LICENSE_KEY=your-license-key
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`). The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing ## Installing
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary ## Free vs premium boundary
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on. - Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate. - Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade). If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
+21 -1
View File
@@ -1,6 +1,6 @@
# ReUI components # ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -362,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon. **Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert ## alert
**Required:** `Alert` > `AlertTitle` **Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild. ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`. - **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`. - **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens. - **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md). - **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium ## Free vs premium
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill. - **Free, no key:** the 22 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)). - **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index ## Component API index
+1 -1
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot: For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`. - `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...). - `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`. - `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
+3 -3
View File
@@ -45,8 +45,8 @@ If you already know the exact item name, skip `search`. Everything else is situa
## Error playbook ## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`. - **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples. - **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing?ref=skill (upgrade). Free accounts still get all components + examples.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`. - **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command. - **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
@@ -55,4 +55,4 @@ If you already know the exact item name, skip `search`. Everything else is situa
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API). - No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp. - The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+14 -4
View File
@@ -54,6 +54,12 @@ function formatSemver(parts) {
function bumpVersion(version, bump) { function bumpVersion(version, bump) {
const parts = parseSemver(version) const parts = parseSemver(version)
if (bump === "major") {
parts.major += 1
parts.minor = 0
parts.patch = 0
return formatSemver(parts)
}
if (bump === "minor") { if (bump === "minor") {
parts.minor += 1 parts.minor += 1
parts.patch = 0 parts.patch = 0
@@ -66,8 +72,8 @@ function bumpVersion(version, bump) {
function parseCommit(subject, body = "") { function parseCommit(subject, body = "") {
const match = CONVENTIONAL_RE.exec(subject.trim()) const match = CONVENTIONAL_RE.exec(subject.trim())
const breaking = const breaking =
/BREAKING CHANGE/i.test(body) || /BREAKING[ -]CHANGE:/i.test(body) ||
(match?.[1] && subject.includes(`${match[1]}!:`)) (match !== null && /^[^:]+!:\s/.test(subject.trim()))
if (!match) { if (!match) {
return { type: "other", scope: null, description: subject.trim(), bump: "patch" } return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
@@ -78,7 +84,9 @@ function parseCommit(subject, body = "") {
const description = match[3].trim() const description = match[3].trim()
let bump = "patch" let bump = "patch"
if (breaking || MINOR_TYPES.has(type)) { if (breaking) {
bump = "major"
} else if (MINOR_TYPES.has(type)) {
bump = "minor" bump = "minor"
} else if (PATCH_TYPES.has(type)) { } else if (PATCH_TYPES.has(type)) {
bump = "patch" bump = "patch"
@@ -251,7 +259,9 @@ function main() {
return return
} }
const bump = commits.some((commit) => commit.bump === "minor") ? "minor" : "patch" const bump = commits.some((commit) => commit.bump === "major")
? "major"
: commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
const version = bumpVersion(baseFromTag, bump) const version = bumpVersion(baseFromTag, bump)
const tag = `v${version}` const tag = `v${version}`
const publishedAt = new Date().toISOString() const publishedAt = new Date().toISOString()
+61
View File
@@ -0,0 +1,61 @@
import assert from "node:assert/strict"
import { execFileSync } from "node:child_process"
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { basename, dirname, join, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import test from "node:test"
const source = fileURLToPath(new URL("./compute-release.mjs", import.meta.url))
const cases = [
{ name: "исправление повышает patch", commits: [["fix(ui): исправить отображение"]], version: "1.47.7", bump: "patch" },
{ name: "новая возможность повышает minor", commits: [["feat(ui): добавить раздел"]], version: "1.48.0", bump: "minor" },
{ name: "breaking со scope повышает major", commits: [["chore(release)!: подготовить вторую основную версию"]], version: "2.0.0", bump: "major" },
{ name: "breaking без scope повышает major", commits: [["feat!: заменить контракт"]], version: "2.0.0", bump: "major" },
{ name: "footer со пробелом повышает major", commits: [["fix(api): заменить контракт", "BREAKING CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
{ name: "footer с дефисом повышает major", commits: [["fix(api): заменить контракт", "BREAKING-CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
{ name: "major имеет приоритет в смешанной истории", commits: [["chore(release)!: подготовить выпуск"], ["feat(ui): добавить раздел"], ["fix(ui): исправить отображение"]], version: "2.0.0", bump: "major" },
{ name: "после второй версии сохраняется patch", base: "2.0.0", commits: [["fix(ui): исправить отображение"]], version: "2.0.1", bump: "patch" },
{ name: "после второй версии сохраняется minor", base: "2.0.0", commits: [["feat(ui): добавить раздел"]], version: "2.1.0", bump: "minor" },
{ name: "готовый тег не выпускается повторно", base: "2.0.0", commits: [], version: "2.0.0", bump: "none" },
]
for (const scenario of cases) test(scenario.name, () => {
const parent = resolve(tmpdir())
const root = mkdtempSync(join(parent, "mm-release-"))
const git = (...args) => execFileSync("git", args, { cwd: root, stdio: "pipe" })
try {
mkdirSync(join(root, ".ci/scripts"), { recursive: true })
copyFileSync(source, join(root, ".ci/scripts/compute-release.mjs"))
git("init", "--initial-branch=main")
git("config", "user.name", "Release Test")
git("config", "user.email", "[email protected]")
git("config", "core.hooksPath", ".git/hooks")
writeFileSync(join(root, "fixture"), "baseline")
git("add", "fixture")
git("commit", "-m", "chore(test): создать исходную базу")
git("tag", `v${scenario.base ?? "1.47.6"}`)
for (const [index, [subject, body]] of scenario.commits.entries()) {
writeFileSync(join(root, "fixture"), `change ${index}`)
git("add", "fixture")
git("commit", "-m", subject, ...(body ? ["-m", body] : []))
}
execFileSync(process.execPath, [join(root, ".ci/scripts/compute-release.mjs")], {
cwd: root, stdio: "pipe",
env: { ...process.env, BASE_VERSION: "1.0.0", RELEASE_OUTPUT_DIR: ".ci/release", GITHUB_OUTPUT: join(root, "outputs"), GITEA_REPOSITORY: "test/repo", GITEA_SERVER_URL: "https://example.invalid" },
})
const manifest = JSON.parse(readFileSync(join(root, ".ci/release/release-manifest.json"), "utf8"))
const output = readFileSync(join(root, "outputs"), "utf8")
assert.equal(manifest.version, scenario.version)
assert.equal(manifest.tag, `v${scenario.version}`)
assert.equal(manifest.releaseUrl, `https://example.invalid/test/repo/releases/tag/v${scenario.version}`)
assert.match(output, new RegExp(`^bump=${scenario.bump}$`, "m"))
assert.match(output, new RegExp(`^should_release=${scenario.bump !== "none"}$`, "m"))
if (scenario.bump !== "none") assert.match(readFileSync(join(root, ".ci/release/release_notes.md"), "utf8"), new RegExp(`# v${scenario.version.replaceAll(".", "\\.")}`))
} finally {
assert.equal(dirname(resolve(root)), parent)
assert.ok(basename(root).startsWith("mm-release-"))
rmSync(root, { recursive: true, force: true })
}
})
+4 -4
View File
@@ -1,17 +1,17 @@
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `fe6742e29c`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+4 -4
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project) ## Registry setup (one-time, per project)
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`: Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json ```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } } { "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -31,7 +31,7 @@ REUI_LICENSE_KEY=your-license-key
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`). The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing ## Installing
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary ## Free vs premium boundary
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on. - Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate. - Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade). If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
+21 -1
View File
@@ -1,6 +1,6 @@
# ReUI components # ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -362,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon. **Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert ## alert
**Required:** `Alert` > `AlertTitle` **Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild. ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`. - **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`. - **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens. - **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md). - **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium ## Free vs premium
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill. - **Free, no key:** the 22 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)). - **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index ## Component API index
+1 -1
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot: For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`. - `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...). - `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`. - `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
+3 -3
View File
@@ -45,8 +45,8 @@ If you already know the exact item name, skip `search`. Everything else is situa
## Error playbook ## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`. - **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples. - **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing?ref=skill (upgrade). Free accounts still get all components + examples.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`. - **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command. - **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
@@ -55,4 +55,4 @@ If you already know the exact item name, skip `search`. Everything else is situa
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API). - No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp. - The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+3
View File
@@ -0,0 +1,3 @@
[mcp_servers.reui]
url = "https://mcp.reui.io/api/mcp?style=base-nova"
bearer_token_env_var = "REUI_LICENSE_KEY"
+5 -1
View File
@@ -29,7 +29,11 @@ alwaysApply: true
## Minor (`1.x.0`) ## Minor (`1.x.0`)
`feat:`, `feat!`, footer `BREAKING CHANGE` — только для заметного core-функционала. `feat:` — новая пользовательская возможность.
## Major (`2.0.0` после `1.x`)
`тип!:`, `тип(scope)!:` или footer `BREAKING CHANGE:` / `BREAKING-CHANGE:` повышают major, обнуляя minor и patch. При смешанной истории major имеет приоритет над minor и patch. Версия, тег, manifest и Docker tags вычисляются CI; package.json вручную не меняется. Для согласованного перехода на вторую основную версию используется `chore(release)!:` с русским описанием.
## Примеры ## Примеры
+4 -4
View File
@@ -6,18 +6,18 @@ alwaysApply: false
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `fe6742e29c`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+4 -4
View File
@@ -1,17 +1,17 @@
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `fe6742e29c`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+4 -4
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project) ## Registry setup (one-time, per project)
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`: Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json ```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } } { "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -31,7 +31,7 @@ REUI_LICENSE_KEY=your-license-key
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`). The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing ## Installing
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary ## Free vs premium boundary
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on. - Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate. - Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade). If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
+21 -1
View File
@@ -1,6 +1,6 @@
# ReUI components # ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -362,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon. **Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert ## alert
**Required:** `Alert` > `AlertTitle` **Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild. ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`. - **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`. - **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens. - **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md). - **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium ## Free vs premium
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill. - **Free, no key:** the 22 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)). - **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index ## Component API index
+1 -1
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot: For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`. - `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...). - `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`. - `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
+3 -3
View File
@@ -45,8 +45,8 @@ If you already know the exact item name, skip `search`. Everything else is situa
## Error playbook ## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`. - **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples. - **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing?ref=skill (upgrade). Free accounts still get all components + examples.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`. - **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command. - **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
@@ -55,4 +55,4 @@ If you already know the exact item name, skip `search`. Everything else is situa
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API). - No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp. - The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+2
View File
@@ -0,0 +1,2 @@
deploy/netflow-agent/*.sh text eol=lf
deploy/netflow-agent/*.py text eol=lf
+106 -7
View File
@@ -25,6 +25,9 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Test release version calculation
run: node --test .ci/scripts/compute-release.test.mjs
- name: Compute release metadata - name: Compute release metadata
id: release id: release
shell: bash shell: bash
@@ -53,30 +56,66 @@ jobs:
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
docker run -d --name mm-pg \ NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
docker rm -f "$NAME" 2>/dev/null || true
# Docker-backed Gitea runners execute this step in a separate container.
# Join its network; a published host loopback port is not reachable here.
NETWORKS=$(docker inspect --format '{{range $name, $network := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$(cat /etc/hostname)" 2>/dev/null || true)
if [ -n "$NETWORKS" ]; then
test "$(printf '%s\n' "$NETWORKS" | wc -l)" -eq 1
NETWORK_ARGS=(--network "$NETWORKS" --network-alias "$NAME")
PG_HOST="$NAME"
PG_PORT=5432
else
# Host-based runners can use a Docker-assigned ephemeral host port.
NETWORK_ARGS=(-p 127.0.0.1::5432)
PG_HOST=127.0.0.1
fi
docker run -d --name "$NAME" --rm \
-e POSTGRES_USER=mmapp \ -e POSTGRES_USER=mmapp \
-e POSTGRES_PASSWORD=mmapp \ -e POSTGRES_PASSWORD=mmapp \
-e POSTGRES_DB=mmapp \ -e POSTGRES_DB=mmapp \
-p 5432:5432 \ "${NETWORK_ARGS[@]}" \
postgres:18-alpine postgres:18-alpine
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
if [ "$PG_HOST" = 127.0.0.1 ]; then
PG_PORT=$(docker port "$NAME" 5432/tcp | awk -F: '{print $NF}')
fi
echo "DATABASE_URL=postgres://mmapp:mmapp@${PG_HOST}:${PG_PORT}/mmapp" >> "${GITHUB_ENV}"
for i in $(seq 1 40); do for i in $(seq 1 40); do
if docker exec mm-pg pg_isready -U mmapp -d mmapp; then if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
exit 0 exit 0
fi fi
sleep 1 sleep 1
done done
echo "PostgreSQL не поднялся" echo "PostgreSQL не поднялся"
docker logs "$NAME" || true
exit 1 exit 1
- name: Install and test backend - name: Install and test backend
shell: bash shell: bash
env: env:
DATABASE_URL: postgres://mmapp:[email protected]:5432/mmapp REQUIRE_TEST_POSTGRES: "1"
run: | run: |
set -euo pipefail set -euo pipefail
npm ci npm ci
node --input-type=module <<'NODE'
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 8000 });
try {
await pool.query('SELECT 1');
console.log('PostgreSQL reachable from the test process');
} finally {
await pool.end();
}
NODE
npm run test --prefix backend npm run test --prefix backend
- name: Stop PostgreSQL
if: always()
shell: bash
run: docker rm -f "${PG_CONTAINER:-}" 2>/dev/null || true
backend-image: backend-image:
needs: needs:
- prepare-release - prepare-release
@@ -116,6 +155,8 @@ jobs:
cp -R packages/contracts/src "$STAGING/packages/contracts/" cp -R packages/contracts/src "$STAGING/packages/contracts/"
cp backend/package.json backend/tsconfig.json "$STAGING/backend/" cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
cp -R backend/src "$STAGING/backend/" cp -R backend/src "$STAGING/backend/"
mkdir -p "$STAGING/deploy/netflow-agent"
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
if [ -d backend/drizzle ]; then if [ -d backend/drizzle ]; then
cp -R backend/drizzle "$STAGING/backend/" cp -R backend/drizzle "$STAGING/backend/"
fi fi
@@ -290,12 +331,69 @@ jobs:
org.opencontainers.image.revision=${{ gitea.sha }} org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }} org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
netflow-agent-image:
needs: prepare-release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure netflow-agent image name
shell: bash
run: |
set -euo pipefail
owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-netflow-agent" >> "${GITHUB_ENV}"
- name: Test controller and installer
run: |
python3 -m unittest discover -s deploy/netflow-agent -p "test_*.py" -v
bash -n deploy/netflow-agent/install.sh
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Verify image with real WireGuard
run: |
docker build --target test -t mm-netflow-agent-test deploy/netflow-agent
docker run --rm --cap-drop ALL --cap-add NET_ADMIN --read-only --tmpfs /tmp -e FLOW_AGENT_KERNEL_TEST=1 --entrypoint python3 mm-netflow-agent-test -m unittest discover -s /app -p "test_agent.py" -v
- name: Build and push netflow-agent image
uses: docker/build-push-action@v5
with:
context: deploy/netflow-agent
file: deploy/netflow-agent/Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
cache-from: type=gha,scope=netflow-agent
cache-to: type=gha,mode=max,scope=netflow-agent
labels: |
org.opencontainers.image.title=mmapp-netflow-agent
org.opencontainers.image.description=MikroTik Manager local WireGuard controller
org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
publish-release: publish-release:
needs: needs:
- prepare-release - prepare-release
- backend-image - backend-image
- frontend-image - frontend-image
- updater-image - updater-image
- netflow-agent-image
if: needs.prepare-release.outputs.should_release == 'true' if: needs.prepare-release.outputs.should_release == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -377,17 +475,18 @@ jobs:
fi fi
notify-webhook: notify-webhook:
if: ${{ secrets.DEPLOY_WEBHOOK_URL != '' }}
needs: needs:
- prepare-release - prepare-release
- backend-image - backend-image
- frontend-image - frontend-image
- netflow-agent-image
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
steps: steps:
- name: Notify deploy webhook - name: Notify deploy webhook
if: env.WEBHOOK_URL != ''
shell: bash shell: bash
env:
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
run: | run: |
set -euo pipefail set -euo pipefail
curl --fail --silent --show-error --request POST \ curl --fail --silent --show-error --request POST \
+15
View File
@@ -31,6 +31,10 @@ pnpm-debug.log*
# coverage # coverage
coverage/ coverage/
test-results/
playwright-report/
test-results/
playwright-report/
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
@@ -55,3 +59,14 @@ temp/
.cursor/mcp.json .cursor/mcp.json
.mcp.json .mcp.json
opencode.json opencode.json
# Local codegraph process
.codegraph/daemon.pid
# Python controller tests
__pycache__/
*.pyc
# NetFlow agent host state (when Compose is installed in a checkout)
/data/netflow-wireguard/
/data/netflow-agent-socket/
+4 -4
View File
@@ -1,17 +1,17 @@
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `fe6742e29c`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+4 -4
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project) ## Registry setup (one-time, per project)
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`: Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json ```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } } { "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -31,7 +31,7 @@ REUI_LICENSE_KEY=your-license-key
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`). The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing ## Installing
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary ## Free vs premium boundary
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on. - Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate. - Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade). If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
+21 -1
View File
@@ -1,6 +1,6 @@
# ReUI components # ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -362,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon. **Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert ## alert
**Required:** `Alert` > `AlertTitle` **Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild. ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`. - **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`. - **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens. - **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md). - **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium ## Free vs premium
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill. - **Free, no key:** the 22 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)). - **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index ## Component API index
+1 -1
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot: For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`. - `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...). - `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`. - `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
+3 -3
View File
@@ -45,8 +45,8 @@ If you already know the exact item name, skip `search`. Everything else is situa
## Error playbook ## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`. - **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples. - **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing?ref=skill (upgrade). Free accounts still get all components + examples.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`. - **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command. - **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
@@ -55,4 +55,4 @@ If you already know the exact item name, skip `search`. Everything else is situa
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API). - No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp. - The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
@@ -0,0 +1,63 @@
# Локальные GeoLite2-базы (Country + ASN) для потоков по странам и ASN
## Контекст
Сейчас страна и ASN для netflow-потоков резолвятся через внешний RIPEstat API (`backend/src/services/traffic-flow-ripe.ts`): лимит 30 новых префиксов/мин, очередь на 90, кэш в PG `flow_ip_meta`. Новые IP «дозревают» с задержкой, IPv6 не покрывается (кэш индексируется только по IPv4). Локальные mmdb-базы дают мгновенный синхронный lookup всех IP без внешних вызовов.
Решения (подтверждены):
- Источник — **MaxMind GeoLite2 через P3TERX-зеркало**: `https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb` и `.../GeoLite2-ASN.mmdb`. Без регистрации, ключей и tar-распаковки. Точность по стране у GeoLite2 и IPinfo паритетная (<1% ошибок у обоих, arXiv 2026); выбран P3TERX за надёжность зеркала (5.2k звёзд) и преемственность: текущий RIPE-путь и так читает GeoLite (`maxmind-geo-lite`), история в кэше остаётся консистентной.
- **RIPEstat остаётся fallback** (до первой загрузки баз / если lookup не дал результата).
- City-базу не качаем (lat/lng фронтенд не использует).
## Изменения
### 1. Зависимость
- `npm install -w mikrotik-manager-backend maxmind` — sync-чтение mmdb, встроенные TS-типы, без транзитивных зависимостей, Node 22 ок.
### 2. Новый сервис `backend/src/services/traffic-flow-geoip.ts`
(по конвенциям окружения traffic-flow-*: контракт → маршрут → сервис, без БД-логики в маршрутах)
- Каталог: `backend/storage/geoip/` (конвенция `storage/backups`), файлы `GeoLite2-Country.mmdb`, `GeoLite2-ASN.mmdb`.
- `initGeoip()` — открыть ридеры best-effort при старте (из `index.ts` рядом с `startTrafficFlowListener`), независимо от настроек автообновления: файлы есть — работают.
- `lookupGeoip(ip): FlowIpMeta | null` — синхронно: `country.iso_code` (fallback `registered_country.iso_code`) с валидацией `isIsoCountry`, ASN = `autonomous_system_number`, holder = `autonomous_system_organization`; приватные IP → negative-запись как в RIPE (`isNonPublicIp`); в PG не пишем (lookup и так быстрый). IPv6 поддержан ридером.
- `resolveFlowIp(ip)` — фасад: `lookupGeoip(ip) ?? lookupRipeCached(ip)`; главный экспорт для потребителей.
- `geoipStatus()` — loaded, даты сборки баз (метаданные mmdb). Тест-хук `setGeoipReadersForTests`. Смена ридеров после обновления — атомарная замена ссылок.
### 3. Коллектор `backend/src/services/geoip-update-collector.ts`
`collectGeoipUpdateOnce()` по образцу `certificate-renew-collector.ts`:
1. Conditional GET с ETag/If-None-Match из настроек → 304 = skip (фолбэк-сравнение: размер/содержимое).
2. Скачивание в `*.tmp` через глобальный `fetch` + AbortController с таймаутом (внешний HTTP из service-слоя — по правилу fastify-backend-drizzle).
3. Валидация: открыть ридер из tmp-файла, пробой 8.8.8.8 (страна US, ASN 15169).
4. `fs.rename` атомарная подмена, старый файл → `*.prev` (откат, если новый ридер не открылся).
5. Перезагрузка ридеров, статус в настройках; snapshot для `scheduler_runs` (checked/downloaded/skipped/bytes/error).
### 4. Планировщик (`backend/src/services/scheduler.ts`)
- `JOB_KEYS` += `geoip_update`; case в `runSchedulerJobBody`; блок в `refreshScheduler()` по образцу `certificates_renew`: интервал `Math.max(6ч, updateIntervalSec*1000)`, по умолчанию 7 дней (upstream обновляется еженедельно) + немедленный первый запуск при включённой настройке.
### 5. Схема и миграция
- `backend/src/db/schema.ts`: singleton `geoip_settings` — `enabled` (default true), `updateIntervalSec` (default 604800), `lastCheckAt`, `lastSuccessAt`, `lastError`, `countryBuildAt`, `asnBuildAt`, `etagsJson` (jsonb), `createdAt/updatedAt`.
- Миграция: `npm run db:generate` → файл в `backend/drizzle/`.
### 6. API + контракты
- `packages/contracts/src/geoip.ts`: zod-схемы настроек/статуса (все входы — Zod, по правилам проекта).
- Новый `backend/src/routes/geoip.ts`, регистрация в `index.ts` с prefix `/api`:
- `GET /api/geoip` — настройки + статус (ready, даты сборки, последняя проверка/ошибка);
- `PUT /api/geoip` — сохранить настройки, затем `refreshScheduler()`;
- `POST /api/geoip/update` — запустить загрузку сейчас (409, если уже идёт; флаг-гард как в коллекторах).
### 7. Интеграция в пайплайн (geoip-first, RIPE-fallback)
- `traffic-flow-engine.ts` (`queueParsedFlows`, ~строка 336): `lookupRipeCached` → `resolveFlowIp`. Логика misses не меняется: при готовом mmdb публичные IP (v4+v6) резолвятся сразу, очередь RIPE пустеет; до скачивания баз — прежнее поведение.
- Остальные вызовы `lookupRipeCached` → `resolveFlowIp` (grep: как минимум `traffic-flow-analytics.ts` ~258–271).
- `classifyFlowDst`/бренды не трогаем: holder из mmdb (org name) встаёт в существующие `HOLDER_BRANDS`-регулярки как есть.
### 8. Frontend (по next-shadcn-production / ui-guardian: только переиспользование)
- Секция «GeoIP-базы (GeoLite2)» внутри существующей `components/traffic/netflow-settings-panel.tsx`: статус (готово/не скачано, даты сборки Country/ASN, последняя проверка, ошибка), тумблер автообновления, интервал, кнопка «Обновить сейчас» с индикатором. Только уже используемые в панели примитивы (Switch/Button/поля) — никаких новых визуальных паттернов и Card-shell. API-клиент через существующие http-хелперы.
### 9. Хаускипинг, тесты, проверки
- `backend/.gitignore`: `storage/geoip/`.
- Тесты `backend/src/services/traffic-flow-geoip.test.ts` + скрипт `test:geoip` (по образцу `test:traffic-flow`): приоритет фасада (geoip hit → RIPE не зовётся; miss → fallback), negative на приватных IP, фильтрация EU/ZZ через `isIsoCountry`, коллектор с мокнутым fetch (304-skip, битый файл → подмены нет, `.prev` сохранён), dims по стране/ASN с засеянным ридером.
- Проверки после реализации (обязательно по правилам): типы/сборка бэка (`npm run build -w mikrotik-manager-backend`), типы фронта при правке UI (`npx tsc --noEmit`), `npm run test:geoip` и `test:traffic-flow`; предупреждения не игнорировать.
- Коммит: `feat(netflow): <subject по-русски>` — новая пользовательская фича (мгновенные страна/ASN в потоках), по commit-messages-ru.
- README: короткий раздел о GeoIP; примечание, что в Docker `storage/geoip` ephemeral без тома — базы перекачаются после пересоздания контейнера (~17 МБ); при желании смонтировать volume.
## Что это даёт
Страна и ASN появляются у потока мгновенно при ingest (включая IPv6), без ограничения скорости RIPE; dims `country`/`asn` в `flow_daily_dims`, аналитика (карта, топы, monthly) становятся полными сразу. Внешняя зависимость от stat.ripe.net остаётся только как fallback до первой загрузки баз.
@@ -0,0 +1,46 @@
# IKEv2/IPsec VPN: клиенты, IP-адреса и сертификаты — по образцу WireGuard
## Архитектура (как у WireGuard)
Роутер — источник истины: live-чтение через существующий REST-клиент (`mikrotik.ts`), мутации прямыми REST-вызовами, история через `config_revisions` (новая секция `"ipsec"`; `section` — TEXT без CHECK → **SQL-миграция не нужна**). Новых таблиц нет. Объекты, созданные мастером/при создании клиентов, помечаются managed-комментарием `mm-ipsec` (по образцу managed-markers).
## Модель RouterOS (что создаём/читаем)
Мастер инициализации сервера (per router): CA-сертификат (`/certificate add` + `sign`, key-usage=key-cert-sign,crl-sign), серверный сертификат (CN=адрес/домен, SAN, tls-server), `/ip/ipsec/peer` (passive, exchange-mode=ike2, certificate=серверный cert), `/ip/ipsec/profile`+`proposal` (aes-256/sha256/modp2048, pfs), `/ip pool` + `/ip/ipsec/mode-config` «VPN» (address-pool, dns), policy-template, и опциональное (default on, чекбокс) managed srcnat masquerade-правило «интернет клиентам».
Клиент = `/ip/ipsec/identity`: **Сертификат** (`/certificate add` CN=<имя> → `sign` ca=<CA> → `export-certificate type=pkcs12` → файл `.p12` скачивается с роутера → identity match-by=certificate) или **PSK** (auth-method=pre-shared-key, remote-id+secret). IP: «из пула» (общий mode-config) или статический (персональный `mc-<user>` mode-config с `address=x.x.x.x/32`). Онлайн — `/ip/ipsec/active-peers`.
Клиентские загрузки: `.p12` (cert+key+CA, passphrase) + strongSwan `.sswan` (генерация на бэкенде) + текстовая инструкция. Перекачка `.p12` — повторный export с новой passphrase (ключ остаётся на роутере).
## Бэкенд
1. **`mikrotik.ts`**: `downloadFile(name): Promise<Buffer>` — бинарно-безопасный GET `/rest/file/<name>` (с fallback `flash/<name>`, как у `uploadTextFile`; текущий конвейер парсит JSON как utf8 — нужен Buffer-режим); хелперы `signCertificate` (с поллингом готовности, sign небыстрый), `exportCertificatePkcs12(name, passphrase)`.
2. **`services/ipsec-config.ts`** (чистые, тестируемые): генератор `.sswan` и инструкции, поиск свободного IP в пуле, naming-конвенции (`ipsec-ca`, `ipsec-server`, `mc-<user>`, `ipsec-user-<name>`).
3. **`services/ipsec-ca.ts`**: `ensureCa/ensureServerCert/issueClientCert/exportClientP12` — add → sign (poll) → export → download.
4. **`services/ipsec-ros.ts`**: put/patch/delete для `/ip/ipsec/{peer,identity,mode-config,profile,proposal,policy}`, `/ip/pool`, NAT managed-правило.
5. **`services/ipsec-live.ts`**: `fetchIpsecState(server)` — параллельные GET peer/identity/mode-config/pool/active-peers/certificate (+какие из них наши по маркеру/имени) → DTO; `listIpsec()` fan-out с failures; `countIpsecClients()` для сайдбара.
6. **`entity-snapshots.ts`**: `canonicalIpsecSnapshot/parseIpsecSnapshot/planIpsecRestore` (секреты через `isHiddenSecret`); `CONFIG_SECTIONS` += `"ipsec"`; enum в `schema.ts` (миграции нет).
7. **`routes/ipsec.ts`**: `GET /ipsec` (+observed revision), `POST /ipsec/server/init`, `DELETE /ipsec/server/:serverId` (только managed-объекты), `POST /ipsec/users` (ответ включает одноразовый p12 base64), `PATCH /ipsec/users/:serverId/:rosId` (имя/статический IP/psk), `DELETE /ipsec/users/...` (identity + mc + опционально клиентский cert), `POST /ipsec/users/:serverId/:rosId/cert` (перекачка p12), `GET /ipsec/revisions` + `POST /ipsec/revisions/:id/restore` — всё по образцу `routes/wireguard.ts` (`captureAndAppendRevision`). Регистрация в `index.ts`; `/api/ipsec` в обе группы network-правил `permissions.ts`.
8. **Модуль Пользователи**: `InterfaceType` += `"ipsec"` (iface-type.ts, schema.ts enum — TEXT, без миграции); каталог интерфейсов дополняется IPsec-клиентами (identity name + CN в peerPublicKey/peerName) — привязка app-пользователя к VPN-клиенту.
## Контракт
`packages/contracts/src/ipsec.ts` (+`package.json` exports, `index.ts`): `ipsecPeerDto/identityDto/modeConfigDto/poolDto/activePeerDto/certInfoDto/serverSummaryDto`, list-response с failures, `initRequest` (CN/SAN, пул, DNS, NAT-чекбокс), `userCreateRequest` (имя, auth: certificate|psk, psk?, ip: static|pool, passphrase, daysValid), `certDownloadResponse {filename, contentB64, mime}`.
## Фронт
`app/(main)/ipsec/page.tsx` — «одно окно» по образцу WG-страницы: ServerRail + KPI (серверы IKEv2 / клиенты / онлайн / CA) + Tabs **«Клиенты»** (grid: имя, сервер, аутентификация, IP, онлайн, действия — скачать .p12/.sswan, изменить, удалить; Sheet создания клиента), **«Сервер»** (peer/pool/mode-config/certs + мастер инициализации Stepper + статус NAT-правила), **«CLI»** (шпаргалка). История/restore — `ConfigHistorySheet`. Компоненты `components/ipsec/*` на существующих DataGridShell/form-kit/Sheet; скачивание бинарного p12 — Blob из base64 (по образцу `downloadText`), `.sswan`/инструкция — через `CodeExportSheet`. Сайдбар «IPsec / IKEv2» в «Управление» + бейдж (`sidebar-counts.ts` + mock в `sidebar-badges.ts`) + command-palette. Mock-данные в `lib/data.ts` для демо-режима.
## Тесты и проверка
- `ipsec-config.test.ts` (чистые функции: sswan, свободный IP, naming); `entity-snapshots.test.ts` — `planIpsecRestore` + `opsTouchOnly(["/ip/ipsec", ...])`.
- `npm run build -w @mmapp/contracts`; `tsc` backend/root; `npm --prefix backend run test:config-sync && test:wireguard` + новые; eslint без новых ошибок.
- Демо-режим: визуальная проверка страницы в браузере (как в прошлый раз).
## Риски (проверить на живом роутере при внедрении)
- REST-скачивание файла `GET /rest/file/<name>` — еслиRouterOS отдаёт только метаданные, fallback: чтение `contents` маленьких PEM-файлов или сборка p12 из PEM-частей.
- Точные имена полей mode-config (`address` vs `address-pool`) сверить с живым GET и адаптировать.
- `/certificate/sign` — поллинг готовности с таймаутом ~30–60 с.
Объём большой; коммит/пуш — по готовности, отдельным `feat(ipsec): …`.
+2 -1
View File
@@ -17,6 +17,7 @@
## ReUI ## ReUI
Skill: `.claude/skills/reui` / `.cursor/skills/reui` — версия `668fb463eb` (20 free components). Skill: `.agents/skills/reui` (Codex), `.claude/skills/reui`, `.cursor/skills/reui`, `.opencode/skills/reui` — версия `fe6742e29c` (22 free components).
Codex MCP: `.codex/config.toml`, стиль `base-nova`, авторизация через переменную окружения `REUI_LICENSE_KEY`. Для PRO-реестра shadcn тот же ключ хранится в `.env.local` (исключён из Git); реестр настроен в `components.json`. После изменения переменной окружения перезапустить Codex.
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) · [Cursor](https://reui.io/docs/cursor). Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) · [Cursor](https://reui.io/docs/cursor).
Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта. Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
+15 -3
View File
@@ -128,7 +128,7 @@ sequenceDiagram
| Зависимость | Реализация | | Зависимость | Реализация |
|-------------|------------| |-------------|------------|
| PostgreSQL | Контейнер `mmapp-postgres` (`postgres:18-alpine`). `DATABASE_URL=postgres://mmapp:…@postgres:5432/mmapp`. | | PostgreSQL | Контейнер `mmapp-postgres` (`postgres:18-alpine`). `DATABASE_URL=postgres://mmapp:…@postgres:5432/mmapp`. |
| SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При первом старте, если PG пустой, backend сам импортирует данные и ставит маркер. Повторный старт не копирует заново. | | SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При старте, пока нет маркера `data_migration.sqlite_imported_at`, backend импортирует sqlite в PG (`ON CONFLICT` / upsert). После успешного импорта повтор не копирует заново. Том PG18: `mmapp-pgdata:/var/lib/postgresql` (не `.../data`). |
| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). | | Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
## Локальная разработка ## Локальная разработка
@@ -190,6 +190,14 @@ npm run build -w @mmapp/contracts
npm --prefix backend run db:migrate-from-sqlite npm --prefix backend run db:migrate-from-sqlite
``` ```
### GeoIP-базы GeoLite2 (страны и ASN для NetFlow)
Backend держит локальные mmdb-базы MaxMind GeoLite2 (Country + ASN) в `backend/storage/geoip/` и скачивает их с зеркала [P3TERX/GeoLite.mmdb](https://github.com/P3TERX/GeoLite.mmdb) — без регистрации и ключей. Lookup страны/ASN потока при ingest становится мгновенным (включая IPv6) и не упирается в лимиты RIPEstat; пока базы не скачаны или lookup промахнулся, работает прежний RIPE-fallback.
Управление — секция «GeoIP-базы (GeoLite2)» в настройках NetFlow (страница «Сбор данных»): автообновление (по умолчанию проверка раз в 7 дней, upstream обновляется еженедельно), статус сборки баз и кнопка «Обновить сейчас». Джоба планировщика — `geoip_update`. Атрибуция: данные MaxMind GeoLite2, CC BY-SA 4.0.
Примечание для Docker: каталог `storage/geoip` внутри контейнера ephemeral — без смонтированного volume базы (~17 МБ) перекачаются после пересоздания контейнера. Каталог переопределяется переменной `GEOIP_DIR`.
## CI/CD (Gitea Actions) ## CI/CD (Gitea Actions)
Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**). Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**).
@@ -211,9 +219,9 @@ npm --prefix backend run db:migrate-from-sqlite
### Версионирование и релизы ### Версионирование и релизы
- Базовая версия: **`v1.0.0`**. Линия semver: `1.x.y` (major `2.x` вне scope). - Базовая версия: **`v1.0.0`**. Поддерживаются patch, minor и major; выпуск `2.0.0` инициируется breaking-коммитом, версия вычисляется CI.
- Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**. - Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**.
- **`feat` / `feat!` / `BREAKING CHANGE`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`). - **`!` после типа/scope или footer `BREAKING CHANGE:` / `BREAKING-CHANGE:`** → major (`2.0.0` после `1.x`); **`feat`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
- Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`. - Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`.
- Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`). - Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`).
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`. - Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
@@ -560,3 +568,7 @@ docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
10. Проверить `curl` на `:8000/health` и `:3000/`. 10. Проверить `curl` на `:8000/health` и `:3000/`.
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`. 11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`. 12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
### Автоматизация NetFlow через Docker-агент
[Установка, схема взаимодействия и production-состав](docs/netflow-agent.md).
+318 -683
View File
File diff suppressed because it is too large. Load diff
+13 -28
View File
@@ -8,25 +8,22 @@ import { AsnsDataGrid } from "@/components/data-grids/asns-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog" import { FileImportDialog } from "@/components/file-import-dialog"
import { asns as mockAsns } from "@/lib/data" import { asns as mockAsns } from "@/lib/data"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react" import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
export default function AsnsPage() { export default function AsnsPage() {
const { mode } = useDataSource() const { mode, snapshot, loading, known } = useEvoCatalogView()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled const useEvoCatalog = mode === "live"
const rows = useMemo(() => { const rows = useMemo(() => {
if (!useEvoCatalog) return mockAsns if (mode === "mock") return mockAsns
if (loading && !snapshot) return [] if (loading && !snapshot) return []
return snapshot?.asns ?? [] return snapshot?.asns ?? []
}, [useEvoCatalog, loading, snapshot]) }, [mode, loading, snapshot])
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) return rows if (!search) return rows
@@ -35,7 +32,7 @@ export default function AsnsPage() {
(r) => (r) =>
r.asn.toLowerCase().includes(q) || r.asn.toLowerCase().includes(q) ||
r.org.toLowerCase().includes(q) || r.org.toLowerCase().includes(q) ||
String(r.prefixes).includes(q), (r.prefixes === null ? "" : String(r.prefixes)).includes(q),
) )
}, [rows, search]) }, [rows, search])
@@ -43,8 +40,7 @@ export default function AsnsPage() {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Данные" }, { label: "ASN" }]} crumbs={[{ label: "Данные" }, { label: "ASN" }]}
actions={ actions={mode === "live" ? null : <>
<>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}> <Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт <UploadIcon className="size-4" />Импорт
</Button> </Button>
@@ -60,32 +56,21 @@ export default function AsnsPage() {
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Автономные системы — источники маршрутов для BGP-фильтров Автономные системы — источники маршрутов для BGP-фильтров
</p> </p>
{useEvoCatalog && ( <div className="mt-3"><EvoBgpCatalogStatus /></div>
<p className={cn(
"text-xs mt-2 flex items-center gap-1.5",
error ? "text-destructive" : "text-muted-foreground",
)}>
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
{error
? `EvoBGP: ${error}`
: snapshot?.fetchedAt
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
</p>
)}
</div> </div>
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по ASN, имени, префиксам…" searchPlaceholder="Поиск по ASN, имени, префиксам…"
countLabel={`${filtered.length} ASN`} countLabel={known ? `${filtered.length} ASN` : "Количество: —"}
/> />
<AsnsDataGrid {known || loading ? <AsnsDataGrid
asns={filtered} asns={filtered}
isLoading={useEvoCatalog && loading && !snapshot} isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog} pagination={useEvoCatalog}
/> emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
/> : <EvoBgpCatalogUnavailable />}
</DataPageCard> </DataPageCard>
</div> </div>
</div> </div>
File diff suppressed because it is too large. Load diff
+231 -161
View File
@@ -1,37 +1,41 @@
"use client" "use client"
import { Fragment, useState, useMemo, useEffect } from "react" import { Suspense, useState, useMemo, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid" import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
import type { Filter } from "@/components/reui/filters" import { useBgpUrlState } from "@/hooks/use-bgp-url-state"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters" import { QueryFilters } from "@/components/data-grids/query-filters"
import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields" import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types" import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
import { bgpCatalogSchema, bgpServersSchema, type BackendBgpSession, type BgpFailure } from "@/lib/bgp/response"
import { fmtBgpNum, sumBgpCounters } from "@/lib/bgp/helpers"
import { BGP_AS_NAMES } from "@/lib/bgp/types" import { BGP_AS_NAMES } from "@/lib/bgp/types"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
RefreshCwIcon, DownloadIcon, SearchIcon, RefreshCwIcon, DownloadIcon,
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon, ActivityIcon, BarChart3Icon,
ArrowDownIcon, ClipboardCopyIcon, ServerIcon, ArrowDownIcon, ServerIcon,
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
} from "lucide-react" } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { servers as mockServers, type Server } from "@/lib/data"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
type BgpSession = BgpSessionRow type BgpSession = BgpSessionRow
type BgpTab = "sessions" | "routers" | "analytics" type BgpTab = "sessions" | "routers" | "analytics"
type StateFilter = "all" | BgpState
type TypeFilter = "all" | BgpType
const AS_NAMES = BGP_AS_NAMES const AS_NAMES = BGP_AS_NAMES
// ─── mock data ──────────────────────────────────────────────────────────────── // ─── mock data ────────────────────────────────────────────────────────────────
@@ -181,6 +185,7 @@ const SESSIONS: BgpSession[] = [
// ─── visual helpers ─────────────────────────────────────────────────────────── // ─── visual helpers ───────────────────────────────────────────────────────────
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = { const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
Unknown: { bg: "bg-muted", text: "text-muted-foreground", dot: "bg-muted-foreground", label: "Неизвестно" },
Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" }, Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" },
Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" }, Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" },
Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" }, Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" },
@@ -189,48 +194,32 @@ const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; lab
OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" }, OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" },
} }
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = { const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
Unknown: { bg: "bg-muted", text: "text-muted-foreground" },
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" }, eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" }, iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
} }
function StateBadge({ state }: { state: BgpState }) {
const s = STATE_STYLE[state]
return (
<span className={cn("inline-flex items-center gap-1.5 rounded border px-2 py-0.5 text-[11px] font-semibold",
s.bg, s.text, "border-current/20")}>
<span className={cn("size-1.5 rounded-full shrink-0", s.dot)} />
{s.label}
</span>
)
}
function TypeBadge({ type }: { type: BgpType }) { function TypeBadge({ type }: { type: BgpType }) {
const s = TYPE_STYLE[type] const s = TYPE_STYLE[type]
return ( return (
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold", <span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
s.bg, s.text, "border-current/20")}> s.bg, s.text, "border-current/20")}>
{type} {type === "Unknown" ? "Тип неизвестен" : type}
</span> </span>
) )
} }
function fmtNum(n: number) { const fmtNum = fmtBgpNum
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`
return String(n)
}
// ─── backend mapping ────────────────────────────────────────────────────────── // ─── backend mapping ──────────────────────────────────────────────────────────
interface BackendBgpSession { type BackendServer = ReturnType<typeof bgpServersSchema.parse>[number]
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string type InventoryServer = Server & { monitoringUnknown?: boolean }
name: string; peerIp: string; remoteAs: number; localAs: number function mapBackendServer(s: BackendServer): InventoryServer {
localId: string; remoteId: string; state: string; type: "eBGP" | "iBGP" return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: s.site ?? "",
uptime: string | null; holdTime: number; keepalive: number country: s.country || "UN", asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled,
prefixesRx: number; prefixesTx: number status: s.status ?? "offline", monitoringUnknown: s.status == null, latency: s.latency ?? null, sessions: 0 }
inputMessages: number; outputMessages: number
capabilities: string[]; lastError: string | null
} }
function backendToFrontend(b: BackendBgpSession): BgpSession { function backendToFrontend(b: BackendBgpSession): BgpSession {
@@ -244,15 +233,15 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
localAs: b.localAs, localAs: b.localAs,
routerId: b.remoteId || b.localId, routerId: b.remoteId || b.localId,
description: b.name, description: b.name,
state: (b.state as BgpState) || "Idle", state: Object.hasOwn(STATE_STYLE, b.state) ? b.state as BgpState : "Unknown",
type: b.type, type: b.type,
afi: "IPv4 Unicast", afi: b.afi ?? "Неизвестно",
uptime: b.uptime, uptime: b.uptime,
holdTime: b.holdTime, holdTime: b.holdTime,
keepalive: b.keepalive, keepalive: b.keepalive,
prefixesRx: b.prefixesRx, prefixesRx: b.prefixesRx,
prefixesTx: b.prefixesTx, prefixesTx: b.prefixesTx,
prefixesActive: b.prefixesRx, prefixesActive: null,
inputMessages: b.inputMessages, inputMessages: b.inputMessages,
outputMessages: b.outputMessages, outputMessages: b.outputMessages,
capabilities: b.capabilities, capabilities: b.capabilities,
@@ -268,14 +257,12 @@ const BGP_FILTER_ACCESSORS = {
afi: (s: BgpSession) => s.afi, afi: (s: BgpSession) => s.afi,
} }
function SessionsTab({ sessions }: { sessions: BgpSession[] }) { function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession }: { sessions: BgpSession[]; emptyTitle?: string; selectedSessionId: string | null; onSelectSession: (id: string | null) => void }) {
const [search, setSearch] = useState("") const { search, setSearch, stateFilter, setStateFilter, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset } = useBgpUrlState()
const [stateFilter, setStateFilter] = useState<StateFilter>("all") const fields = useMemo(() => BGP_FILTER_FIELDS.map(field => field.id === "afi" ? { ...field, options: Array.from(new Set(sessions.map(s => s.afi))).sort().map(value => ({ value, label: value })) } : field), [sessions])
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const q = search.toLowerCase() const q = search.toLowerCase()
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (urlError) return []
const base = sessions.filter((s) => { const base = sessions.filter((s) => {
if (stateFilter !== "all" && s.state !== stateFilter) return false if (stateFilter !== "all" && s.state !== stateFilter) return false
if (typeFilter !== "all" && s.type !== typeFilter) return false if (typeFilter !== "all" && s.type !== typeFilter) return false
@@ -283,46 +270,48 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
q && q &&
!s.peerIp.includes(q) && !s.peerIp.includes(q) &&
!s.description.toLowerCase().includes(q) && !s.description.toLowerCase().includes(q) &&
!s.serverLabel.includes(q) && !s.serverLabel.toLowerCase().includes(q) &&
!String(s.remoteAs).includes(q) && !String(s.remoteAs).includes(q) &&
!(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q) !(s.remoteAs === null ? "" : AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
) { ) {
return false return false
} }
return true return true
}) })
return applyReuiFilters(base, advancedFilters, BGP_FILTER_ACCESSORS) return applyFilterQuery(base, advancedFilters, BGP_FILTER_ACCESSORS)
}, [sessions, q, stateFilter, typeFilter, advancedFilters]) }, [sessions, q, stateFilter, typeFilter, advancedFilters, urlError])
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{urlError && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>{urlError}<Button variant="outline" size="sm" onClick={reset}>Сбросить фильтры BGP</Button></AlertDescription></Alert>}
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
className="[&>div:first-child]:max-w-full [&>div:first-child]:overflow-x-auto [&>div:first-child_button]:shrink-0 [&>div:first-child_button]:whitespace-nowrap"
segmented={{ segmented={{
value: stateFilter, value: stateFilter,
onChange: setStateFilter, onChange: setStateFilter,
options: [ options: [
{ value: "all", label: "Все", count: sessions.length }, { value: "all", label: "Все", count: sessions.length },
{ value: "Unknown", label: "Неизвестно", count: sessions.filter(s => s.state === "Unknown").length },
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length }, { value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length }, { value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length }, { value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length }, { value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
], ],
}} }}
filters={advancedFilters} filterControl={<QueryFilters fields={fields} query={advancedFilters} onQueryChange={setAdvancedFilters} />}
onFiltersChange={setAdvancedFilters}
filterFields={BGP_FILTER_FIELDS}
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="IP, AS, описание…" searchPlaceholder="IP, AS, описание…"
countLabel={`${filtered.length} из ${sessions.length}`} countLabel={`${filtered.length} из ${sessions.length}`}
actions={ actions={
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40"> <div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{(["all", "eBGP", "iBGP"] as const).map((t) => ( {(["all", "eBGP", "iBGP", "Unknown"] as const).map((t) => (
<button <button
key={t} key={t}
type="button" type="button"
onClick={() => setTypeFilter(t)} onClick={() => setTypeFilter(t)}
aria-pressed={typeFilter === t}
className={cn( className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors", "px-2.5 py-1 text-[11px] rounded transition-colors",
typeFilter === t typeFilter === t
@@ -330,13 +319,15 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)} )}
> >
{t === "all" ? "Все типы" : t} {t === "all" ? "Все типы" : t === "Unknown" ? "Неизвестно" : t}
</button> </button>
))} ))}
</div> </div>
} }
/> />
<BgpSessionsDataGrid sessions={filtered} /> {selectedSessionId && sessions.some(s => s.id === selectedSessionId) && !filtered.some(s => s.id === selectedSessionId) && <p role="status" className="px-5 py-2 text-xs text-muted-foreground">Сессия из ссылки скрыта текущими фильтрами.</p>}
{hasFilters && !urlError && <Button variant="ghost" size="sm" onClick={reset}>Сбросить фильтры BGP</Button>}
<BgpSessionsDataGrid sessions={filtered} emptyTitle={urlError ? "Некорректные фильтры BGP" : emptyTitle} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />
</DataPageCard> </DataPageCard>
</div> </div>
) )
@@ -358,8 +349,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{byRouter.map(router => { {byRouter.map(router => {
const estCnt = router.sessions.filter(s => s.state === "Established").length const estCnt = router.sessions.filter(s => s.state === "Established").length
const downCnt = router.sessions.length - estCnt const downCnt = router.sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0) const totalRx = sumBgpCounters(router.sessions.map(s => s.prefixesRx))
return ( return (
<Frame key={router.id} dense className="w-full overflow-hidden"> <Frame key={router.id} dense className="w-full overflow-hidden">
@@ -369,7 +360,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
<ServerIcon className="size-4 text-muted-foreground shrink-0" /> <ServerIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-none">{router.label}</p> <p className="text-sm font-medium leading-none">{router.label}</p>
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · AS65001</p> <p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · {Array.from(new Set(router.sessions.map(s => s.localAs === null ? "AS неизвестна" : "AS" + s.localAs))).join(", ")}</p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{estCnt > 0 && ( {estCnt > 0 && (
@@ -400,19 +391,19 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
<TypeBadge type={s.type} /> <TypeBadge type={s.type} />
</div> </div>
<div className="flex items-center gap-1.5 mt-0.5"> <div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-muted-foreground">AS{s.remoteAs}</span> <span className="text-[10px] text-muted-foreground">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
{AS_NAMES[s.remoteAs] && ( {s.remoteAs !== null && AS_NAMES[s.remoteAs] && (
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span> <span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
)} )}
</div> </div>
</div> </div>
<div className="shrink-0 text-right"> <div className="shrink-0 text-right">
{s.prefixesRx > 0 && ( {s.prefixesRx !== null && s.prefixesRx > 0 && (
<p className="text-[10px] font-mono text-emerald-500 tabular-nums"> <p className="text-[10px] font-mono text-emerald-500 tabular-nums">
↓ {fmtNum(s.prefixesRx)} ↓ {fmtNum(s.prefixesRx)}
</p> </p>
)} )}
<p className={cn("text-[10px] font-semibold", ss.text)}>{s.state}</p> <p className={cn("text-[10px] font-semibold", ss.text)}>{ss.label}</p>
</div> </div>
</div> </div>
) )
@@ -420,7 +411,7 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
</div> </div>
{/* footer summary */} {/* footer summary */}
{totalRx > 0 && ( {totalRx !== null && totalRx > 0 && (
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20"> <div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
<ArrowDownIcon className="size-3 text-emerald-500" /> <ArrowDownIcon className="size-3 text-emerald-500" />
<span className="text-[11px] font-mono text-muted-foreground"> <span className="text-[11px] font-mono text-muted-foreground">
@@ -441,9 +432,9 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) { function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
// top peers by prefixes received (eBGP only) // top peers by prefixes received (eBGP only)
const topPeers = useMemo(() => const topPeers = useMemo(() =>
[...sessions] (sessions.some(s => s.prefixesRx === null) ? [] : [...sessions])
.filter(s => s.prefixesRx > 0) .filter(s => s.prefixesRx !== null && s.prefixesRx > 0)
.sort((a, b) => b.prefixesRx - a.prefixesRx) .sort((a, b) => (b.prefixesRx ?? 0) - (a.prefixesRx ?? 0))
.slice(0, 8), .slice(0, 8),
[sessions] [sessions]
) )
@@ -457,8 +448,8 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
}, [sessions]) }, [sessions])
// total prefix stats // total prefix stats
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0) const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
const totalActive = sessions.reduce((a, s) => a + s.prefixesActive, 0) const totalActive = sumBgpCounters(sessions.map(s => s.prefixesActive))
const ebgpSessions = sessions.filter(s => s.type === "eBGP").length const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
const ibgpSessions = sessions.filter(s => s.type === "iBGP").length const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
@@ -469,29 +460,30 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
items={[ items={[
{ {
id: "rx", id: "rx",
label: "Всего префиксов", label: "Префиксы",
value: fmtNum(totalRx), value: fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />, icon: <DownloadIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "active", id: "active",
label: "Активных маршрутов", label: "Маршруты",
hint: totalActive === null ? "нет данных API" : undefined,
value: fmtNum(totalActive), value: fmtNum(totalActive),
icon: <GitMergeIcon className="size-4" />, icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-info", iconClassName: "text-info",
}, },
{ {
id: "ebgp", id: "ebgp",
label: "eBGP сессий", label: "eBGP",
value: ebgpSessions, value: sessions.some(s => s.type === "Unknown") ? "—" : ebgpSessions,
icon: <ActivityIcon className="size-4" />, icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "ibgp", id: "ibgp",
label: "iBGP сессий", label: "iBGP",
value: ibgpSessions, value: sessions.some(s => s.type === "Unknown") ? "—" : ibgpSessions,
icon: <ServerIcon className="size-4" />, icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
@@ -503,7 +495,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5"> <OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{topPeers.map((s, i) => { {topPeers.map((s, i) => {
const pct = (s.prefixesRx / maxRx) * 100 const pct = ((s.prefixesRx ?? 0) / maxRx) * 100
const ss = STATE_STYLE[s.state] const ss = STATE_STYLE[s.state]
return ( return (
<div key={s.id} className="flex items-center gap-3"> <div key={s.id} className="flex items-center gap-3">
@@ -514,7 +506,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
<div className="flex items-center justify-between mb-1 gap-2"> <div className="flex items-center justify-between mb-1 gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-mono truncate">{s.peerIp}</span> <span className="text-xs font-mono truncate">{s.peerIp}</span>
<span className="text-[10px] text-muted-foreground shrink-0">AS{s.remoteAs}</span> <span className="text-[10px] text-muted-foreground shrink-0">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
<TypeBadge type={s.type} /> <TypeBadge type={s.type} />
</div> </div>
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}> <span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
@@ -533,7 +525,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
) )
})} })}
{topPeers.length === 0 && ( {topPeers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p> <p className="text-sm text-muted-foreground text-center py-4">Недостаточно данных о префиксах для сравнения</p>
)} )}
</div> </div>
</OpsPanel> </OpsPanel>
@@ -552,7 +544,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
return ( return (
<div key={state} className="flex items-center gap-2"> <div key={state} className="flex items-center gap-2">
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} /> <span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
<span className="text-xs w-24">{state}</span> <span className="text-xs w-24">{ss.label}</span>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden"> <div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} /> <div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
</div> </div>
@@ -566,7 +558,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
{/* eBGP vs iBGP */} {/* eBGP vs iBGP */}
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5"> <OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
{sessions.length === 0 ? ( {sessions.length === 0 || sessions.some(s => s.type === "Unknown") ? (
<p className="text-xs text-muted-foreground">Нет данных</p> <p className="text-xs text-muted-foreground">Нет данных</p>
) : ( ) : (
<> <>
@@ -593,7 +585,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{Object.entries( {Object.entries(
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => { sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => {
acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc if (s.remoteAs !== null) acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
}, {}) }, {})
).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => ( ).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
<div key={as} className="flex items-center justify-between text-[11px]"> <div key={as} className="flex items-center justify-between text-[11px]">
@@ -622,29 +614,78 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
] ]
export default function BgpPage() { export default function BgpPage() {
const [activeTab, setActiveTab] = useState<BgpTab>("sessions") return <Suspense fallback={<div role="status">Загрузка BGP…</div>}><BgpRoute /></Suspense>
}
function BgpRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const router = useRouter()
const selectedServerId = params.get("server") ?? ALL_SERVERS_ID
const selectedSessionId = params.get("session")
const tab = params.get("tab")
const activeTab: BgpTab = tab === "routers" || tab === "analytics" ? tab : "sessions"
function navigate(values: Record<string, string | null>) {
const next = new URLSearchParams(params.toString())
for (const [key, value] of Object.entries(values)) {
if (value === null) next.delete(key)
else next.set(key, value)
}
router.push("/bgp" + (next.size ? "?" + next.toString() : ""))
}
return <BgpPageContent key={JSON.stringify([mode, backendUrl])} selectedServerId={selectedServerId} selectedSessionId={selectedSessionId} activeTab={activeTab}
onSelectServer={id => navigate({ server: id === ALL_SERVERS_ID ? null : id, session: null })}
onSelectSession={id => navigate({ session: id })}
onSelectTab={tab => navigate({ tab: tab === "sessions" ? null : tab })} />
}
function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSelectServer, onSelectSession, onSelectTab }: {
selectedServerId: string; selectedSessionId: string | null; activeTab: BgpTab;
onSelectServer: (id: string) => void; onSelectSession: (id: string | null) => void; onSelectTab: (tab: BgpTab) => void;
}) {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([]) const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
const [loading, setLoading] = useState(false) const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
const [loading, setLoading] = useState(isLive)
const [fetchedAt, setFetchedAt] = useState<Date | null>(null) const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null) const [liveError, setLiveError] = useState<string | null>(null)
const [failures, setFailures] = useState<BgpFailure[]>([])
const [hasLoaded, setHasLoaded] = useState(false)
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
const [fetchTick, setFetchTick] = useState(0) const [fetchTick, setFetchTick] = useState(0)
useEffect(() => { useEffect(() => {
if (!isLive) return if (!isLive) {
queueMicrotask(() => {
setLiveSessions([])
setLiveServers([])
setLiveError(null)
})
return
}
let cancelled = false let cancelled = false
queueMicrotask(() => { queueMicrotask(() => {
if (cancelled) return if (cancelled) return
setLoading(true) setLoading(true)
setLiveError(null) void Promise.all([
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions") requestJson<unknown>(backendUrl, "/api/bgp/catalog"),
.then(data => { requestJson<unknown>(backendUrl, "/api/servers"),
])
.then(([data, servers]) => {
if (cancelled) return if (cancelled) return
setLiveSessions(data.map(backendToFrontend)) const catalog = bgpCatalogSchema.parse(data)
setFetchedAt(new Date()) const inventory = bgpServersSchema.parse(servers)
const enabled = new Set(inventory.filter(s => s.enabled).map(s => s.id))
if (catalog.sessions.some(s => !enabled.has(s.serverId)) || catalog.failures.some(f => !enabled.has(f.serverId))) throw new Error("Чужой контекст BGP-каталога")
const failed = new Set(catalog.failures.map(f => String(f.serverId)))
setLiveSessions(previous => [...catalog.sessions.map(backendToFrontend), ...previous.filter(s => enabled.has(Number(s.serverId)) && failed.has(s.serverId))])
setLiveServers(inventory.filter(s => s.enabled).map(mapBackendServer))
setFailures(catalog.failures)
setLiveError(null)
setHasLoaded(true)
const receivedAt = new Date()
setFetchedAt(receivedAt)
setReadReceipts(previous => ({ ...Object.fromEntries(Object.entries(previous).filter(([id]) => enabled.has(Number(id)))), ...Object.fromEntries([...enabled].filter(id => !failed.has(String(id))).map(id => [String(id), receivedAt.toISOString()])) }))
setLoading(false) setLoading(false)
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
@@ -656,52 +697,89 @@ export default function BgpPage() {
return () => { cancelled = true } return () => { cancelled = true }
}, [isLive, backendUrl, fetchTick]) }, [isLive, backendUrl, fetchTick])
// Use live or mock data for all tabs and KPI const unknownIds = useMemo(() => new Set(isLive ? (liveError ? liveServers.map(s => s.id) : failures.map(f => String(f.serverId))) : []), [isLive, liveError, liveServers, failures])
const sessions = isLive ? liveSessions : SESSIONS const allSessions: BgpSession[] = isLive ? liveSessions.map(s => unknownIds.has(s.serverId) ? { ...s, state: "Unknown" } : s) : SESSIONS
const unavailableContext = isLive && hasLoaded && selectedServerId !== ALL_SERVERS_ID && !liveServers.some(s => s.id === selectedServerId)
const incomplete = isLive && (unavailableContext || !hasLoaded || Boolean(liveError) || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : unknownIds.has(selectedServerId)))
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId = selectedServerId
const sessions = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return allSessions
return allSessions.filter((s) => s.serverId === effectiveServerId)
}, [allSessions, effectiveServerId])
const railItems = useMemo<ServerTileItem[]>(() => {
const counts = new Map<string, number>()
for (const s of allSessions) {
counts.set(s.serverId, (counts.get(s.serverId) ?? 0) + 1)
}
return displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: "monitoringUnknown" in s && s.monitoringUnknown ? undefined : s.status,
meta: "monitoringUnknown" in s && s.monitoringUnknown ? "Статус неизвестен" : undefined,
type: s.type,
count: isLive && (!hasLoaded || unknownIds.has(s.id)) ? undefined : counts.get(s.id) ?? 0,
enabled: s.enabled,
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
}))
}, [displayServers, allSessions, hasLoaded, isLive, unknownIds])
const established = sessions.filter(s => s.state === "Established").length const established = sessions.filter(s => s.state === "Established").length
const notEstab = sessions.length - established const notEstab = sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0) const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
const serverCount = useMemo(
() => new Set(liveSessions.map(s => s.serverId)).size,
[liveSessions],
)
return ( return (
<div className="flex flex-col h-full"> <ServerRailLayout
<PageHeader preserveSelection
crumbs={[{ label: "Управление" }, { label: "BGP" }]} items={railItems}
actions={ selectedId={effectiveServerId}
<> onSelect={onSelectServer}
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}> showAll
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} /> allCount={displayServers.length}
Обновить loading={isLive && loading && displayServers.length === 0}
</Button> header={
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button> <PageHeader
</> crumbs={[{ label: "Управление" }, { label: "BGP" }]}
} actions={
/> <>
<ServerRailMobileButton />
{/* tab bar */} <Button variant="outline" size="sm" disabled={!isLive || loading} onClick={() => setFetchTick(t => t + 1)}>
<div className="border-b bg-background shrink-0"> <RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
<div className="flex items-center px-6"> Обновить
{TABS.map(t => ( </Button>
<button key={t.id} onClick={() => setActiveTab(t.id)} </>
className={cn( }
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px", />
activeTab === t.id }
? "border-primary text-foreground" banner={
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border", <div className="border-b bg-background shrink-0">
)}> <div className="flex min-w-0 items-center overflow-x-auto px-4 sm:px-6">
{t.icon}{t.label} {TABS.map(t => (
</button> <button key={t.id} type="button" aria-pressed={activeTab === t.id} onClick={() => onSelectTab(t.id)}
))} className={cn(
"flex shrink-0 items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
</button>
))}
</div>
</div> </div>
</div> }
>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{unavailableContext && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Устройство BGP недоступно в этом каталоге</AlertTitle><AlertDescription>Контекст {selectedServerId} сохранён. Выберите доступное устройство.<Button variant="outline" size="sm" onClick={() => onSelectServer(ALL_SERVERS_ID)}>Все устройства BGP</Button></AlertDescription></Alert>}
{selectedSessionId && hasLoaded && !sessions.some(s => s.id === selectedSessionId) && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>Сессия из ссылки отсутствует в выбранном снимке. Контекст ссылки сохранён.<Button variant="outline" size="sm" onClick={() => onSelectSession(null)}>Все сессии BGP</Button></AlertDescription></Alert>}
{/* data source banner */} {/* data source banner */}
{isLive && loading && ( {isLive && loading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -709,31 +787,23 @@ export default function BgpPage() {
Загрузка BGP-сессий… Загрузка BGP-сессий…
</div> </div>
)} )}
{isLive && !loading && fetchedAt && !liveError && ( {isLive && fetchedAt && (
<div className="flex items-center gap-2"> <div role="group" aria-label="Свежесть BGP" className="text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400"> Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>.
<span className="size-1.5 rounded-full bg-emerald-500" /> Это время получения браузером, не время измерения RouterOS.
Живые данные · {serverCount} серверов · обновлено {fetchedAt.toLocaleTimeString("ru")} {displayServers.filter(s => effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId).map(s => <p key={s.id}>{s.name}: {readReceipts[s.id] ? <time data-bgp-server={s.id} dateTime={readReceipts[s.id]}>{new Date(readReceipts[s.id]).toLocaleTimeString("ru")}</time> : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}</p>)}
</span>
<button
onClick={() => setFetchTick(t => t + 1)}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
<RefreshCwIcon className="size-3" />
Обновить
</button>
</div> </div>
)} )}
{isLive && liveError && ( {isLive && (liveError || failures.length > 0) && (
<Alert variant="warning" className="py-2"> <Alert variant="warning">
<AlertCircleIcon /> <AlertCircleIcon />
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription> <AlertTitle>{liveError ? "Не удалось загрузить BGP" : "Не удалось опросить часть роутеров BGP"}</AlertTitle>
<AlertDescription>
{liveError ?? failures.map(f => f.serverName + ": " + f.error).join("; ")}. {hasLoaded ? "Показаны последние полученные данные; статус отказавших устройств неизвестен." : "Данные пока неизвестны."}
<Button variant="outline" size="sm" disabled={loading} onClick={() => setFetchTick(t => t + 1)} aria-label="Повторить загрузку BGP">Повторить</Button>
</AlertDescription>
</Alert> </Alert>
)} )}
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
BGP не настроен ни на одном сервере
</div>
)}
{mode === "mock" && ( {mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground"> <span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные Моковые данные
@@ -745,30 +815,31 @@ export default function BgpPage() {
items={[ items={[
{ {
id: "sessions", id: "sessions",
label: "Сессий всего", label: "Сессий",
value: sessions.length, value: incomplete ? "—" : sessions.length,
hint: incomplete ? "неизвестно" : undefined,
icon: <GitMergeIcon className="size-4" />, icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "established", id: "established",
label: "Established", label: "Established",
value: established, value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : established,
icon: <CheckCircleIcon className="size-4" />, icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "not-estab", id: "not-estab",
label: "Не установлено", label: "Не поднято",
value: notEstab, value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : notEstab,
icon: <AlertCircleIcon className="size-4" />, icon: <AlertCircleIcon className="size-4" />,
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground", iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
variant: notEstab > 0 ? "warning" : "default", variant: notEstab > 0 ? "warning" : "default",
}, },
{ {
id: "prefixes", id: "prefixes",
label: "Получено префиксов", label: "Префиксы",
value: fmtNum(totalRx), value: incomplete ? "—" : fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />, icon: <DownloadIcon className="size-4" />,
iconClassName: "text-info", iconClassName: "text-info",
}, },
@@ -776,25 +847,24 @@ export default function BgpPage() {
/> />
{/* alert: not-established sessions */} {/* alert: not-established sessions */}
{notEstab > 0 && ( {!incomplete && notEstab > 0 && (
<Alert variant="warning"> <Alert variant="warning">
<AlertCircleIcon /> <AlertCircleIcon />
<AlertTitle> <AlertTitle>
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established <span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
Проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")} Проверьте {sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").map(s => s.peerIp).join(", ")}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{/* tab content */} {/* tab content */}
{activeTab === "sessions" && <SessionsTab sessions={sessions} />} {activeTab === "sessions" && <SessionsTab sessions={sessions} emptyTitle={incomplete && sessions.length === 0 ? "Данные BGP неизвестны" : undefined} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />}
{activeTab === "routers" && <RoutersTab sessions={sessions} />} {activeTab === "routers" && (incomplete ? <p role="status">Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <RoutersTab sessions={sessions} />)}
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />} {activeTab === "analytics" && (incomplete ? <p role="status">Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <AnalyticsTab sessions={sessions} />)}
</div> </div>
</div> </ServerRailLayout>
</div>
) )
} }
+160 -342
View File
@@ -1,16 +1,19 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { Suspense, useMemo, useState } from "react"
import { useSearchParams } from "next/navigation"
import { readCertificatesUrl, patchCertificatesUrl, type CertificateStatusFilter } from "@/lib/certificates-url-state"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog"
import { routerCertificates, servers as mockServers } from "@/lib/data" import { routerCertificates, servers as mockServers } from "@/lib/data"
import type { CertStatus, Server } from "@/lib/data" import type { Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates" import type { CertificateDto } from "@mmapp/contracts/certificates"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid" import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
import { CertificateAcmeSettings } from "@/components/certificates/certificate-acme-settings"
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
@@ -27,17 +30,11 @@ import {
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers" import { can } from "@/lib/auth"
import { useCertificateCatalog } from "@/lib/use-certificate-catalog"
import type { CertificateView } from "@/lib/certificate-view"
import { toFrontendServer } from "@/entities/server/model/mappers" import { toFrontendServer } from "@/entities/server/model/mappers"
import { import { useCertificateIssue } from "@/lib/use-certificate-issue"
createCertificateIssueJob,
getAcmeSettings,
getCertificateIssueJob,
listCertificates,
putAcmeSettings,
refreshCertificates,
testAcmeSettings,
} from "@/shared/api/certificates"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ShieldCheckIcon, ShieldCheckIcon,
@@ -58,7 +55,7 @@ import {
StepperSeparator, StepperSeparator,
StepperTitle, StepperTitle,
StepperTrigger, StepperTrigger,
} from "@/components/reui/stepper" } from "@/components/reui-kit/accessible-stepper"
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto { function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
return { return {
@@ -78,12 +75,12 @@ function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
} }
} }
function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) { function CertPartAlertExpired({ expired }: { expired: CertificateView[] }) {
return ( return (
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm"> <div className="flex items-start gap-3 rounded-lg bg-destructive/5 border border-destructive/20 px-4 py-3 text-sm">
<AlertCircleIcon className="size-5 text-red-500 shrink-0 mt-0.5" /> <AlertCircleIcon className="size-5 text-destructive shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium text-red-600 dark:text-red-400"> <p className="font-medium text-destructive">
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"} {expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
</p> </p>
<p className="text-muted-foreground text-xs mt-0.5"> <p className="text-muted-foreground text-xs mt-0.5">
@@ -94,12 +91,12 @@ function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
) )
} }
function CertPartAlertExpiring({ expiring }: { expiring: CertificateDto[] }) { function CertPartAlertExpiring({ expiring }: { expiring: CertificateView[] }) {
return ( return (
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm"> <div className="flex items-start gap-3 rounded-lg bg-warning/5 border border-warning/20 px-4 py-3 text-sm">
<AlertTriangleIcon className="size-5 text-amber-500 shrink-0 mt-0.5" /> <AlertTriangleIcon className="size-5 text-warning shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium text-amber-600 dark:text-amber-400"> <p className="font-medium text-warning">
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней {expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
</p> </p>
<p className="text-muted-foreground text-xs mt-0.5"> <p className="text-muted-foreground text-xs mt-0.5">
@@ -114,10 +111,14 @@ function CertPartKpi({
displayCerts, displayCerts,
expiring, expiring,
expired, expired,
complete,
totalKnown,
}: { }: {
displayCerts: CertificateDto[] displayCerts: CertificateView[]
expiring: CertificateDto[] expiring: CertificateView[]
expired: CertificateDto[] expired: CertificateView[]
complete: boolean
totalKnown: boolean
}) { }) {
return ( return (
<KpiStatGrid <KpiStatGrid
@@ -126,21 +127,21 @@ function CertPartKpi({
{ {
id: "all", id: "all",
label: "Всего", label: "Всего",
value: displayCerts.length, value: totalKnown ? displayCerts.length : "—",
icon: <ShieldCheckIcon className="size-4" />, icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "valid", id: "valid",
label: "Действующих", label: "Действующих",
value: displayCerts.filter((c) => c.status === "valid").length, value: complete ? displayCerts.filter((c) => c.status === "valid").length : "—",
icon: <BadgeCheckIcon className="size-4" />, icon: <BadgeCheckIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "expiring", id: "expiring",
label: "Истекают", label: "Истекают",
value: expiring.length, value: complete ? expiring.length : "—",
icon: <AlertTriangleIcon className="size-4" />, icon: <AlertTriangleIcon className="size-4" />,
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground", iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
variant: expiring.length > 0 ? "warning" : "default", variant: expiring.length > 0 ? "warning" : "default",
@@ -148,7 +149,7 @@ function CertPartKpi({
{ {
id: "expired", id: "expired",
label: "Истёкших", label: "Истёкших",
value: expired.length, value: complete ? expired.length : "—",
icon: <AlertCircleIcon className="size-4" />, icon: <AlertCircleIcon className="size-4" />,
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground", iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
variant: expired.length > 0 ? "destructive" : "default", variant: expired.length > 0 ? "destructive" : "default",
@@ -158,92 +159,11 @@ function CertPartKpi({
) )
} }
function CertPartAcmeSettings({
acmeDirectoryUrl,
setAcmeDirectoryUrl,
acmeZoneId,
setAcmeZoneId,
acmeTokenDraft,
setAcmeTokenDraft,
acmeTokenConfigured,
acmeSaveBusy,
onTest,
onSave,
}: {
acmeDirectoryUrl: string
setAcmeDirectoryUrl: (v: string) => void
acmeZoneId: string
setAcmeZoneId: (v: string) => void
acmeTokenDraft: string
setAcmeTokenDraft: (v: string) => void
acmeTokenConfigured: boolean
acmeSaveBusy: boolean
onTest: () => void
onSave: () => void
}) {
return (
<OpsPanel
title="ACME · Cloudflare DNS-01"
description="Публичные Let's Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+."
contentClassName="px-5 py-4 flex flex-col gap-3"
>
<form
className="grid gap-3 md:grid-cols-2"
autoComplete="off"
onSubmit={(event) => event.preventDefault()}
>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">ACME directory URL</label>
<Input
autoComplete="off"
name="acme-directory-url"
value={acmeDirectoryUrl}
onChange={(e) => setAcmeDirectoryUrl(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Cloudflare zone id (опционально)</label>
<Input
autoComplete="off"
name="cloudflare-zone-id"
value={acmeZoneId}
onChange={(e) => setAcmeZoneId(e.target.value)}
placeholder="Авто по домену"
/>
</div>
<div className="flex flex-col gap-1.5 md:col-span-2">
<label className="text-sm font-medium">Cloudflare API token</label>
<Input
type="password"
autoComplete="new-password"
name="cloudflare-api-token"
value={acmeTokenDraft}
onChange={(e) => setAcmeTokenDraft(e.target.value)}
placeholder={
acmeTokenConfigured
? "Токен сохранён — введите новый для замены"
: "API token с правом DNS"
}
/>
</div>
</form>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" disabled={acmeSaveBusy} onClick={onTest}>
Проверить Cloudflare
</Button>
<Button size="sm" disabled={acmeSaveBusy} onClick={onSave}>
Сохранить настройки
</Button>
</div>
</OpsPanel>
)
}
function CertPartReference() { function CertPartReference() {
return ( return (
<OpsPanel <OpsPanel
title="RouterOS 7 · /certificate — справка CLI" title="RouterOS 7 · /certificate — справка CLI"
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве." description="RouterOS 7 умеет обновлять Let's Encrypt сам. Этот CLI — справка; автообновление MM включается панелью выше."
contentClassName="px-5 py-4" contentClassName="px-5 py-4"
> >
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
@@ -279,7 +199,7 @@ function CertPartReference() {
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide"> <p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
{b.title} {b.title}
</p> </p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre"> <pre tabIndex={0} role="region" aria-label={"Команды RouterOS: " + b.title} className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2">
{b.lines.join("\n")} {b.lines.join("\n")}
</pre> </pre>
</div> </div>
@@ -328,6 +248,7 @@ function CertPartIssueForm({
<SectionTitle>Основные</SectionTitle> <SectionTitle>Основные</SectionTitle>
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат"> <FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
<select <select
aria-label="Сервер выпуска"
value={issueServerId} value={issueServerId}
onChange={(e) => setIssueServerId(e.target.value)} onChange={(e) => setIssueServerId(e.target.value)}
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" 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"
@@ -345,6 +266,7 @@ function CertPartIssueForm({
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве"> <FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
<Input <Input
className="font-mono" className="font-mono"
aria-label="Имя сертификата"
value={issueCertName} value={issueCertName}
onChange={(e) => setIssueCertName(e.target.value)} onChange={(e) => setIssueCertName(e.target.value)}
placeholder="router-le" placeholder="router-le"
@@ -359,6 +281,7 @@ function CertPartIssueForm({
<FormField label="Common Name" required hint="Основное имя в сертификате"> <FormField label="Common Name" required hint="Основное имя в сертификате">
<Input <Input
className="font-mono" className="font-mono"
aria-label="Основной домен"
value={issueCommonName} value={issueCommonName}
onChange={(e) => setIssueCommonName(e.target.value)} onChange={(e) => setIssueCommonName(e.target.value)}
placeholder="vpn.example.com" placeholder="vpn.example.com"
@@ -367,6 +290,7 @@ function CertPartIssueForm({
<FormField label="SAN" hint="По одному имени в строке"> <FormField label="SAN" hint="По одному имени в строке">
<textarea <textarea
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
aria-label="Дополнительные домены"
value={issueSans} value={issueSans}
onChange={(e) => setIssueSans(e.target.value)} onChange={(e) => setIssueSans(e.target.value)}
placeholder="www.example.com" placeholder="www.example.com"
@@ -387,15 +311,16 @@ function CertPartIssueForm({
<p className="text-sm font-medium">Trust store · www</p> <p className="text-sm font-medium">Trust store · www</p>
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p> <p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
</div> </div>
<FormToggle checked={issueTrustWww} onChange={setIssueTrustWww} /> <FormToggle ariaLabel="Trust store www" checked={issueTrustWww} onChange={setIssueTrustWww} />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium">Trust store · api</p> <p className="text-sm font-medium">Trust store · api</p>
<p className="text-xs text-muted-foreground">REST API и управление</p> <p className="text-xs text-muted-foreground">REST API и управление</p>
</div> </div>
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} /> <FormToggle ariaLabel="Trust store api" checked={issueTrustApi} onChange={setIssueTrustApi} />
</div> </div>
<p className="text-xs text-muted-foreground">После импорта сертификат будет назначен сервисам www-ssl и api-ssl; оба сервиса будут включены. Переключатели выше задают trust store сертификата.</p>
</div> </div>
)} )}
@@ -407,6 +332,8 @@ function CertPartIssueForm({
<li>Имя: {issueCertName || "—"}</li> <li>Имя: {issueCertName || "—"}</li>
<li>CN: {issueCommonName || "—"}</li> <li>CN: {issueCommonName || "—"}</li>
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li> <li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
<li>Сервисы: www-ssl и api-ssl будут включены и получат этот сертификат.</li>
<li>DNS: приложение попробует синхронизировать A-записи доменов с IPv4 сервера; CNAME сохраняются.</li>
</ul> </ul>
</div> </div>
)} )}
@@ -415,21 +342,42 @@ function CertPartIssueForm({
} }
export default function CertificatesPage() { export default function CertificatesPage() {
return <Suspense fallback={<p role="status">Загрузка контекста сертификатов…</p>}><CertificatesSource /></Suspense>
}
function CertificatesSource() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
return <CertificatesContent key={mode + ":" + backendUrl} />
}
function CertificatesContent() {
const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource() const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
const isLive = prefsHydrated && mode === "live" const isLive = prefsHydrated && mode === "live"
const liveReady = isLive && backendStatus === true const liveReady = isLive && backendStatus === true
const [search, setSearch] = useState("") const params = useSearchParams()
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all") const context = readCertificatesUrl(params)
const [certificates, setCertificates] = useState<CertificateDto[]>([]) const search = context.search
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle") const statusFilter = context.status
const [loadError, setLoadError] = useState<string | null>(null) const selectedServerId = context.server
const [serverList, setServerList] = useState<Server[]>([]) const urlError = context.error
function writeContext(changes: Record<string, string | null>, replace = false) {
const href = patchCertificatesUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
}
const setSearch = (value: string) => writeContext({ q: value }, true)
const setStatusFilter = (value: CertificateStatusFilter) => writeContext({ status: value === "all" ? null : value })
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
const canRead = can("mm:certificates:read")
const canWrite = can("mm:certificates:write")
const canReadServers = can("mm:servers:read")
const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers)
const { snapshots, loading, error: loadError, serverError } = catalog
const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers])
const [issueOpen, setIssueOpen] = useState(false) const [issueOpen, setIssueOpen] = useState(false)
const [issueStep, setIssueStep] = useState(1) const [issueStep, setIssueStep] = useState(1)
const [importOpen, setImportOpen] = useState(false)
const [issueBusy, setIssueBusy] = useState(false)
const [issueServerId, setIssueServerId] = useState("") const [issueServerId, setIssueServerId] = useState("")
const [issueCertName, setIssueCertName] = useState("") const [issueCertName, setIssueCertName] = useState("")
const [issueCommonName, setIssueCommonName] = useState("") const [issueCommonName, setIssueCommonName] = useState("")
@@ -437,28 +385,20 @@ export default function CertificatesPage() {
const [issueTrustWww, setIssueTrustWww] = useState(true) const [issueTrustWww, setIssueTrustWww] = useState(true)
const [issueTrustApi, setIssueTrustApi] = useState(true) const [issueTrustApi, setIssueTrustApi] = useState(true)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
"https://acme-v02.api.letsencrypt.org/directory",
)
const [acmeZoneId, setAcmeZoneId] = useState("")
const [acmeTokenDraft, setAcmeTokenDraft] = useState("")
const [acmeTokenConfigured, setAcmeTokenConfigured] = useState(false)
const [acmeSaveBusy, setAcmeSaveBusy] = useState(false)
const displayCerts = useMemo(() => { const displayCerts = useMemo(() => {
if (!prefsHydrated) return [] if (!prefsHydrated) return []
if (isLive) return certificates if (isLive) return Object.values(snapshots).flatMap(snapshot => snapshot.confirmed?.certificates ?? [])
return routerCertificates.map(mockToDto) return routerCertificates.map(mockToDto)
}, [prefsHydrated, isLive, certificates]) }, [prefsHydrated, isLive, snapshots])
const displayServers = isLive ? serverList : mockServers const displayServers = isLive ? serverList : mockServers
const scopedCerts = useMemo(() => { const scopedCerts = useMemo(() => {
if (urlError) return []
if (selectedServerId === ALL_SERVERS_ID) return displayCerts if (selectedServerId === ALL_SERVERS_ID) return displayCerts
return displayCerts.filter((c) => c.serverId === selectedServerId) return displayCerts.filter((c) => c.serverId === selectedServerId)
}, [displayCerts, selectedServerId]) }, [displayCerts, selectedServerId, urlError])
const certRailItems = useMemo<ServerTileItem[]>(() => ( const certRailItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({ displayServers.map((s) => ({
@@ -467,12 +407,12 @@ export default function CertificatesPage() {
host: s.host, host: s.host,
site: s.site, site: s.site,
country: s.country, country: s.country,
status: s.status, status: isLive ? catalog.servers.find(server => String(server.id) === s.id)?.status ?? undefined : s.status,
type: s.type, type: s.type,
enabled: s.enabled, enabled: s.enabled,
meta: String(displayCerts.filter((c) => c.serverId === s.id).length), meta: isLive && !snapshots[s.id]?.confirmed ? "—" : String(displayCerts.filter((c) => c.serverId === s.id).length),
})) }))
), [displayServers, displayCerts]) ), [displayServers, displayCerts, isLive, catalog.servers, snapshots])
const serverById = useMemo(() => { const serverById = useMemo(() => {
const map = new Map<string, Server>() const map = new Map<string, Server>()
@@ -480,69 +420,21 @@ export default function CertificatesPage() {
return map return map
}, [isLive, serverList]) }, [isLive, serverList])
const loadLive = useCallback( const loadLive = catalog.reload
async (silent = false) => { const issue = useCertificateIssue(backendUrl, liveReady && canRead, liveReady && canWrite, loadLive, isLive)
if (!isLive) return const issueBusy = issue.busy
if (!silent) setLoadState("loading") const issueTerminal = issue.checkpoint?.job?.status === "done" || issue.checkpoint?.job?.status === "failed"
setLoadError(null)
try {
const [certRes, serversRes] = await Promise.all([
listCertificates(backendUrl),
listServers(backendUrl),
])
setCertificates(certRes.certificates)
setServerList(serversRes.map(toFrontendServer))
if (certRes.failures.length > 0 && certRes.certificates.length === 0) {
setLoadError(
certRes.failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; "),
)
} else if (certRes.failures.length > 0) {
toast.warning(`Часть серверов недоступна: ${certRes.failures.length}`)
}
setLoadState("idle")
} catch (e) {
setCertificates([])
setLoadError(e instanceof Error ? e.message : "Не удалось загрузить сертификаты")
setLoadState("error")
}
},
[backendUrl, isLive],
)
const loadAcmeSettings = useCallback(async () => {
if (!liveReady) return
try {
const s = await getAcmeSettings(backendUrl)
setAcmeDirectoryUrl(s.directoryUrl)
setAcmeZoneId(s.defaultZoneId ?? "")
setAcmeTokenConfigured(s.tokenConfigured)
} catch {
/* ignore */
}
}, [backendUrl, liveReady])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setCertificates([])
setServerList([])
setLoadState("idle")
setLoadError(null)
})
return
}
queueMicrotask(() => {
void loadLive()
void loadAcmeSettings()
})
}, [isLive, loadLive, loadAcmeSettings])
const expiring = useMemo( const expiring = useMemo(
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30), () => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30),
[scopedCerts], [scopedCerts],
) )
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts]) const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
const scopeSnapshots = Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId)
const scopeKnown = !urlError && (!isLive && (selectedServerId === ALL_SERVERS_ID || mockServers.some(server => server.id === selectedServerId)) || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null))
const classificationsKnown = scopeKnown && scopedCerts.every(cert => cert.status !== "unknown" && cert.daysLeft !== null)
const filtered = useMemo(() => { const filtered = useMemo(() => {
return scopedCerts.filter((c) => { return scopedCerts.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false if (statusFilter !== "all" && c.status !== statusFilter) return false
@@ -558,93 +450,19 @@ export default function CertificatesPage() {
}, [scopedCerts, search, statusFilter]) }, [scopedCerts, search, statusFilter])
async function handleRefresh() { async function handleRefresh() {
if (!liveReady) return await loadLive()
try {
const res = await refreshCertificates(backendUrl)
setCertificates(res.certificates)
toast.success("Список сертификатов обновлён")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка обновления")
}
}
async function pollIssueJob(jobId: string) {
for (let i = 0; i < 120; i++) {
await new Promise((r) => setTimeout(r, 2000))
const job = await getCertificateIssueJob(backendUrl, jobId)
if (job.status === "done") {
toast.success("Сертификат выпущен и импортирован на роутер")
setIssueOpen(false)
await loadLive(true)
return
}
if (job.status === "failed") {
throw new Error(job.error ?? "Выпуск не удался")
}
}
throw new Error("Таймаут ожидания выпуска сертификата")
} }
async function handleIssue() { async function handleIssue() {
if (!liveReady) return if (issue.checkpoint) { await issue.run(); return }
const domains = [ if (!liveReady || !canWrite || !canRead || urlError || !catalog.hasServers || serverError || !catalog.servers.some(server => String(server.id) === issueServerId && server.enabled)) return
issueCommonName.trim(), const domains = [issueCommonName.trim(), ...issueSans.split(/[\n,;]+/).map(value => value.trim())].filter(Boolean)
...issueSans.split(/[\n,;]+/).map((s) => s.trim()), if (!issueServerId || !issueCertName.trim() || domains.length === 0) { toast.error("Укажите сервер, имя сертификата и домены"); return }
].filter(Boolean) await issue.run({ serverId: issueServerId, certName: issueCertName.trim(), domainNames: domains, trustStore: [...(issueTrustWww ? ["www" as const] : []), ...(issueTrustApi ? ["api" as const] : [])] })
if (!issueServerId || !issueCertName.trim() || domains.length === 0) {
toast.error("Укажите сервер, имя сертификата и домены")
return
}
setIssueBusy(true)
try {
const trustStore: Array<"www" | "api"> = []
if (issueTrustWww) trustStore.push("www")
if (issueTrustApi) trustStore.push("api")
const { jobId } = await createCertificateIssueJob(backendUrl, {
serverId: issueServerId,
certName: issueCertName.trim(),
domainNames: domains,
trustStore,
})
toast.message("Выпуск сертификата запущен…")
await pollIssueJob(jobId)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка выпуска")
} finally {
setIssueBusy(false)
}
} }
function startNewIssue() {
async function handleSaveAcmeSettings() { if (!issue.reset()) return
if (!liveReady) return setIssueCertName(""); setIssueCommonName(""); setIssueSans(""); setIssueStep(1)
setAcmeSaveBusy(true)
try {
const saved = await putAcmeSettings(backendUrl, {
directoryUrl: acmeDirectoryUrl.trim(),
defaultZoneId: acmeZoneId.trim() || null,
cloudflareApiToken: acmeTokenDraft.trim() ? acmeTokenDraft.trim() : undefined,
})
setAcmeTokenConfigured(saved.tokenConfigured)
setAcmeTokenDraft("")
toast.success("Настройки ACME сохранены")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сохранить настройки")
} finally {
setAcmeSaveBusy(false)
}
}
async function handleTestAcme() {
if (!liveReady) return
try {
const res = await testAcmeSettings(backendUrl, {
cloudflareApiToken: acmeTokenDraft.trim() || undefined,
})
if (res.ok) toast.success(res.message ?? "Cloudflare API доступен")
else toast.error(res.message ?? "Проверка не прошла")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Проверка Cloudflare не удалась")
}
} }
return ( return (
@@ -655,7 +473,8 @@ export default function CertificatesPage() {
onSelect={setSelectedServerId} onSelect={setSelectedServerId}
showAll showAll
allCount={displayServers.length} allCount={displayServers.length}
loading={isLive && loadState === "loading" && displayServers.length === 0} preserveSelection
loading={isLive && loading && displayServers.length === 0}
header={ header={
<PageHeader <PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]} crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
@@ -665,47 +484,59 @@ export default function CertificatesPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!liveReady || loadState === "loading"} disabled={!isLive || loading || (!canRead && !canReadServers)}
onClick={() => { onClick={() => {
void handleRefresh() void handleRefresh()
}} }}
> >
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} /> <RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить Обновить
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}> <Button variant="outline" size="sm" disabled title="Импорт сертификатов пока недоступен">
<UploadIcon className="size-4" /> <UploadIcon className="size-4" />
Импорт Импорт
</Button> </Button>
<Button <Button
size="sm" size="sm"
disabled={!liveReady || issueBusy} disabled={!issue.hydrated || !liveReady || !canRead || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
onClick={() => { onClick={() => {
setIssueStep(1) setIssueStep(issue.checkpoint ? 4 : 1)
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId) if (!issue.checkpoint && selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
setIssueOpen(true) setIssueOpen(true)
}} }}
> >
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
Выпустить сертификат {issue.checkpoint ? "Задача выпуска" : "Выпустить сертификат"}
</Button> </Button>
</> </>
} }
/> />
} }
> >
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5 min-w-0">
{!isLive && <p role="status" className="text-xs text-muted-foreground">Моковые данные</p>}
{urlError && <div role="alert" aria-label="Контекст сертификатов">{urlError} <Button variant="outline" size="sm" onClick={() => writeContext({ server: null, status: null, q: null }, true)}>Сбросить контекст</Button></div>}
{!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) && <p role="alert">Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.</p>}
{isLive && backendStatus === false && ( {isLive && backendStatus === false && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300"> <div className="rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
Backend недоступен — live-операции отключены. Backend недоступен — live-операции отключены.
</div> </div>
)} )}
{loadError && isLive && ( {loadError && isLive && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"> <div role="alert" aria-label="Каталог сертификатов" className="[overflow-wrap:anywhere] rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{loadError} {catalog.hasState ? "Последние данные сохранены. " : "Сертификаты пока неизвестны. "}{loadError}
</div> </div>
)} )}
{isLive && serverError && <p role="alert" aria-label="Серверы сертификатов" className="[overflow-wrap:anywhere]">{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}</p>}
{isLive && !canRead && <p role="status">Нет права mm:certificates:read.</p>}
{isLive && !canReadServers && <p role="status">Нет права mm:servers:read. Выбор устройства для выпуска недоступен.</p>}
{isLive && catalog.collectedAt && <p className="text-xs text-muted-foreground">Последнее чтение каталога: {new Date(catalog.collectedAt).toLocaleString("ru-RU")}</p>}
{isLive && Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId).map(snapshot => <div key={snapshot.latest.serverId}>
{snapshot.latest.error && <p role="alert" aria-label={"Сертификаты сервера " + snapshot.latest.serverId} className="[overflow-wrap:anywhere]">{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}</p>}
<p className="text-xs text-muted-foreground">{snapshot.latest.serverName}: {snapshot.confirmed ? "Подтверждённые данные от " + new Date(snapshot.confirmed.collectedAt).toLocaleString("ru-RU") : "Успешного чтения нет"}</p>
</div>)}
{(expiring.length > 0 || expired.length > 0) && ( {(expiring.length > 0 || expired.length > 0) && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{expired.length > 0 && <CertPartAlertExpired expired={expired} />} {expired.length > 0 && <CertPartAlertExpired expired={expired} />}
@@ -713,49 +544,38 @@ export default function CertificatesPage() {
</div> </div>
)} )}
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} /> {!classificationsKnown && <p role="status" className="text-xs text-muted-foreground">Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.</p>}
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} complete={classificationsKnown} totalKnown={scopeKnown} />
{liveReady && ( <CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
<CertPartAcmeSettings
acmeDirectoryUrl={acmeDirectoryUrl} <CertificateAcmeSettings backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
setAcmeDirectoryUrl={setAcmeDirectoryUrl}
acmeZoneId={acmeZoneId}
setAcmeZoneId={setAcmeZoneId}
acmeTokenDraft={acmeTokenDraft}
setAcmeTokenDraft={setAcmeTokenDraft}
acmeTokenConfigured={acmeTokenConfigured}
acmeSaveBusy={acmeSaveBusy}
onTest={() => {
void handleTestAcme()
}}
onSave={() => {
void handleSaveAcmeSettings()
}}
/>
)}
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, CN, эмитенту…" searchPlaceholder="Поиск по имени, CN, эмитенту…"
segmented={{ filterControl={<div role="group" aria-label="Статус сертификатов" className="w-full sm:w-auto max-w-full overflow-x-auto"><SegmentedControl className="whitespace-nowrap"
value: statusFilter, value={statusFilter}
onChange: setStatusFilter, onChange={setStatusFilter}
options: [ options={[
{ value: "all", label: "Все" }, { value: "all", label: "Все" },
{ value: "valid", label: "Действующие" }, { value: "valid", label: "Действующие" },
{ value: "expired", label: "Истёкшие" }, { value: "expired", label: "Истёкшие" },
{ value: "revoked", label: "Отозванные" }, { value: "revoked", label: "Отозванные" },
], { value: "unknown", label: "Неизвестные" },
}} ]}
countLabel={`${filtered.length} сертификатов`} /></div>}
countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
/> />
<CertificatesDataGrid {urlError ? <p role="status" className="p-5">Исправьте контекст ссылки</p> : isLive && scopedCerts.length === 0 && !scopeKnown ? <p role="status" className="p-5">{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}</p> : <CertificatesDataGrid
certificates={filtered} certificates={filtered}
serverMap={serverById} serverMap={serverById}
isLoading={prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0} emptyTitle={search || statusFilter !== "all" ? "Нет совпадений" : "Нет сертификатов"}
/> emptyDescription={search || statusFilter !== "all" ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}
isLoading={isLive && loading && displayCerts.length === 0}
/>}
</DataPageCard> </DataPageCard>
<CertPartReference /> <CertPartReference />
@@ -763,7 +583,7 @@ export default function CertificatesPage() {
</ServerRailLayout> </ServerRailLayout>
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}> <Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0"> <SheetContent side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg min-w-0 flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0"> <SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Выпуск сертификата</SheetTitle> <SheetTitle>Выпуск сертификата</SheetTitle>
<SheetDescription> <SheetDescription>
@@ -771,6 +591,11 @@ export default function CertificatesPage() {
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="px-6 pt-3 text-sm min-w-0 [overflow-wrap:anywhere]">
{(issue.error || issue.storageError) && <p role="alert" aria-label="Выпуск сертификата">{issue.error || issue.storageError}</p>}
{issue.checkpoint && <p className="text-xs text-muted-foreground">Закрытие формы не отменяет серверную задачу. Её можно проверить повторно.</p>}
{issue.checkpoint && <p role="status">Задача {issue.checkpoint.request.requestId}: {issue.checkpoint.job ? ({ queued: "В очереди", running: "Выполняется", done: "Завершена", failed: "Ошибка" }[issue.checkpoint.job.status]) + " · " + issue.checkpoint.job.step : issue.checkpoint.accepted ? "Принята; результат пока неизвестен" : "Принятие запроса пока не подтверждено"}. Сервер {issue.checkpoint.request.serverId}, сертификат {issue.checkpoint.request.certName}.</p>}
</div>
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5"> <Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5"> <StepperNav className="mb-5">
{[ {[
@@ -791,30 +616,33 @@ export default function CertificatesPage() {
<StepperPanel className="flex-1 overflow-y-auto"> <StepperPanel className="flex-1 overflow-y-auto">
{[1, 2, 3, 4].map((s) => ( {[1, 2, 3, 4].map((s) => (
<StepperContent key={s} value={s}> <StepperContent key={s} value={s}>
<fieldset disabled={issueBusy || !!issue.checkpoint} className="min-w-0">
<CertPartIssueForm <CertPartIssueForm
step={s as 1 | 2 | 3 | 4} step={s as 1 | 2 | 3 | 4}
serverList={displayServers} serverList={displayServers}
issueServerId={issueServerId} issueServerId={String(issue.checkpoint?.request.serverId ?? issueServerId)}
setIssueServerId={setIssueServerId} setIssueServerId={setIssueServerId}
issueCertName={issueCertName} issueCertName={issue.checkpoint?.request.certName ?? issueCertName}
setIssueCertName={setIssueCertName} setIssueCertName={setIssueCertName}
issueCommonName={issueCommonName} issueCommonName={issue.checkpoint?.request.domainNames[0] ?? issueCommonName}
setIssueCommonName={setIssueCommonName} setIssueCommonName={setIssueCommonName}
issueSans={issueSans} issueSans={issue.checkpoint ? issue.checkpoint.request.domainNames.slice(1).join("\n") : issueSans}
setIssueSans={setIssueSans} setIssueSans={setIssueSans}
issueTrustWww={issueTrustWww} issueTrustWww={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("www") ?? true : issueTrustWww}
setIssueTrustWww={setIssueTrustWww} setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issueTrustApi} issueTrustApi={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("api") ?? true : issueTrustApi}
setIssueTrustApi={setIssueTrustApi} setIssueTrustApi={setIssueTrustApi}
/> />
</fieldset>
</StepperContent> </StepperContent>
))} ))}
</StepperPanel> </StepperPanel>
</Stepper> </Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2"> <SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}> {issueTerminal && canWrite && <Button variant="outline" disabled={issueBusy} onClick={startNewIssue}>Новый выпуск</Button>}
Отмена <SheetClose render={<Button variant="outline" className="flex-1" />}>
{issue.checkpoint ? "Закрыть" : "Отмена"}
</SheetClose> </SheetClose>
{issueStep > 1 && ( {issueStep > 1 && (
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}> <Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
@@ -824,7 +652,7 @@ export default function CertificatesPage() {
{issueStep < 4 ? ( {issueStep < 4 ? (
<Button <Button
className="flex-1" className="flex-1"
disabled={issueStep === 1 && (!issueServerId || !issueCertName)} disabled={issueBusy || !issue.checkpoint && issueStep === 1 && (!issueServerId || !issueCertName)}
onClick={() => setIssueStep((s) => s + 1)} onClick={() => setIssueStep((s) => s + 1)}
> >
Далее Далее
@@ -832,26 +660,16 @@ export default function CertificatesPage() {
) : ( ) : (
<Button <Button
className="flex-1" className="flex-1"
disabled={!liveReady || issueBusy} disabled={!issue.hydrated || !liveReady || !canRead || issueBusy || issueTerminal || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
onClick={() => { void handleIssue() }} onClick={() => { void handleIssue() }}
> >
{issueBusy ? "Выпуск…" : "Выпустить"} {issueBusy ? "Проверяем…" : issueTerminal ? "Задача завершена" : issue.checkpoint ? "Продолжить проверку" : "Выпустить"}
</Button> </Button>
)} )}
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт сертификата"
description="Загрузите PEM, CRT или PKCS#12 для импорта на RouterOS"
accept=".pem,.crt,.cer,.p12,.pfx"
onImport={async (files) => {
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
}}
/>
</> </>
) )
} }
+56 -45
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useMemo, useEffect } from "react" import { useEffect, useRef, useState, useMemo } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { import {
@@ -8,21 +8,20 @@ import {
type CommunityRow, type CommunityRow,
TYPE_LABELS, TYPE_LABELS,
ACTION_LABELS, ACTION_LABELS,
ACTION_COLOR,
} from "@/components/data-grids/communities-data-grid" } from "@/components/data-grids/communities-data-grid"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
PlusIcon, SearchIcon, TagIcon, FilterIcon, PlusIcon, SearchIcon, TagIcon, FilterIcon,
CopyIcon, CheckIcon, TrashIcon, PencilIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
LoaderCircleIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { filters } from "@/lib/data" import { filters } from "@/lib/data"
import { useDataSource } from "@/lib/data-source" import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
import { useEvoBGP } from "@/lib/evobgp-context" import { useEvoBGP } from "@/lib/evobgp-context"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
@@ -98,24 +97,31 @@ const COMMUNITIES: Community[] = [
// ─── page ───────────────────────────────────────────────────────────────────── // ─── page ─────────────────────────────────────────────────────────────────────
export default function CommunitiesPage() { export default function CommunitiesPage() {
const { mode } = useDataSource() const { sourceKey } = useEvoBGP()
const { enabled, snapshot, loading, error } = useEvoBGP() return <CommunitiesContent key={sourceKey} />
const useEvoCatalog = mode === "live" && enabled }
function CommunitiesContent() {
const { mode, snapshot, loading, known } = useEvoCatalogView()
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all") const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
const [copied, setCopied] = useState<string | null>(null) const [copied, setCopied] = useState<string | null>(null)
const [selected, setSelected] = useState<Community | null>(null) const [copying, setCopying] = useState(false)
const [copyError, setCopyError] = useState<string | null>(null)
const copyRequest = useRef({ active: true, pending: false, revision: 0, timer: null as ReturnType<typeof setTimeout> | null })
useEffect(() => {
const state = copyRequest.current; state.active = true
return () => { state.active = false; state.revision++; if (state.timer) clearTimeout(state.timer) }
}, [])
const [selectedId, setSelectedId] = useState<string | null>(null)
const listData = useMemo((): Community[] => { const listData = useMemo((): Community[] => {
if (!useEvoCatalog) return COMMUNITIES if (mode === "mock") return COMMUNITIES
if (loading && !snapshot) return [] if (loading && !snapshot) return []
return snapshot?.communities ?? [] return snapshot?.communities ?? []
}, [useEvoCatalog, loading, snapshot]) }, [mode, loading, snapshot])
useEffect(() => { const selected = known ? listData.find(row => row.id === selectedId) ?? null : null
setSelected(null)
}, [useEvoCatalog])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.toLowerCase() const q = search.toLowerCase()
@@ -126,10 +132,24 @@ export default function CommunitiesPage() {
}) })
}, [search, typeFilter, listData]) }, [search, typeFilter, listData])
const handleCopy = (value: string) => { const handleCopy = async (value: string) => {
navigator.clipboard.writeText(value).catch(() => {}) const state = copyRequest.current
setCopied(value) if (!state.active || state.pending) return
setTimeout(() => setCopied(null), 1500) state.pending = true; const revision = ++state.revision
if (state.timer) clearTimeout(state.timer)
const current = () => state.active && state.revision === revision
setCopying(true); setCopied(null); setCopyError(null)
try {
await navigator.clipboard.writeText(value)
if (!current()) return
setCopied(value)
state.timer = setTimeout(() => { if (current()) setCopied(null) }, 1500)
} catch {
if (current()) setCopyError("Браузер не разрешил запись в буфер обмена. Повторите копирование или выделите значение вручную.")
} finally {
state.pending = false
if (current()) setCopying(false)
}
} }
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
@@ -138,8 +158,7 @@ export default function CommunitiesPage() {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Данные" }, { label: "Communities" }]} crumbs={[{ label: "Данные" }, { label: "Communities" }]}
actions={ actions={mode === "live" ? null : <Button size="sm">
<Button size="sm">
<PlusIcon className="size-4" />Добавить <PlusIcon className="size-4" />Добавить
</Button> </Button>
} }
@@ -147,19 +166,10 @@ export default function CommunitiesPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{useEvoCatalog && ( <EvoBgpCatalogStatus />
<p className={cn( {copyError && <Alert variant="warning"><AlertTitle>Community не скопировано</AlertTitle><AlertDescription>{copyError}</AlertDescription></Alert>}
"text-xs flex items-center gap-1.5 -mt-1", {copying && <p role="status" className="text-xs text-muted-foreground">Копирование community…</p>}
error ? "text-destructive" : "text-muted-foreground", {copied && <p role="status" className="text-xs text-muted-foreground">Community {copied} скопировано</p>}
)}>
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
{error
? `EvoBGP: ${error}`
: snapshot?.fetchedAt
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
</p>
)}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка communities" aria-label="Сводка communities"
@@ -167,35 +177,35 @@ export default function CommunitiesPage() {
{ {
id: "all", id: "all",
label: "Всего communities", label: "Всего communities",
value: String(listData.length), value: known ? String(listData.length) : "—",
icon: <TagIcon className="size-4" />, icon: <TagIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "enabled", id: "enabled",
label: "Активных", label: "Активных",
value: String(listData.filter((c) => c.enabled).length), value: known ? String(listData.filter((c) => c.enabled).length) : "—",
icon: <CheckIcon className="size-4" />, icon: <CheckIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "standard", id: "standard",
label: "Стандартных", label: "Стандартных",
value: String(listData.filter((c) => c.type === "standard").length), value: known ? String(listData.filter((c) => c.type === "standard").length) : "—",
icon: <TagIcon className="size-4" />, icon: <TagIcon className="size-4" />,
iconClassName: "text-info", iconClassName: "text-info",
}, },
{ {
id: "filters", id: "filters",
label: "Использует фильтры", label: "Использует фильтры",
value: String(new Set(listData.flatMap((c) => c.filterIds)).size), value: mode === "mock" ? String(new Set(listData.flatMap((c) => c.filterIds)).size) : "—",
icon: <FilterIcon className="size-4" />, icon: <FilterIcon className="size-4" />,
iconClassName: "text-primary", iconClassName: "text-primary",
}, },
]} ]}
/> />
<div className="grid grid-cols-[1fr_320px] gap-5"> <div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-5">
{/* ── main table ── */} {/* ── main table ── */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* toolbar */} {/* toolbar */}
@@ -204,7 +214,7 @@ export default function CommunitiesPage() {
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" /> <SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} /> <Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
</div> </div>
<div className="flex gap-1"> <div className="flex flex-wrap gap-1">
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => ( {(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"} <Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}> className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
@@ -216,13 +226,14 @@ export default function CommunitiesPage() {
{/* list */} {/* list */}
<DataPageCard> <DataPageCard>
<CommunitiesDataGrid {known ? <CommunitiesDataGrid
communities={filtered} communities={filtered}
selectedId={selected?.id} selectedId={selected?.id}
copiedValue={copied} copiedValue={copied}
onSelect={setSelected} copying={copying}
onSelect={row => setSelectedId(row.id)}
onCopy={handleCopy} onCopy={handleCopy}
/> /> : <EvoBgpCatalogUnavailable />}
</DataPageCard> </DataPageCard>
</div> </div>
@@ -234,8 +245,8 @@ export default function CommunitiesPage() {
description={selected.name} description={selected.name}
headerRight={ headerRight={
<div className="flex gap-1 shrink-0"> <div className="flex gap-1 shrink-0">
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button> <Button size="sm" variant="ghost" aria-label="Изменить community" disabled className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button> <Button size="sm" variant="ghost" aria-label="Удалить community" disabled className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
</div> </div>
} }
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5" contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
@@ -256,7 +267,7 @@ export default function CommunitiesPage() {
{[ {[
["Тип", TYPE_LABELS[selected.type]], ["Тип", TYPE_LABELS[selected.type]],
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`], ["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
["Маршрутов", selected.prefixCount.toLocaleString("ru-RU")], ["Маршрутов", selected.prefixCount?.toLocaleString("ru-RU") ?? "—"],
["Серверов", selected.serverCount.toLocaleString("ru-RU")], ["Серверов", selected.serverCount.toLocaleString("ru-RU")],
].map(([k, v]) => ( ].map(([k, v]) => (
<div key={k} className="flex justify-between gap-2"> <div key={k} className="flex justify-between gap-2">
@@ -282,7 +293,7 @@ export default function CommunitiesPage() {
)} )}
<div className="pt-2 border-t flex flex-col gap-2"> <div className="pt-2 border-t flex flex-col gap-2">
<Button size="sm" variant="outline" className="w-full justify-start gap-2" onClick={() => handleCopy(selected.value)}> <Button size="sm" variant="outline" className="w-full justify-start gap-2" disabled={copying} onClick={() => { void handleCopy(selected.value) }}>
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />} {copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
Скопировать значение Скопировать значение
</Button> </Button>
+369 -182
View File
@@ -1,15 +1,20 @@
"use client" "use client"
import { useMemo, useState } from "react" import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useSearchParams } from "next/navigation"
import { readContainersUrl, patchContainersUrl, type ContainerStatusFilter } from "@/lib/containers-url-state"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { routerContainers, servers } from "@/lib/data" import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
import type { RouterContainer } from "@/lib/data" import type { RouterContainer, Server } from "@/lib/data"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuItem, DropdownMenuSeparator,
@@ -18,124 +23,110 @@ import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon, BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon, MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, ActivityIcon, ServerIcon, CodeXmlIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon, TerminalIcon, AlertCircleIcon, RefreshCwIcon,
} from "lucide-react" } from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { useDataSource } from "@/lib/data-source"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
import { mutateContainer, type ContainerAction, readContainersState } from "@/shared/api/containers"
import { serverListSchema } from "@mmapp/contracts/servers"
import type { ContainerServerState, ContainersState } from "@mmapp/contracts/containers"
import { can } from "@/lib/auth"
import { userApiError } from "@/shared/api/users"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── helpers ────────────────────────────────────────────────────────────────── type ContainerView = Omit<RouterContainer, "status" | "startOnBoot"> & { status: RouterContainer["status"] | "unknown"; startOnBoot: boolean | null }
type ContainerServer = Omit<Server, "status"> & {status: Server["status"] | null}
function serverFor(id: string) { interface BackendServer {
return servers.find((s) => s.id === id) id: number
name: string
host: string
type?: Server["type"]
site?: string
country: string
asn?: string
enabled: boolean
status?: Server["status"] | null
latency?: number | null
} }
function statusConfig(status: RouterContainer["status"]) { function mapBackendServer(s: BackendServer): ContainerServer {
return { return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? null,
latency: s.latency ?? null,
sessions: 0,
}
}
function statusConfig(status: ContainerView["status"]) {
return {
unknown: { dot: "bg-muted-foreground", badge: "bg-muted text-muted-foreground border-border", label: "Неизвестно" },
running: { running: {
dot: "bg-emerald-500 animate-pulse", dot: "bg-success",
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", badge: "bg-success/10 text-success border-success/20",
label: "Running", label: "Работает",
}, },
stopped: { stopped: {
dot: "bg-muted-foreground", dot: "bg-muted-foreground",
badge: "bg-muted/50 text-muted-foreground border-border", badge: "bg-muted/50 text-muted-foreground border-border",
label: "Stopped", label: "Остановлен",
}, },
error: { error: {
dot: "bg-red-500 animate-pulse", dot: "bg-destructive",
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", badge: "bg-destructive/10 text-destructive border-destructive/20",
label: "Error", label: "Ошибка",
}, },
}[status] }[status]
} }
// ─── RSC generator ──────────────────────────────────────────────────────────── type ContainerExport = { container: ContainerView; collectedAt: string | null; failures: ContainerServerState["failures"]; source: "live" | "mock" }
function ExportSheet({ selection, onClose }: {selection: ContainerExport | null; onClose: () => void}) {
function generateContainerRsc(c: RouterContainer): string { const formats = useMemo(() => [{
const srv = serverFor(c.serverId) id: "json", label: "Снимок JSON", filename: "container-" + (selection?.container.id ?? "snapshot").replace(/[^a-zA-Z0-9_-]/g, "_") + ".json",
const lines: string[] = [] code: selection ? JSON.stringify(selection, null, 2) : "",
lines.push(`# RouterOS Container — ${c.name}`) }], [selection])
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`) return <CodeExportSheet open={!!selection} onClose={onClose} title="Снимок контейнера" description="Данные каталога; конфигурация устройства и резервная копия в снимок не входят."
lines.push(`# Образ: ${c.image}:${c.tag}`) className="data-[side=right]:w-full data-[side=right]:sm:max-w-xl min-w-0 [&_[data-slot=sheet-footer]]:flex-wrap"
lines.push(`# RouterOS 7.4+ · /container`) beforeCode={<p className="text-xs text-muted-foreground">{selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}</p>}
lines.push(``) returnFocus={() => Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === "Действия контейнера " + selection?.container.name) ?? null}
formats={formats} />
// interface
for (const iface of c.interfaces) {
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
}
lines.push(``)
// envs
if (c.envs.length > 0) {
lines.push(`/container/envs/add name=${c.name}-envs \\`)
for (const { key, value } of c.envs) {
lines.push(` ${key}="${value}" \\`)
}
lines.push(``)
}
// mounts
for (const m of c.mounts) {
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
if (m.src) lines.push(` src=${m.src} \\`)
lines.push(` dst=${m.dst}`)
lines.push(``)
}
// container
lines.push(`/container/add \\`)
lines.push(` remote-image=${c.image}:${c.tag} \\`)
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
if (c.envs.length > 0) lines.push(` envlist=${c.name}-envs \\`)
if (c.mounts.length > 0) lines.push(` mounts=${c.mounts.map((m) => `${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)}`).join(",")} \\`)
if (c.cmd) lines.push(` cmd="${c.cmd}" \\`)
if (c.startOnBoot) lines.push(` start-on-boot=yes \\`)
if (c.comment) lines.push(` comment="${c.comment}" \\`)
lines.push(` logging=yes`)
return lines.join("\n")
} }
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, container, onClose }: {
open: boolean; container: RouterContainer | null; onClose: () => void
}) {
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
return (
<CodeExportSheet
open={open}
onClose={onClose}
title="Экспорт Container"
description="RouterOS 7.4+ · /container · /interface/veth"
formats={[
{
id: "rsc",
label: "MikroTik .rsc",
filename: `${container?.name ?? "container"}.rsc`,
code,
},
]}
/>
)
}
// ─── Container card ───────────────────────────────────────────────────────────
function ContainerCard({ function ContainerCard({
container, container,
server,
live,
busy,
onExport, onExport,
onStart,
onStop,
onRestart,
onRemove,
}: { }: {
container: RouterContainer container: ContainerView
server?: ContainerServer
live: boolean
busy: boolean
onExport: () => void onExport: () => void
onStart: () => void
onStop: () => void
onRestart: () => void
onRemove: () => void
}) { }) {
const srv = serverFor(container.serverId)
const cfg = statusConfig(container.status) const cfg = statusConfig(container.status)
const canMutate = live && Boolean(container.rosId) && !busy
return ( return (
<Frame dense className="w-full overflow-hidden"> <Frame dense className="w-full overflow-hidden">
@@ -150,51 +141,56 @@ function ContainerCard({
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger render={ <DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7 shrink-0"> <Button variant="ghost" size="icon" aria-label={"Действия контейнера " + container.name} className="size-11 sm:size-8 shrink-0" disabled={busy}>
<MoreHorizontalIcon className="size-4" /> <MoreHorizontalIcon aria-hidden="true" className="size-4" />
</Button> </Button>
} /> } />
<DropdownMenuContent side="bottom" align="end"> <DropdownMenuContent side="bottom" align="end">
{container.status === "running" ? ( {container.status === "running" ? (
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem> <DropdownMenuItem disabled={!canMutate} onClick={onStop}>
<StopCircleIcon aria-hidden="true" className="size-4 text-warning" />Остановить
</DropdownMenuItem>
) : ( ) : (
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem> <DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onStart}>
<PlayIcon aria-hidden="true" className="size-4 text-success" />Запустить
</DropdownMenuItem>
)} )}
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem> <DropdownMenuItem disabled><TerminalIcon aria-hidden="true" className="size-4" />Логи</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem> <DropdownMenuItem disabled><PencilIcon aria-hidden="true" className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem> <DropdownMenuItem onClick={onExport}><CodeXmlIcon aria-hidden="true" className="size-4" />Снимок JSON</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem> <DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onRestart}>
<PowerIcon aria-hidden="true" className="size-4" />Перезапустить
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem> <DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
<Trash2Icon className="size-4" />Удалить
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="px-4 py-3 flex flex-col gap-3"> <div className="px-4 py-3 flex flex-col gap-3">
{/* image */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" /> <BoxIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs text-foreground/80"> <span className="font-mono text-xs text-foreground/80 min-w-0 [overflow-wrap:anywhere]">
{container.image}:<span className="text-muted-foreground">{container.tag}</span> {container.image || "Образ неизвестен"}{container.tag && <>:<span className="text-muted-foreground">{container.tag}</span></>}
</span> </span>
</div> </div>
{/* server */} {server && (
{srv && (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<ServerIcon className="size-3.5 shrink-0" /> <ServerIcon aria-hidden="true" className="size-3.5 shrink-0" />
<Flag code={srv.country} size={12} /> <Flag code={server.country} size={12} />
<span className="font-mono">{srv.name}</span> <span className="font-mono truncate" title={server.name}>{server.name}</span>
</div> </div>
)} )}
{/* uptime + stats */} {container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
{container.status === "running" && (
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5"> <div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
{container.uptime && ( {container.uptime && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ActivityIcon className="size-3 text-emerald-500" /> <ActivityIcon aria-hidden="true" className="size-3 text-success" />
<span>{container.uptime}</span> <span>{container.uptime}</span>
</div> </div>
)} )}
@@ -202,7 +198,7 @@ function ContainerCard({
<div> <div>
<span className="text-muted-foreground">CPU </span> <span className="text-muted-foreground">CPU </span>
<span className={cn("font-mono font-medium", <span className={cn("font-mono font-medium",
container.cpu > 50 ? "text-amber-500" : "text-foreground")}> container.cpu > 50 ? "text-warning" : "text-foreground")}>
{container.cpu}% {container.cpu}%
</span> </span>
</div> </div>
@@ -210,13 +206,12 @@ function ContainerCard({
{container.memMb !== undefined && ( {container.memMb !== undefined && (
<div> <div>
<span className="text-muted-foreground">RAM </span> <span className="text-muted-foreground">RAM </span>
<span className="font-mono font-medium">{container.memMb} МБ</span> <span className="font-mono font-medium">{container.memMb} МиБ</span>
</div> </div>
)} )}
</div> </div>
)} )}
{/* interfaces */}
{container.interfaces.length > 0 && ( {container.interfaces.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{container.interfaces.map((i) => ( {container.interfaces.map((i) => (
@@ -227,7 +222,6 @@ function ContainerCard({
</div> </div>
)} )}
{/* mounts */}
{container.mounts.length > 0 && ( {container.mounts.length > 0 && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{container.mounts.map((m, idx) => ( {container.mounts.map((m, idx) => (
@@ -241,7 +235,7 @@ function ContainerCard({
)} )}
{container.comment && ( {container.comment && (
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p> <p className="text-xs text-muted-foreground border-t pt-2 [overflow-wrap:anywhere]">{container.comment}</p>
)} )}
</div> </div>
</FramePanel> </FramePanel>
@@ -249,42 +243,225 @@ function ContainerCard({
) )
} }
// ════════════════════════════════════════════════════════════════════════════ export default function ContainersPage() { return <Suspense fallback={<p role="status">Загрузка контекста контейнеров…</p>}><ContainersSource /></Suspense> }
export default function ContainersPage() { function ContainersSource() {
const [search, setSearch] = useState("") const { mode, backendUrl, prefsHydrated } = useDataSource()
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all") if (!prefsHydrated) return <p role="status">Загрузка настроек контейнеров…</p>
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null) return <ContainersContent key={JSON.stringify([mode, backendUrl])} />
}
function ContainersContent() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const canRead = can("mm:dashboard:read")
const canReadServers = can("mm:servers:read")
const canWrite = can("mm:servers:write")
const params = useSearchParams()
const context = readContainersUrl(params)
const search = context.search
const statusFilter = context.status
const selectedServerId = context.server
const urlError = context.error
function writeContext(changes: Record<string, string | null>, replace = false) {
const href = patchContainersUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
}
const setSearch = (value: string) => writeContext({ q: value }, true)
const setStatusFilter = (value: ContainerStatusFilter) => writeContext({ status: value === "all" ? null : value })
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
const [exportContainer, setExportContainer] = useState<ContainerExport | null>(null)
const [state, setState] = useState<ContainersState | null>(null)
const [lastStates, setLastStates] = useState<Record<number, ContainerServerState>>({})
const [liveServers, setLiveServers] = useState<ContainerServer[]>([])
const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
const [busyId, setBusyId] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ContainerView | null>(null)
const deleteReturnLabel = useRef<string | null>(null)
useEffect(() => {
if (deleteTarget || busyId || !deleteReturnLabel.current) return
const frame = requestAnimationFrame(() => {
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current)
;(target ?? Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus()
})
return () => cancelAnimationFrame(frame)
}, [deleteTarget, busyId])
const [operationError, setOperationError] = useState<string | null>(null)
const [operationNotice, setOperationNotice] = useState<string | null>(null)
const [commandNeedsRead, setCommandNeedsRead] = useState(false)
const mutationLock = useRef(false)
const mutationController = useRef<AbortController | null>(null)
const alive = useRef(true)
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
const [liveError, setLiveError] = useState<string | null>(null)
const [serversError, setServersError] = useState<string | null>(null)
const [readAt, setReadAt] = useState<number | null>(null)
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
const loadController = useRef<AbortController | null>(null)
const loadRevision = useRef(0)
const loadLive = useCallback(async () => {
if (!isLive) return false
loadController.current?.abort()
const controller = new AbortController()
loadController.current = controller
const revision = ++loadRevision.current
const current = () => !controller.signal.aborted && loadRevision.current === revision
setLoading(true)
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
let stateComplete = !canRead
const tasks = await Promise.allSettled([
canRead ? readContainersState(backendUrl, { signal }).then(result => {
stateComplete = result.servers.every(node => node.availability !== "error")
if (current()) {
setState(result); setReadAt(Date.now()); setLiveError(null)
if (result.servers.every(node => node.availability !== "error")) setCommandNeedsRead(false)
setLastStates(previous => Object.fromEntries(result.servers.map(node => [node.serverId, node.availability === "error" ? previous[node.serverId] ?? node : node])))
}
}).catch(error => { if (current()) setLiveError(userApiError(error)); throw error }) : Promise.resolve(),
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
const result = serverListSchema.parse(payload)
if (new Set(result.map(server => server.id)).size !== result.length) throw new Error("Каталог серверов содержит дубли ID")
if (current()) { setLiveServers(result.filter(server => server.enabled).map(mapBackendServer)); setServersReadAt(Date.now()); setServersError(null) }
}).catch(error => { if (current()) setServersError(userApiError(error)); throw error }) : Promise.resolve(),
])
if (current()) setLoading(false)
return current() && stateComplete && tasks.every(task => task.status === "fulfilled")
}, [isLive, backendUrl, canRead, canReadServers])
useEffect(() => { let cancelled = false; queueMicrotask(() => { if (!cancelled) void loadLive() }); return () => { cancelled = true; loadController.current?.abort() } }, [loadLive])
const displayContainers: ContainerView[] = useMemo(() => isLive ? Object.values(lastStates).flatMap(node => node.containers) : mockContainers, [isLive, lastStates])
const displayServers = isLive ? liveServers : mockServers.filter(server => server.enabled)
const effectiveServerId = selectedServerId
const relevantStates = state?.servers.filter(node => selectedServerId === ALL_SERVERS_ID || String(node.serverId) === selectedServerId)
const known = !isLive || !!state && (selectedServerId === ALL_SERVERS_ID || !!relevantStates?.length) && relevantStates!.every(node => node.availability !== "error")
const stale = isLive && !!liveError
const writeReady = !isLive || canWrite && !urlError && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError
const nodeFresh = (serverId: string) => !isLive || writeReady && state?.servers.some(node => String(node.serverId) === serverId && node.availability === "available" && displayServers.some(server => server.id === serverId))
const scoped = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
return displayContainers.filter((c) => c.serverId === effectiveServerId)
}, [displayContainers, effectiveServerId])
const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
)
const railItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status ?? "unknown",
type: s.type,
enabled: s.enabled,
meta: isLive && (!state || !state.servers.some(node => String(node.serverId) === s.id && node.availability !== "error")) ? "—" : String(displayContainers.filter((c) => c.serverId === s.id).length),
}))
), [displayServers, displayContainers, state, isLive])
const filtered = useMemo(() => { const filtered = useMemo(() => {
return routerContainers.filter((c) => { return scoped.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true if (!search) return true
const q = search.toLowerCase() const q = search.toLowerCase()
return ( return (
c.name.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) ||
c.image.toLowerCase().includes(q) || c.image.toLowerCase().includes(q) ||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false) (serverById[c.serverId]?.name.toLowerCase().includes(q) ?? false)
) )
}) })
}, [search, statusFilter]) }, [search, statusFilter, scoped, serverById])
const running = routerContainers.filter((c) => c.status === "running").length const running = scoped.filter((c) => c.status === "running").length
const stopped = routerContainers.filter((c) => c.status === "stopped").length const stopped = scoped.filter((c) => c.status === "stopped").length
const errors = routerContainers.filter((c) => c.status === "error").length const errors = scoped.filter((c) => c.status === "error").length
const statusKnown = known && scoped.every(container => container.status !== "unknown")
const currentDeleteTarget = deleteTarget ? displayContainers.find(row => row.id === deleteTarget.id && row.rosId === deleteTarget.rosId && row.serverId === deleteTarget.serverId) : undefined
const deleteStopped = currentDeleteTarget?.status === "stopped"
async function mutate(c: ContainerView, action: ContainerAction) {
if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId) || mutationLock.current || !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId)) return
if (action === "remove" && !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId && row.status === "stopped")) return
mutationLock.current = true
const controller = new AbortController()
mutationController.current = controller
const current = () => alive.current && !controller.signal.aborted
setBusyId(c.id); setOperationError(null); setOperationNotice(null)
try {
await mutateContainer(backendUrl, c.serverId, c.rosId, action, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
if (!current()) return
const labels = { start: "запуск", stop: "остановка", restart: "перезапуск", remove: "удаление" }
const message = c.name + ": API подтвердил команду «" + labels[action] + "». Состояние проверяется повторным чтением."
setOperationNotice(message)
if (action === "remove") {
setLastStates(previous => Object.fromEntries(Object.entries(previous).map(([id, node]) => [id, { ...node, containers: node.containers.filter(row => row.id !== c.id) }])))
setDeleteTarget(null)
}
toast.success("Команда подтверждена API.")
const refreshed = await loadLive()
if (current() && !refreshed) setOperationNotice(message + " Обновление каталогов не удалось; повторите чтение.")
} catch (error) {
if (!current()) return
const uncertain = !(error instanceof ApiClientError && error.status >= 400 && error.status < 500)
setCommandNeedsRead(uncertain)
setOperationError(c.name + ": " + userApiError(error) + (uncertain ? " Результат команды не подтверждён; она могла выполниться частично. Обновите каталог перед новым действием." : " Команда отклонена API."))
} finally {
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
if (alive.current) setBusyId(null)
}
}
return ( return (
<div className="flex flex-col h-full"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]} items={railItems}
actions={ selectedId={effectiveServerId}
<Button size="sm"> onSelect={setSelectedServerId}
<BoxIcon className="size-4" />Новый контейнер showAll
</Button> preserveSelection
} allCount={displayServers.length}
/> loading={isLive && loading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || loading || !canRead && !canReadServers || !!busyId}
>
<RefreshCwIcon aria-hidden="true" className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button size="sm" disabled title="Создание контейнеров ещё не подключено">
<BoxIcon aria-hidden="true" className="size-4" />Новый контейнер
</Button>
</>
}
/>
}
>
<div className="flex flex-col min-w-0 gap-5 [overflow-wrap:anywhere]">
<div className="flex-1 overflow-y-auto p-6"> {urlError && <Alert role="alert" aria-label="Контекст контейнеров" variant="warning"><AlertDescription>{urlError}<Button type="button" variant="outline" onClick={() => writeContext({server:null,status:null,q:null})}>Сбросить контекст</Button></AlertDescription></Alert>}
<div className="flex flex-col gap-5"> {operationNotice && <p role="status" className="text-sm [overflow-wrap:anywhere]">{operationNotice}</p>}
{operationError && !deleteTarget && <Alert role="alert" aria-label="Команда контейнера" variant="warning"><AlertDescription>{operationError}</AlertDescription></Alert>}
{isLive && !canRead && <p role="status">Для каталога контейнеров требуется mm:dashboard:read.</p>}
{isLive && !canReadServers && <p role="status">Для каталога серверов требуется mm:servers:read. Команды недоступны.</p>}
{isLive && loading && !state && <p role="status">Загрузка контейнеров…</p>}
{isLive && readAt && <p role="status" className="text-xs text-muted-foreground">Каталог контейнеров получен: {new Date(readAt).toLocaleTimeString("ru-RU")}</p>}
{isLive && serversReadAt && <p role="status" className="text-xs text-muted-foreground">Серверы получены: {new Date(serversReadAt).toLocaleTimeString("ru-RU")}</p>}
{isLive && liveError && <Alert variant="warning" role="alert" aria-label="Каталог контейнеров"><AlertDescription>{state ? "Последние данные сохранены." : "Контейнеры пока неизвестны."} {liveError}</AlertDescription></Alert>}
{isLive && serversError && <Alert variant="warning" role="alert" aria-label="Серверы контейнеров"><AlertDescription>{serversReadAt ? "Последний каталог сохранён." : "Каталог серверов пока неизвестен."} {serversError}</AlertDescription></Alert>}
{isLive && relevantStates?.map(node => node.availability === "error" || node.failures.length ? <Alert key={node.serverId} variant="warning" role="alert" aria-label={"Контейнеры сервера " + node.serverId}><AlertDescription>Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: {node.failures.map(f => f.resource + ": " + f.error).join("; ")}. {node.availability === "available" ? "Контейнеры получены; настройки загружены частично." : lastStates[node.serverId]?.containers.length ? "Последние контейнеры сохранены." : "Список неизвестен."}</AlertDescription></Alert> : node.availability === "unsupported" ? <p key={node.serverId} role="status">Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: пакет container недоступен.</p> : null)}
{isLive && selectedServerId !== ALL_SERVERS_ID && !displayServers.some(server => server.id === selectedServerId) && <p role="alert">Выбранный сервер недоступен в каталоге. Выберите другое устройство; фильтр сохранён.</p>}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка контейнеров" aria-label="Сводка контейнеров"
@@ -292,97 +469,100 @@ export default function ContainersPage() {
{ {
id: "all", id: "all",
label: "Всего", label: "Всего",
value: routerContainers.length, value: known ? scoped.length : "—",
icon: <BoxIcon className="size-4" />, icon: <BoxIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "running", id: "running",
label: "Running", label: "Работает",
value: running, value: statusKnown ? running : "—",
icon: <PlayIcon className="size-4" />, icon: <PlayIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "stopped", id: "stopped",
label: "Stopped", label: "Остановлен",
value: stopped, value: statusKnown ? stopped : "—",
icon: <StopCircleIcon className="size-4" />, icon: <StopCircleIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "errors", id: "errors",
label: "Ошибок", label: "Ошибок",
value: errors, value: statusKnown ? errors : "—",
icon: <AlertCircleIcon className="size-4" />, icon: <AlertCircleIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-destructive", iconClassName: "text-destructive",
variant: errors > 0 ? "destructive" : "default", variant: errors > 0 ? "destructive" : "default",
}, },
]} ]}
/> />
{/* Info banner */} <div className="flex items-start gap-3 rounded-lg bg-info/5 border border-info/20 px-4 py-3 text-sm">
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm"> <BoxIcon aria-hidden="true" className="size-5 text-info shrink-0 mt-0.5" />
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium text-violet-600 dark:text-violet-400">Контейнеры доступны с RouterOS 7.4+</p> <p className="font-medium text-foreground">Контейнеры доступны с RouterOS 7.4+</p>
<p className="text-muted-foreground text-xs mt-0.5"> <p className="text-muted-foreground text-xs mt-0.5">
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>. Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
Интерфейсы veth создаются автоматически. Сеть veth настраивается на устройстве. Создание контейнеров в интерфейсе пока недоступно.
</p> </p>
</div> </div>
</div> </div>
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]"> <div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-0 w-full sm:w-auto">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" /> <SearchIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
<input <input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm" className="flex-1 min-w-0 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, образу, серверу…" aria-label="Поиск контейнеров" placeholder="Поиск по имени, образу, серверу…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </div>
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5"> <div role="group" aria-label="Статус контейнеров" className="flex flex-wrap items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{(["all", "running", "stopped", "error"] as const).map((s) => ( {(["all", "running", "stopped", "error", "unknown"] as const).map((s) => (
<button key={s} <button key={s}
onClick={() => setStatusFilter(s)} type="button" aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}
className={cn( className={cn(
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors", "px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
statusFilter === s statusFilter === s
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)}> )}>
{s === "all" ? "Все" : s === "running" ? "Running" : s === "stopped" ? "Stopped" : "Error"} {s === "all" ? "Все" : s === "running" ? "Работает" : s === "stopped" ? "Остановлен" : s === "unknown" ? "Неизвестно" : "Ошибка"}
</button> </button>
))} ))}
</div> </div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span> <span className="text-sm text-muted-foreground ml-auto">{known ? filtered.length : "—"} контейнеров</span>
</div> </div>
{/* Grid */} {filtered.length === 0 ? (!known ? <p role="status">Список контейнеров пока неизвестен.</p> : (
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground"> <div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<BoxIcon className="size-10 mb-3 opacity-20" /> <BoxIcon aria-hidden="true" className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Контейнеры не найдены</p> <p className="text-sm font-medium">Контейнеры не найдены</p>
<p className="text-xs mt-1">Добавьте первый контейнер или измените фильтр</p> <p className="text-xs mt-1">{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}</p>
</div> </div>
) : ( )) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((c) => ( {filtered.map((c) => (
<ContainerCard <ContainerCard
key={c.id} key={c.id}
container={c} container={c}
onExport={() => setExportContainer(c)} server={serverById[c.serverId]}
live={isLive && !!nodeFresh(c.serverId)}
busy={!!busyId}
onExport={() => { const snapshot = lastStates[Number(c.serverId)]; setExportContainer({ container: c, collectedAt: snapshot?.collectedAt ?? null, failures: snapshot?.failures ?? [], source: isLive ? "live" : "mock" }) }}
onStart={() => { void mutate(c, "start") }}
onStop={() => { void mutate(c, "stop") }}
onRestart={() => { void mutate(c, "restart") }}
onRemove={() => { deleteReturnLabel.current = "Действия контейнера " + c.name; setOperationError(null); setDeleteTarget(c) }}
/> />
))} ))}
</div> </div>
)} )}
{/* RouterOS reference */}
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4"> <OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[ {[
@@ -433,7 +613,7 @@ export default function ContainersPage() {
].map((b) => ( ].map((b) => (
<div key={b.title}> <div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p> <p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre"> <pre className="bg-muted rounded-md p-2.5 text-foreground text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
{b.lines.join("\n")} {b.lines.join("\n")}
</pre> </pre>
</div> </div>
@@ -442,13 +622,20 @@ export default function ContainersPage() {
</OpsPanel> </OpsPanel>
</div> </div>
</div> </ServerRailLayout>
<ExportSheet {deleteTarget && <AlertDialog open onOpenChange={open => { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}>
open={!!exportContainer} <AlertDialogContent>
container={exportContainer} <AlertDialogHeader><AlertDialogTitle>Удалить контейнер?</AlertDialogTitle><AlertDialogDescription>{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. {deleteStopped ? "Будет удалён остановленный контейнер. Дополнительная очистка файлов не выполняется." : "Сначала закройте это окно, остановите контейнер через меню действий и обновите каталог. Удаление доступно только после подтверждённой остановки."}</AlertDialogDescription></AlertDialogHeader>
onClose={() => setExportContainer(null)} {operationError && <p role="alert" aria-label="Удаление контейнера" className="text-sm text-destructive [overflow-wrap:anywhere]">{operationError}</p>}
/> <AlertDialogFooter>
</div> {(commandNeedsRead || !deleteStopped) && <Button type="button" variant="outline" disabled={loading || !!busyId} onClick={() => { void loadLive() }}>Обновить каталог</Button>}
<AlertDialogCancel disabled={!!busyId}>Отмена</AlertDialogCancel>
<AlertDialogAction type="button" variant="destructive" disabled={!!busyId || !deleteStopped || !nodeFresh(deleteTarget.serverId)} aria-busy={!!busyId} onClick={() => { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>}
<ExportSheet selection={exportContainer} onClose={() => setExportContainer(null)} />
</>
) )
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+13 -28
View File
@@ -8,25 +8,22 @@ import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog" import { FileImportDialog } from "@/components/file-import-dialog"
import { domains as mockDomains } from "@/lib/data" import { domains as mockDomains } from "@/lib/data"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react" import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
export default function DomainsPage() { export default function DomainsPage() {
const { mode } = useDataSource() const { mode, snapshot, loading, known } = useEvoCatalogView()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled const useEvoCatalog = mode === "live"
const rows = useMemo(() => { const rows = useMemo(() => {
if (!useEvoCatalog) return mockDomains if (mode === "mock") return mockDomains
if (loading && !snapshot) return [] if (loading && !snapshot) return []
return snapshot?.domains ?? [] return snapshot?.domains ?? []
}, [useEvoCatalog, loading, snapshot]) }, [mode, loading, snapshot])
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) return rows if (!search) return rows
@@ -43,8 +40,7 @@ export default function DomainsPage() {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Данные" }, { label: "Домены" }]} crumbs={[{ label: "Данные" }, { label: "Домены" }]}
actions={ actions={mode === "live" ? null : <>
<>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}> <Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт <UploadIcon className="size-4" />Импорт
</Button> </Button>
@@ -58,34 +54,23 @@ export default function DomainsPage() {
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1> <h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik Домены из каталога для BGP-фильтрации
</p> </p>
{useEvoCatalog && ( <div className="mt-3"><EvoBgpCatalogStatus /></div>
<p className={cn(
"text-xs mt-2 flex items-center gap-1.5",
error ? "text-destructive" : "text-muted-foreground",
)}>
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
{error
? `EvoBGP: ${error}`
: snapshot?.fetchedAt
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
</p>
)}
</div> </div>
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по домену…" searchPlaceholder="Поиск по домену…"
countLabel={`${filtered.length} доменов`} countLabel={known ? `${filtered.length} доменов` : "Количество: —"}
/> />
<DomainsDataGrid {known || loading ? <DomainsDataGrid
domains={filtered} domains={filtered}
isLoading={useEvoCatalog && loading && !snapshot} isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog} pagination={useEvoCatalog}
/> emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
/> : <EvoBgpCatalogUnavailable />}
</DataPageCard> </DataPageCard>
</div> </div>
</div> </div>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+541 -397
View File
File diff suppressed because it is too large. Load diff
+13 -28
View File
@@ -8,25 +8,22 @@ import { IpRangesDataGrid } from "@/components/data-grids/ip-ranges-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog" import { FileImportDialog } from "@/components/file-import-dialog"
import { ipRanges as mockIpRanges } from "@/lib/data" import { ipRanges as mockIpRanges } from "@/lib/data"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react" import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
export default function IpRangesPage() { export default function IpRangesPage() {
const { mode } = useDataSource() const { mode, snapshot, loading, known } = useEvoCatalogView()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled const useEvoCatalog = mode === "live"
const rows = useMemo(() => { const rows = useMemo(() => {
if (!useEvoCatalog) return mockIpRanges if (mode === "mock") return mockIpRanges
if (loading && !snapshot) return [] if (loading && !snapshot) return []
return snapshot?.ipRanges ?? [] return snapshot?.ipRanges ?? []
}, [useEvoCatalog, loading, snapshot]) }, [mode, loading, snapshot])
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) return rows if (!search) return rows
@@ -44,8 +41,7 @@ export default function IpRangesPage() {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]} crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
actions={ actions={mode === "live" ? null : <>
<>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}> <Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт <UploadIcon className="size-4" />Импорт
</Button> </Button>
@@ -59,34 +55,23 @@ export default function IpRangesPage() {
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1> <h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам CIDR-блоки из каталога для сетевых фильтров
</p> </p>
{useEvoCatalog && ( <div className="mt-3"><EvoBgpCatalogStatus /></div>
<p className={cn(
"text-xs mt-2 flex items-center gap-1.5",
error ? "text-destructive" : "text-muted-foreground",
)}>
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
{error
? `EvoBGP: ${error}`
: snapshot?.fetchedAt
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
</p>
)}
</div> </div>
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по CIDR, ASN…" searchPlaceholder="Поиск по CIDR, ASN…"
countLabel={`${filtered.length} диапазонов`} countLabel={known ? `${filtered.length} диапазонов` : "Количество: —"}
/> />
<IpRangesDataGrid {known || loading ? <IpRangesDataGrid
ipRanges={filtered} ipRanges={filtered}
isLoading={useEvoCatalog && loading && !snapshot} isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog} pagination={useEvoCatalog}
/> emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
/> : <EvoBgpCatalogUnavailable />}
</DataPageCard> </DataPageCard>
</div> </div>
</div> </div>
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -22,7 +22,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
К содержимому К содержимому
</a> </a>
<AppSidebar /> <AppSidebar />
<SidebarInset id="main-content" className="h-svh overflow-hidden"> <SidebarInset tabIndex={-1} id="main-content" className="h-svh overflow-hidden">
{children} {children}
</SidebarInset> </SidebarInset>
<CommandPalette /> <CommandPalette />
+2
View File
@@ -0,0 +1,2 @@
import { LogsWorkspace } from "@/components/logs/logs-workspace"
export default function LogsPage() { return <LogsWorkspace /> }
File diff suppressed because it is too large. Load diff
+415 -383
View File
File diff suppressed because it is too large. Load diff
+269 -327
View File
@@ -1,6 +1,10 @@
"use client" "use client"
import { useEffect, useRef, useState, useMemo, useCallback } from "react" import { toast } from "sonner"
import { Suspense, useEffect, useRef, useState, useMemo, useCallback } from "react"
import { useSearchParams } from "next/navigation"
import { readProbeSource } from "@/lib/probe-source"
import { readProbeUrl, patchProbeUrl } from "@/lib/probe-url"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit" import { FormToggle } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
@@ -11,21 +15,21 @@ import {
type SchedRule, type SchedRule,
type SchedType, type SchedType,
} from "@/components/data-grids/probes-schedule-data-grid" } from "@/components/data-grids/probes-schedule-data-grid"
import { import {ProbesSpeedSchedule} from "@/components/probes-speed-schedule"
ProbesSpeedProbesDataGrid,
type SpeedProbeApiRow,
} from "@/components/data-grids/probes-speed-probes-data-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail" import { type ServerTileItem } from "@/components/server-tile-rail"
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data" import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
import { filtersGreResponseSchema } from "@/lib/filters-response"
import { can } from "@/lib/auth"
import { readProbeOutput, probeServersSchema } from "@/lib/probe-response"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { import {
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon, PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon, ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
ZapIcon, ClockIcon, CheckIcon, TerminalIcon, ZapIcon, CheckIcon, TerminalIcon,
LoaderCircleIcon, AlertCircleIcon, LoaderCircleIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
@@ -33,7 +37,7 @@ import { requestJson } from "@/shared/api/http-client"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu" type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu"
type RunStatus = "running" | "done" | "error" type RunStatus = "running" | "done" | "error" | "cancelled"
type TraceProto = "icmp" | "udp" | "tcp" type TraceProto = "icmp" | "udp" | "tcp"
type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR" type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
@@ -66,12 +70,12 @@ const TOOL_META: Record<DiagTool, {
color: string color: string
description: string description: string
}> = { }> = {
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-sky-500", description: "Проверка связи и RTT" }, ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-info", description: "Проверка связи и RTT" },
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-violet-500", description: "Трассировка маршрута" }, traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-info", description: "Трассировка маршрута" },
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-emerald-500", description: "Пропускная способность" }, bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-success", description: "Пропускная способность" },
dns: { label: "DNS", ros: "/resolve", Icon: SearchIcon, color: "text-amber-500", description: "Разрешение DNS-имён" }, dns: { label: "DNS", ros: "DNS приложения", Icon: SearchIcon, color: "text-warning", description: "DNS на хосте приложения" },
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-blue-500", description: "Поиск активного маршрута" }, route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-info", description: "Поиск активного маршрута" },
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-orange-500", description: "Определение MTU пути" }, mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-warning", description: "Определение MTU пути" },
} }
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
@@ -80,31 +84,25 @@ function makeApiFetch(backendUrl: string) {
} }
} }
async function copyProbeText(text:string){
try{await navigator.clipboard.writeText(text);toast.success("Скопировано.")}
catch{toast.error("Не удалось скопировать. Разрешите доступ к буферу обмена или выделите текст вручную.")}
}
function inferProbeLineKind(line: string): OutputLine["kind"] { function inferProbeLineKind(line: string): OutputLine["kind"] {
const l = line.toLowerCase() const l = line.toLowerCase()
if (l.includes("error:") || (l.includes("timeout") && l.includes("seq="))) return "err" 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.includes("✓")) return "ok"
if (l.startsWith(";;") || l.trim() === "") return "dim" if (l.startsWith(";;") || l.trim() === "") return "dim"
if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header" if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header"
return "normal" return "normal"
} }
function parseProbeOutput(text: string): OutputLine[] { function parseProbeOutput(text: string): OutputLine[] {
return text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) })) const lines = text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) }))
return lines.length > 1000 ? [{ text: "Показаны последние 1000 строк; начало вывода сокращено.", kind: "dim" }, ...lines.slice(-1000)] : lines
} }
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
}
// SpeedProbeApiRow imported from probes-speed-probes-data-grid // SpeedProbeApiRow imported from probes-speed-probes-data-grid
@@ -321,7 +319,7 @@ function buildCommand(
case "bandwidth": case "bandwidth":
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both` return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
case "dns": case "dns":
return `/resolve ${target} type=${opts.dnsType ?? "A"} server=1.1.1.1` return "DNS приложения: " + target + " · " + (opts.dnsType ?? "A") + " · резолвер хоста backend"
case "route": case "route":
return `/ip route lookup ip=${target}` return `/ip route lookup ip=${target}`
case "mtu": case "mtu":
@@ -331,11 +329,11 @@ function buildCommand(
// ─── small UI components ────────────────────────────────────────────────────── // ─── small UI components ──────────────────────────────────────────────────────
function NativeSelect({ value, onChange, children, className }: { function NativeSelect({ value, onChange, children, className, ariaLabel }: {
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; ariaLabel?: string
}) { }) {
return ( return (
<select value={value} onChange={e => onChange(e.target.value)} <select aria-label={ariaLabel} value={value} onChange={e => onChange(e.target.value)}
className={cn( className={cn(
"h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground", "h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground",
"transition-colors outline-none", "transition-colors outline-none",
@@ -347,7 +345,8 @@ function NativeSelect({ value, onChange, children, className }: {
) )
} }
function OptionLabel({ children }: { children: React.ReactNode }) { function OptionLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
if (htmlFor) return <label htmlFor={htmlFor} className="block text-xs font-medium text-muted-foreground mb-1">{children}</label>
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p> return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
} }
@@ -355,9 +354,9 @@ function SegBtn<T extends string | number>({ value, current, onClick, children }
value: T; current: T; onClick: (v: T) => void; children: React.ReactNode value: T; current: T; onClick: (v: T) => void; children: React.ReactNode
}) { }) {
return ( return (
<button onClick={() => onClick(value)} <button type="button" aria-pressed={value === current} onClick={() => onClick(value)}
className={cn( className={cn(
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors", "px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50", value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50",
)}> )}>
{children} {children}
@@ -368,107 +367,18 @@ function SegBtn<T extends string | number>({ value, current, onClick, children }
// ─── terminal output ────────────────────────────────────────────────────────── // ─── terminal output ──────────────────────────────────────────────────────────
function TerminalOutput({ test }: { test: DiagTest }) { function TerminalOutput({ test }: { test: DiagTest }) {
const ref = useRef<HTMLDivElement>(null) const ref=useRef<HTMLDivElement>(null),follow=useRef(true)
const visible = test.lines.slice(0, test.totalLines) const visible=test.lines.slice(0,test.totalLines)
useEffect(()=>{if(ref.current&&follow.current)ref.current.scrollTop=ref.current.scrollHeight},[visible.length])
useEffect(() => { return <div ref={ref} role="region" aria-label={"Вывод "+TOOL_META[test.tool].label+" · "+test.srcServerName} tabIndex={0}
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight onScroll={()=>{const el=ref.current;if(el)follow.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}}
}, [visible.length]) className="h-full min-h-0 overflow-auto rounded-lg bg-muted/30 border px-3 py-3 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="mb-2 text-muted-foreground [overflow-wrap:anywhere]">{test.source==="demo"?"Демо":test.tool==="dns"?"DNS приложения":test.srcServerName} · {test.command}</div>
return ( {visible.map((line,i)=><div key={i} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="ok"&&"text-success",line.kind==="err"&&"text-destructive",line.kind==="dim"&&"text-muted-foreground",line.kind==="header"&&"text-foreground font-semibold",line.kind==="cmd"&&"text-warning",line.kind==="normal"&&"text-foreground")}>{line.text||" "}</div>)}
<div ref={ref} </div>
className="h-72 overflow-y-auto rounded-lg bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 px-4 py-3 font-mono text-xs leading-relaxed">
{/* command line */}
<div className="mb-2 flex items-center gap-2">
<span className="text-zinc-500">[{test.srcServerName}]</span>
<span className="text-emerald-400">$</span>
<span className="text-zinc-300">{test.command}</span>
{test.status === "running" && (
<span className="inline-block size-1.5 rounded-full bg-emerald-400 animate-pulse ml-1" />
)}
</div>
{/* output */}
{visible.map((line, i) => (
<div key={i} className={cn(
"whitespace-pre leading-5",
line.kind === "ok" && "text-emerald-400",
line.kind === "err" && "text-red-400",
line.kind === "dim" && "text-zinc-500",
line.kind === "header" && "text-zinc-400 font-semibold",
line.kind === "cmd" && "text-amber-400",
line.kind === "normal" && "text-zinc-300",
)}>
{line.text || " "}
</div>
))}
{test.status === "running" && visible.length < test.lines.length && (
<div className="text-zinc-600 animate-pulse">…</div>
)}
</div>
)
} }
// ─── uptime speed probes (live) ─────────────────────────────────────────────── // ─── uptime speed probes (live) ───────────────────────────────────────────────
function ScheduleSpeedProbesLive({
apiFetch,
serversForName,
}: {
apiFetch: ReturnType<typeof makeApiFetch>
serversForName: Server[]
}) {
const [rows, setRows] = useState<SpeedProbeApiRow[]>([])
const [loading, setLoading] = useState(true)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setErr(null)
void apiFetch<{ probes: SpeedProbeApiRow[] }>("/api/uptime/speed-probes")
.then(d => {
if (!cancelled) setRows(d.probes ?? [])
})
.catch(e => {
if (!cancelled) setErr(e instanceof Error ? e.message : String(e))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [apiFetch])
const name = (id: string) => serversForName.find(s => s.id === id)?.name ?? id
if (loading) {
return (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-5 animate-spin" />
Загрузка расписания из БД…
</div>
)
}
if (err) {
return (
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertCircleIcon className="size-4 shrink-0" />
{err}
</div>
)
}
return (
<div className="flex flex-col gap-3">
<DataPageCard>
<ProbesSpeedProbesDataGrid rows={rows} serverName={name} />
</DataPageCard>
<p className="text-[11px] text-muted-foreground">
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.
</p>
</div>
)
}
// ─── schedule tab ───────────────────────────────────────────────────────────── // ─── schedule tab ─────────────────────────────────────────────────────────────
const INIT_RULES: SchedRule[] = [ const INIT_RULES: SchedRule[] = [
@@ -492,21 +402,12 @@ function ScheduleTab({
}) { }) {
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1") const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
const [addTun, setAddTun] = useState("") const [selectedTunnel, setAddTun] = useState("")
const [addType, setAddType] = useState<SchedType>("ping") const [addType, setAddType] = useState<SchedType>("ping")
const [addMin, setAddMin] = useState(10) const [addMin, setAddMin] = useState(10)
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer]) const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
useEffect(() => { const addTun = addTunnels.some(t=>t.id===selectedTunnel) ? selectedTunnel : addTunnels[0]?.id ?? ""
if (defaultSrc) setAddSrc(defaultSrc)
}, [defaultSrc])
useEffect(() => {
const list = tunnelsForServer(addSrc)
if (list.length && !list.some(t => t.id === addTun)) {
setAddTun(list[0]?.id ?? "")
}
}, [addSrc, addTun, tunnelsForServer])
const tunnelName = (srcId: string, tunnelId: string) => const tunnelName = (srcId: string, tunnelId: string) =>
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
@@ -528,21 +429,21 @@ function ScheduleTab({
<Frame dense className="w-full overflow-hidden"> <Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden"> <FramePanel className="p-0 overflow-hidden">
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium"> <div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
<PlusIcon className="size-4 text-muted-foreground" />Новое правило <PlusIcon aria-hidden="true" className="size-4 text-muted-foreground" />Новое правило
</div> </div>
<div className="px-4 py-4 flex flex-wrap items-end gap-3"> <div className="px-4 py-4 flex flex-wrap items-end gap-3">
<div><OptionLabel>Сервер</OptionLabel> <div><OptionLabel>Сервер</OptionLabel>
<NativeSelect value={addSrc} onChange={setAddSrc} className="min-w-[160px]"> <NativeSelect ariaLabel="Устройство расписания" value={addSrc} onChange={value=>{setAddSrc(value);setAddTun("")}} className="min-w-[160px]">
{serverOptions.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)} {serverOptions.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</NativeSelect> </NativeSelect>
</div> </div>
<div><OptionLabel>GRE-туннель</OptionLabel> <div><OptionLabel>GRE-туннель</OptionLabel>
<NativeSelect value={addTun} onChange={setAddTun} className="min-w-[150px]"> <NativeSelect ariaLabel="Туннель расписания" value={addTun} onChange={setAddTun} className="min-w-[150px]">
{addTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)} {addTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</NativeSelect> </NativeSelect>
</div> </div>
<div><OptionLabel>Тип</OptionLabel> <div><OptionLabel>Тип</OptionLabel>
<NativeSelect value={addType} onChange={v => setAddType(v as SchedType)}> <NativeSelect ariaLabel="Тип теста расписания" value={addType} onChange={v => setAddType(v as SchedType)}>
<option value="ping">Ping</option> <option value="ping">Ping</option>
<option value="bandwidth">BW-тест</option> <option value="bandwidth">BW-тест</option>
<option value="both">Ping + BW</option> <option value="both">Ping + BW</option>
@@ -566,7 +467,7 @@ function ScheduleTab({
</Frame> </Frame>
) : ( ) : (
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}> <Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
<PlusIcon className="size-4" />Добавить правило <PlusIcon aria-hidden="true" className="size-4" />Добавить правило
</Button> </Button>
)} )}
</div> </div>
@@ -576,16 +477,40 @@ function ScheduleTab({
// ─── page ───────────────────────────────────────────────────────────────────── // ─── page ─────────────────────────────────────────────────────────────────────
export default function ProbesPage() { export default function ProbesPage() {
return <Suspense fallback={<p role="status">Загрузка диагностики…</p>}><ProbesRoute /></Suspense>
}
function ProbesRoute() {
const params = useSearchParams()
const url = readProbeUrl(new URLSearchParams(params.toString()))
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (url.error) return <div role="alert" aria-label="Ссылка диагностики" className="p-4 space-y-2"><p>{url.error}</p><Button type="button" variant="outline" onClick={() => window.history.replaceState(null, "", patchProbeUrl(new URLSearchParams(params.toString()), null) + window.location.hash)}>Сбросить контекст диагностики</Button></div>
if (!prefsHydrated) return <p role="status">Загрузка настроек диагностики…</p>
return <ProbesContent key={JSON.stringify([mode, backendUrl])} />
}
function ProbesContent() {
const params = useSearchParams()
const { server } = readProbeUrl(new URLSearchParams(params.toString()))
const srcId = server ?? ""
const selectServer = (id: string) => window.history.pushState(null, "", patchProbeUrl(new URLSearchParams(params.toString()), id || null) + window.location.hash)
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const canRead = can("mm:servers:read")
const canRun = can("mm:servers:write")
const canReadGre = can("mm:filters:read")
const isLive = mode === "live" const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [liveServers, setLiveServers] = useState<Server[]>([]) const [liveServers, setLiveServers] = useState<Server[]>([])
const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle")) const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle"))
const [catalogError, setCatalogError] = useState<string | null>(null)
const [catalogRevision, setCatalogRevision] = useState(0)
const [catalogReadAt, setCatalogReadAt] = useState<number | null>(null)
const [greState, setGreState] = useState<{ id: string; state: "loading" | "ready" | "error"; error?: string } | null>(null)
const [greRevision, setGreRevision] = useState(0)
const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({}) const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({})
const [tool, setTool] = useState<DiagTool>("ping") const [tool, setTool] = useState<DiagTool>("ping")
const [srcId, setSrcId] = useState(servers[0]?.id ?? "srv1")
const [target, setTarget] = useState("8.8.8.8") const [target, setTarget] = useState("8.8.8.8")
const [pingCount, setPingCount] = useState(5) const [pingCount, setPingCount] = useState(5)
const [pingSize, setPingSize] = useState(64) const [pingSize, setPingSize] = useState(64)
@@ -594,44 +519,40 @@ export default function ProbesPage() {
const [traceUseDns, setTraceUseDns] = useState(false) const [traceUseDns, setTraceUseDns] = useState(false)
const [traceHops] = useState(30) const [traceHops] = useState(30)
const [dnsType, setDnsType] = useState<DnsType>("A") const [dnsType, setDnsType] = useState<DnsType>("A")
const [bwTunId, setBwTunId] = useState("") const [bwChoice, setBwChoice] = useState<{ serverId: string; id: string } | null>(null)
const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp") const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp")
const [bwDuration, setBwDuration] = useState(10) const [bwDuration, setBwDuration] = useState(10)
const [runBusy, setRunBusy] = useState(false) const [runBusy, setRunBusy] = useState(false)
/** В live: IPv4 для src-address (DNS A-запись к host API); undefined = грузим, null = не удалось */ /** В live: IPv4 для src-address (DNS A-запись к host API); undefined = грузим, null = не удалось */
const [rosSrcV4, setRosSrcV4] = useState<string | null | undefined>(undefined) const [source, setSource] = useState<{ key: string; ipv4?: string | null; error?: string; loading: boolean } | null>(null)
const [sourceRevision, setSourceRevision] = useState(0)
useEffect(() => { useEffect(() => {
if (!isLive) { if (!isLive || !canRead) return
queueMicrotask(() => setLiveLoad("idle")) let cancelled = false
return const ctrl = new AbortController()
} queueMicrotask(() => { if (!cancelled) { setLiveLoad("loading"); setCatalogError(null) } })
setLiveLoad("loading") void apiFetch<unknown>("/api/servers", { signal: ctrl.signal })
void apiFetch<BackendServerRow[]>("/api/servers") .then(raw => {
.then(rows => { const result = probeServersSchema.safeParse(raw)
const mapped: Server[] = rows.map(s => ({ if (!result.success) throw new Error("Некорректный ответ API: каталог устройств не подтверждён.")
id: String(s.id), if (cancelled) return
name: s.name || s.host, setLiveServers(result.data.map(s => ({
host: s.host, id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—",
model: "—", site: s.site ?? "", country: s.country ?? "UN", asn: s.asn ?? "", type: s.type,
os: "—", enabled: s.enabled, status: s.status ?? "unknown",
site: s.site, latency: s.latency != null ? Math.round(s.latency) : null, sessions: 0,
country: s.country || "UN", })))
asn: s.asn, setCatalogReadAt(Date.now())
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}))
setLiveServers(mapped)
setLiveLoad("idle") setLiveLoad("idle")
}) })
.catch(() => { .catch(error => {
setLiveServers([]) if (cancelled) return
setCatalogError(error instanceof Error ? error.message : "Не удалось прочитать устройства.")
setLiveLoad("error") setLiveLoad("error")
}) })
}, [isLive, apiFetch]) return () => { cancelled = true; ctrl.abort() }
}, [isLive, canRead, apiFetch, catalogRevision])
const allServers = useMemo(() => { const allServers = useMemo(() => {
if (!isLive) return servers if (!isLive) return servers
@@ -652,72 +573,71 @@ export default function ProbesPage() {
})) }))
), [allServers]) ), [allServers])
useEffect(() => { useEffect(() => {
if (!isLive) { if (!isLive || !canReadGre || liveLoad !== "idle" || !allServers.some(s => s.id === srcId && s.enabled)) return
setRosSrcV4(undefined)
return
}
const n = Number.parseInt(srcId, 10)
if (!Number.isFinite(n)) {
setRosSrcV4(undefined)
return
}
let cancelled = false let cancelled = false
setRosSrcV4(undefined) const ctrl = new AbortController()
void apiFetch<{ ipv4: string | null }>(`/api/servers/${n}/ros-src-address`) queueMicrotask(() => { if (!cancelled) setGreState({ id: srcId, state: "loading" }) })
.then((r) => { void apiFetch<unknown>("/api/filters/gre-tunnels?serverId=" + encodeURIComponent(srcId), { signal: ctrl.signal })
if (!cancelled) setRosSrcV4(r.ipv4) .then(raw => {
const result = filtersGreResponseSchema.safeParse(raw)
if (!result.success || result.data.tunnels.some(tunnel => tunnel.serverId !== srcId)) throw new Error("Некорректный ответ API: GRE выбранного устройства не подтверждены.")
if (cancelled) return
setGreByServer(prev => ({ ...prev, [srcId]: result.data.tunnels }))
setGreState({ id: srcId, state: "ready" })
}) })
.catch(() => { .catch(error => {
if (!cancelled) setRosSrcV4(null) if (cancelled) return
setGreState({ id: srcId, state: "error", error: error instanceof Error ? error.message : "Не удалось прочитать GRE." })
}) })
return () => { cancelled = true } return () => { cancelled = true; ctrl.abort() }
}, [isLive, srcId, apiFetch]) }, [isLive, canReadGre, liveLoad, allServers, srcId, apiFetch, greRevision])
const ensureGre = useCallback(async (serverId: string) => {
if (!isLive) return
try {
const d = await apiFetch<{ tunnels: GreTunnel[] }>(
`/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`,
)
setGreByServer(prev => ({ ...prev, [serverId]: d.tunnels }))
} catch {
setGreByServer(prev => ({ ...prev, [serverId]: [] }))
}
}, [isLive, apiFetch])
useEffect(() => {
if (!isLive || !srcId) return
void ensureGre(srcId)
}, [isLive, srcId, ensureGre])
const tunnelsForSrc = useMemo(() => { const tunnelsForSrc = useMemo(() => {
if (isLive) return greByServer[srcId] ?? [] if (isLive) return greByServer[srcId] ?? []
return greTunnels.filter(t => t.serverId === srcId) return greTunnels.filter(t => t.serverId === srcId)
}, [isLive, greByServer, srcId]) }, [isLive, greByServer, srcId])
useEffect(() => {
const first = allServers[0]?.id
if (!first) return
if (!allServers.some(s => s.id === srcId)) setSrcId(first)
}, [allServers, srcId])
useEffect(() => {
const list = isLive ? (greByServer[srcId] ?? []) : greTunnels.filter(t => t.serverId === srcId)
if (list.length && !list.some(t => t.id === bwTunId)) setBwTunId(list[0]!.id)
}, [isLive, greByServer, srcId, bwTunId])
// ── run state ── // ── run state ──
const [tests, setTests] = useState<DiagTest[]>([]) const [tests, setTests] = useState<DiagTest[]>([])
const [tab, setTab] = useState<"history" | "schedule">("history") const [tab, setTab] = useState<"history" | "schedule">("history")
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES) const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
const nextId = useRef(1) const nextId = useRef(1)
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null) const targetRef = useRef<HTMLInputElement>(null)
const runButtonRef = useRef<HTMLButtonElement>(null)
/** Живой POST /probes/run — отмена через fetch AbortSignal */ /** Живой POST /probes/run — отмена через fetch AbortSignal */
const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null) const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null)
const runLock = useRef(false)
const alive = useRef(true)
useEffect(() => {
alive.current = true
return () => { alive.current = false; liveProbeRunRef.current?.ctrl.abort() }
}, [])
const bwTunnels = tunnelsForSrc const bwTunnels = tunnelsForSrc
const bwTunId = bwChoice?.serverId === srcId ? bwChoice.id : (bwTunnels[0]?.id ?? "")
const setBwTunId = (id: string) => setBwChoice({ serverId: srcId, id })
const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId]) const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId])
const sourceKey = JSON.stringify([srcId, srcServer?.host])
const sourceRequired = tool === "ping" || tool === "traceroute" || tool === "mtu"
const sourceCurrent = source?.key === sourceKey ? source : null
const rosSrcV4 = sourceCurrent?.ipv4
useEffect(() => {
if (!isLive || !canRead || liveLoad !== "idle" || !srcServer?.enabled || !sourceRequired) return
const host = srcServer.host, id = srcServer.id
if (!/^[1-9][0-9]*$/.test(id) || !Number.isSafeInteger(Number(id))) return
let cancelled = false
const ctrl = new AbortController()
queueMicrotask(() => { if (!cancelled) setSource({ key: sourceKey, loading: true }) })
void apiFetch<unknown>("/api/servers/" + id + "/ros-src-address", { signal: ctrl.signal })
.then(raw => { const ipv4 = readProbeSource(raw, host); if (!cancelled) setSource({ key: sourceKey, loading: false, ipv4 }) })
.catch(error => { if (!cancelled) setSource({ key: sourceKey, loading: false, error: error instanceof Error ? error.message : "Источник preview неизвестен." }) })
return () => { cancelled = true; ctrl.abort() }
}, [isLive, canRead, liveLoad, srcServer, sourceRequired, sourceKey, apiFetch, sourceRevision])
const greReady = !isLive || (greState?.id === srcId && greState.state === "ready")
const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId]) const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId])
// current command preview // current command preview
@@ -726,37 +646,21 @@ export default function ProbesPage() {
bwTarget: bwTun?.remoteAddress, bwProto, bwDuration, bwTarget: bwTun?.remoteAddress, bwProto, bwDuration,
}, isLive ? rosSrcV4 : undefined), [allServers, tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, traceUseDns, dnsType, bwTun, bwProto, bwDuration, isLive, rosSrcV4]) }, isLive ? rosSrcV4 : undefined), [allServers, tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, traceUseDns, dnsType, bwTun, bwProto, bwDuration, isLive, rosSrcV4])
// ── progressive reveal tick ──
useEffect(() => {
tickRef.current = setInterval(() => {
setTests(prev => {
const hasRunning = prev.some(t => t.status === "running")
if (!hasRunning) return prev
return prev.map(t => {
if (t.source === "live") return t
if (t.status !== "running") return t
const speed = t.tool === "dns" || t.tool === "route" ? t.lines.length : 1
const nextTotal = Math.min(t.totalLines + speed, t.lines.length)
const done = nextTotal >= t.lines.length
return { ...t, totalLines: nextTotal, status: done ? "done" : "running" }
})
})
}, 400)
return () => { if (tickRef.current) clearInterval(tickRef.current) }
}, [])
// ── run test ── // ── run test ──
const runTest = useCallback(async () => { const runTest = useCallback(async () => {
const srv = allServers.find(s => s.id === srcId) const srv = allServers.find(s => s.id === srcId)
if (!srv) return if (!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || !srv || !srv.enabled || runLock.current || (isLive && liveLoad !== "idle")) return
const id = String(nextId.current++) const id = String(nextId.current++)
const numericServerId = Number.parseInt(srcId, 10) const numericServerId = Number.parseInt(srcId, 10)
if (isLive) { if (isLive) {
if (!Number.isFinite(numericServerId)) return if (!Number.isFinite(numericServerId)) return
runLock.current = true
setRunBusy(true) setRunBusy(true)
const abortCtrl = new AbortController() const abortCtrl = new AbortController()
liveProbeRunRef.current = { testId: id, ctrl: abortCtrl } liveProbeRunRef.current = { testId: id, ctrl: abortCtrl }
let timedOut = false
const timeout = setTimeout(() => { timedOut = true; abortCtrl.abort() }, 60_000)
setTests(p => [{ setTests(p => [{
id, id,
tool, tool,
@@ -766,7 +670,7 @@ export default function ProbesPage() {
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target, target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
command: cmdPreview, command: cmdPreview,
startedAt: Date.now(), startedAt: Date.now(),
lines: [{ text: "Выполняется запрос к MikroTik через API…", kind: "dim" }], lines: [{ text: tool === "dns" ? "Выполняется DNS-запрос приложения…" : "Выполняется запрос к MikroTik через API…", kind: "dim" }],
totalLines: 1, totalLines: 1,
source: "live", source: "live",
}, ...p.slice(0, 9)]) }, ...p.slice(0, 9)])
@@ -793,32 +697,34 @@ export default function ProbesPage() {
bwProto, bwProto,
bwDuration, bwDuration,
} }
const res = await apiFetch<{ output: string }>(`/api/servers/${numericServerId}/probes/run`, { const res = await apiFetch<unknown>(`/api/servers/${numericServerId}/probes/run`, {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
signal: abortCtrl.signal, signal: abortCtrl.signal,
}) })
const lines = parseProbeOutput(res.output ?? "") if (!alive.current || abortCtrl.signal.aborted) return
const looksErr = (res.output ?? "").trim().toLowerCase().startsWith("error:") const output = readProbeOutput(res)
const lines = parseProbeOutput(output || "Запрос выполнен: API вернул пустой вывод.")
setTests(p => p.map(t => (t.id === id setTests(p => p.map(t => (t.id === id
? { ? {
...t, ...t,
lines, lines,
totalLines: lines.length, totalLines: lines.length,
status: looksErr ? "error" : "done", status: "done",
} }
: t))) : t)))
} catch (e) { } catch (e) {
const aborted = if (!alive.current) return
const aborted = abortCtrl.signal.aborted ||
(typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError") (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError")
|| (e instanceof Error && e.name === "AbortError") || (e instanceof Error && e.name === "AbortError")
if (aborted) { if (aborted) {
setTests(p => p.map(t => (t.id === id setTests(p => p.map(t => (t.id === id
? { ? {
...t, ...t,
lines: [{ text: "Запрос отменён (стоп или закрытие запроса).", kind: "dim" }], lines: [{ text: timedOut ? "Время ожидания истекло. Результат на устройстве не подтверждён; повтор выполняется вручную." : "Ожидание отменено. Остановка на устройстве не подтверждена.", kind: timedOut ? "err" : "dim" }],
totalLines: 1, totalLines: 1,
status: "done", status: timedOut ? "error" : "cancelled",
} }
: t))) : t)))
} else { } else {
@@ -833,8 +739,13 @@ export default function ProbesPage() {
: t))) : t)))
} }
} finally { } finally {
clearTimeout(timeout)
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null
setRunBusy(false) runLock.current = false
if (alive.current) {
setRunBusy(false)
requestAnimationFrame(() => { if (alive.current && (document.activeElement === runButtonRef.current || document.activeElement === document.body)) (targetRef.current ?? runButtonRef.current)?.focus() })
}
} }
return return
} }
@@ -867,6 +778,10 @@ export default function ProbesPage() {
setTab("history") setTab("history")
}, [ }, [
isLive, isLive,
liveLoad,
canRun,
canReadGre,
greReady,
allServers, allServers,
srcId, srcId,
tool, tool,
@@ -879,7 +794,6 @@ export default function ProbesPage() {
traceHops, traceHops,
dnsType, dnsType,
bwTun, bwTun,
bwTunId,
bwProto, bwProto,
bwDuration, bwDuration,
cmdPreview, cmdPreview,
@@ -887,21 +801,27 @@ export default function ProbesPage() {
]) ])
const stopTest = (id: string) => { const stopTest = (id: string) => {
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort() if (liveProbeRunRef.current?.testId === id) { liveProbeRunRef.current.ctrl.abort(); return }
setTests(p => p.map(t => (t.id === id && t.status === "running" setTests(p => p.map(t => (t.id === id && t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length } ? { ...t, status: "cancelled" as const, totalLines: t.lines.length }
: t))) : t)))
} }
const clearTest = (id: string) => setTests(p => p.filter(t => t.id !== id)) const clearTest = (id: string) => {
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
setTests(p => p.filter(t => t.id !== id))
runButtonRef.current?.focus()
}
const running = tests.filter(t => t.status === "running") const running = tests.filter(t => t.status === "running")
if (isLive && liveLoad === "loading") { if (!canRead) return <p role="status">Для просмотра устройств требуется mm:servers:read.</p>
if (isLive && liveLoad === "loading" && catalogReadAt === null) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} /> <PageHeader crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} />
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground"> <div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<LoaderCircleIcon className="size-8 animate-spin opacity-50" /> <LoaderCircleIcon aria-hidden="true" className="size-8 motion-safe:animate-spin opacity-50" />
<p className="text-sm">Загрузка серверов из бекенда…</p> <p className="text-sm">Загрузка серверов из бекенда…</p>
</div> </div>
</div> </div>
@@ -912,7 +832,8 @@ export default function ProbesPage() {
<ServerRailLayout <ServerRailLayout
items={probeRailItems} items={probeRailItems}
selectedId={srcId} selectedId={srcId}
onSelect={setSrcId} onSelect={selectServer}
preserveSelection
showAll={false} showAll={false}
loading={isLive && liveLoad === "loading" && allServers.length === 0} loading={isLive && liveLoad === "loading" && allServers.length === 0}
header={ header={
@@ -921,14 +842,12 @@ export default function ProbesPage() {
actions={ actions={
<> <>
<ServerRailMobileButton /> <ServerRailMobileButton />
{isLive && <Button type="button" size="sm" variant="outline" aria-label="Обновить устройства диагностики" disabled={liveLoad === "loading"} onClick={() => setCatalogRevision(n => n + 1)}>Обновить</Button>}
{running.length > 0 {running.length > 0
? <Button variant="outline" size="sm" onClick={() => { ? <Button variant="outline" size="sm" onClick={() => {
liveProbeRunRef.current?.ctrl.abort() running.forEach(test => stopTest(test.id))
setTests(p => p.map(t => (t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length }
: t)))
}}> }}>
<SquareIcon className="size-4" />Остановить все <SquareIcon aria-hidden="true" className="size-4" />Остановить все
</Button> </Button>
: null} : null}
</> </>
@@ -938,35 +857,61 @@ export default function ProbesPage() {
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{!server && <p role="status">Выберите устройство для диагностики. Выбор сохраняется в URL.</p>}
{server && (!isLive || catalogReadAt !== null) && !srcServer && <div role="alert" aria-label="Устройство диагностики"><p>Указанное устройство отсутствует в текущем каталоге. Другое устройство автоматически не выбирается.</p><Button type="button" variant="outline" onClick={() => selectServer("")}>Сбросить выбор устройства</Button></div>}
{srcServer && !srcServer.enabled && <p role="status">Выбранное устройство выключено. Диагностика недоступна.</p>}
{isLive && liveLoad === "error" && ( {isLive && liveLoad === "error" && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-xs text-destructive flex items-center gap-2"> <div role="alert" aria-label="Каталог диагностики" className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" /> <p>{catalogReadAt === null ? "Устройства пока неизвестны." : "Не удалось обновить устройства. Последний список сохранён; запуск приостановлен."}</p>
Бекенд недоступен — переключитесь в режим «Демо» в настройках источника данных или проверьте URL API. <p>{catalogError}</p>
</div>
)}
{isLive && catalogReadAt !== null && <p role="status" className="text-xs text-muted-foreground">Каталог получен: {new Date(catalogReadAt).toLocaleTimeString("ru-RU")}{liveLoad === "loading" ? " · обновляется" : ""}</p>}
{isLive && liveLoad === "idle" && allServers.length === 0 && <p role="status">В каталоге нет устройств. Добавьте устройство в разделе «Серверы».</p>}
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: запросы не отправляются; результаты синтетические.</p>}
{isLive && (
<details className="text-xs text-muted-foreground"><summary className="cursor-pointer">Где выполняются тесты</summary><p className="pt-2">
Сетевые тесты выполняются на выбранном устройстве. DNS использует резолвер приложения. Traceroute ограничен примерно минутой; сокращение числа хопов указывается в ответе. Стоп отменяет ожидание, но не подтверждает остановку на устройстве.
</p></details>
)}
{!canRun && <p role="status" className="text-sm text-muted-foreground">Для запуска диагностики требуется mm:servers:write.</p>}
{isLive && tool === "bandwidth" && !canReadGre && <p role="status" className="text-sm text-muted-foreground">Для выбора GRE требуется mm:filters:read.</p>}
{isLive && tool === "bandwidth" && canReadGre && (
<div className="text-sm">
{greState?.id === srcId && greState.state === "error" ? <div role="alert" aria-label="GRE диагностики"><p className="text-destructive">GRE недоступны. Последний список сохранён; BW-тест приостановлен. {greState.error}</p><Button type="button" size="sm" variant="outline" onClick={() => setGreRevision(n => n + 1)}>Повторить GRE</Button></div>
: !greReady ? <p role="status">GRE выбранного устройства загружаются…</p>
: !bwTunnels.length ? <p role="status">У выбранного устройства нет GRE-туннелей.</p> : !bwTun?.enabled || !bwTun.remoteAddress.trim() ? <p role="status">Выберите включённый GRE с адресом назначения.</p> : null}
</div> </div>
)} )}
{isLive && ( {isLive && srcServer?.enabled && sourceRequired && (
<p className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для источника поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik. {sourceCurrent?.error ? <div role="alert" aria-label="Источник preview"><p>{sourceCurrent.error}</p><Button type="button" size="sm" variant="outline" onClick={() => setSourceRevision(n => n + 1)}>Повторить определение IPv4</Button></div>
</p> : !sourceCurrent || sourceCurrent.loading ? <p role="status">Определяем IPv4 для preview…</p>
: <p role="status">{sourceCurrent.ipv4 === null ? "IPv4 для preview не определён." : "IPv4 для preview: " + sourceCurrent.ipv4}</p>}
<p>Адрес справочный: при запуске определяется заново.</p>
</div>
)} )}
{/* ── tool selector + config ── */} {/* ── tool selector + config ── */}
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4"> <OpsPanel contentClassName="pt-4 pb-4 px-3 md:px-4 flex min-w-0 flex-col gap-4">
{/* tool chips */} {/* tool chips */}
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => { {(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => {
const active = tool === t const active = tool === t
return ( return (
<button key={t} onClick={() => setTool(t)} <button key={t} type="button" aria-pressed={active} title={m.description} onClick={() => setTool(t)}
className={cn( className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors", "flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active active
? "border-foreground bg-foreground text-background" ? "border-foreground bg-foreground text-background"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40", : "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}> )}>
<m.Icon className={cn("size-3.5", active ? "" : m.color)} /> <m.Icon aria-hidden="true" className={cn("size-3.5", active ? "" : m.color)} />
{m.label} {m.label}
</button> </button>
) )
@@ -979,11 +924,12 @@ export default function ProbesPage() {
{/* target — all tools except bandwidth */} {/* target — all tools except bandwidth */}
{tool !== "bandwidth" && ( {tool !== "bandwidth" && (
<div className="flex-1 min-w-[140px]"> <div className="flex-1 min-w-[140px]">
<OptionLabel> <OptionLabel htmlFor="probe-target">
{tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"} {tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель (IP или домен)"}
</OptionLabel> </OptionLabel>
<Input value={target} onChange={e => setTarget(e.target.value)} <Input id="probe-target" ref={targetRef} maxLength={1024} value={target} onChange={e => setTarget(e.target.value)}
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"} placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
aria-label={tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель диагностики"}
className="font-mono text-sm" className="font-mono text-sm"
onKeyDown={e => e.key === "Enter" && runTest()} onKeyDown={e => e.key === "Enter" && runTest()}
/> />
@@ -994,7 +940,8 @@ export default function ProbesPage() {
{tool === "bandwidth" && ( {tool === "bandwidth" && (
<div> <div>
<OptionLabel>GRE-туннель (цель)</OptionLabel> <OptionLabel>GRE-туннель (цель)</OptionLabel>
<NativeSelect value={bwTunId} onChange={setBwTunId} className="min-w-[180px]"> <NativeSelect ariaLabel="GRE-туннель для BW-теста" value={bwTunnels.some(t => t.id === bwTunId) ? bwTunId : ""} onChange={setBwTunId} className="min-w-[180px]">
{!bwTunnels.some(t => t.id === bwTunId) && <option value="" disabled>Выберите GRE-туннель</option>}
{bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)} {bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</NativeSelect> </NativeSelect>
</div> </div>
@@ -1004,14 +951,14 @@ export default function ProbesPage() {
{tool === "ping" && ( {tool === "ping" && (
<> <>
<div> <div>
<OptionLabel>Кол-во</OptionLabel> <OptionLabel>Количество пакетов</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8"> <div className="flex flex-wrap rounded-lg border border-input min-h-8">
{[5, 10, 25, 100].map(n => <SegBtn key={n} value={n} current={pingCount} onClick={setPingCount}>{n}</SegBtn>)} {[5, 10, 25, 100].map(n => <SegBtn key={n} value={n} current={pingCount} onClick={setPingCount}>{n}</SegBtn>)}
</div> </div>
</div> </div>
<div> <div>
<OptionLabel>Размер (байт)</OptionLabel> <OptionLabel>Размер (байт)</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8"> <div className="flex flex-wrap rounded-lg border border-input overflow-hidden min-h-8">
{[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)} {[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)}
</div> </div>
</div> </div>
@@ -1033,7 +980,7 @@ export default function ProbesPage() {
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik. Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
</p> </p>
</div> </div>
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} /> <FormToggle ariaLabel="Разрешать имена хопов на устройстве" checked={traceUseDns} onChange={setTraceUseDns} />
</div> </div>
</> </>
)} )}
@@ -1042,7 +989,7 @@ export default function ProbesPage() {
<div> <div>
<OptionLabel>Тип записи</OptionLabel> <OptionLabel>Тип записи</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8"> <div className="flex rounded-lg border border-input overflow-hidden h-8">
{(["A", "AAAA", "MX", "NS", "TXT", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)} {(["A", "AAAA", "MX", "NS", "TXT", "CNAME", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)}
</div> </div>
</div> </div>
)} )}
@@ -1064,25 +1011,25 @@ export default function ProbesPage() {
</> </>
)} )}
<Button <Button ref={runButtonRef} type="button" aria-busy={runBusy}
onClick={() => void runTest()} onClick={() => void runTest()}
disabled={runBusy || !srcServer || (tool === "bandwidth" && !bwTunnels.length)} disabled={!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || runBusy || !srcServer?.enabled || (isLive && liveLoad !== "idle") || (tool === "bandwidth" && !bwTunnels.length)}
className="h-8 shrink-0 gap-1.5 self-end" className="h-8 shrink-0 gap-1.5 self-end"
> >
{runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />} {runBusy ? <LoaderCircleIcon aria-hidden="true" className="size-3.5 motion-safe:animate-spin" /> : <PlayIcon aria-hidden="true" className="size-3.5" />}
Запустить Запустить
</Button> </Button>
</div> </div>
{/* RouterOS command preview */} {/* RouterOS command preview */}
<div className="flex items-center gap-2 rounded-lg bg-zinc-950 dark:bg-zinc-900 px-3 py-2 border border-zinc-800"> <div className="flex min-w-0 items-center gap-2 rounded-lg bg-muted/30 px-3 py-2 border">
<TerminalIcon className="size-3 text-zinc-500 shrink-0" /> <TerminalIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
<span className="text-zinc-500 text-xs font-mono shrink-0">[{srcServer?.name}]</span> <span className="hidden md:inline text-muted-foreground text-xs font-mono truncate max-w-24">[{tool === "dns" ? (isLive ? "backend" : "демо") : srcServer?.name}]</span>
<span className="text-emerald-400 text-xs font-mono shrink-0">$</span> <span className="hidden md:inline text-success text-xs font-mono shrink-0">$</span>
<span className="text-zinc-300 text-xs font-mono truncate">{cmdPreview}</span> <span className="min-w-0 flex-1 text-foreground text-xs font-mono [overflow-wrap:anywhere]">{cmdPreview}</span>
<button onClick={() => navigator.clipboard.writeText(cmdPreview)} <button type="button" aria-label="Скопировать параметры диагностики" onClick={() => void copyProbeText(cmdPreview)}
className="shrink-0 text-zinc-600 hover:text-zinc-300 transition-colors ml-auto"> className="shrink-0 text-muted-foreground hover:text-foreground transition-colors ml-auto">
<CopyIcon className="size-3" /> <CopyIcon aria-hidden="true" className="size-3" />
</button> </button>
</div> </div>
</OpsPanel> </OpsPanel>
@@ -1094,7 +1041,7 @@ export default function ProbesPage() {
["history", "История тестов"], ["history", "История тестов"],
["schedule", "Расписание"], ["schedule", "Расписание"],
] as const).map(([t, label]) => ( ] as const).map(([t, label]) => (
<button key={t} onClick={() => setTab(t)} <button key={t} type="button" aria-pressed={tab === t} onClick={() => setTab(t)}
className={cn( className={cn(
"px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors", "px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
tab === t tab === t
@@ -1116,7 +1063,7 @@ export default function ProbesPage() {
{tab === "history" && ( {tab === "history" && (
tests.length === 0 ? ( tests.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2"> <div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<TerminalIcon className="size-8 opacity-20" /> <TerminalIcon aria-hidden="true" className="size-8 opacity-20" />
<p className="text-sm">Запустите тест — результаты появятся здесь</p> <p className="text-sm">Запустите тест — результаты появятся здесь</p>
</div> </div>
) : ( ) : (
@@ -1124,52 +1071,46 @@ export default function ProbesPage() {
{tests.map(test => { {tests.map(test => {
const { Icon, color, label } = TOOL_META[test.tool] const { Icon, color, label } = TOOL_META[test.tool]
return ( return (
<Frame key={test.id} dense className="w-full overflow-hidden"> <Frame key={test.id} dense aria-busy={test.status === "running"} className="w-full h-[60dvh] min-h-0 flex flex-col overflow-hidden">
<FramePanel className="p-0 overflow-hidden"> <FramePanel className="p-0 flex-1 min-h-0 flex flex-col overflow-hidden">
{/* header */} {/* header */}
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20"> <div className="shrink-0 flex flex-wrap items-center justify-between gap-2 px-3 py-2.5 border-b bg-muted/20">
<div className="flex items-center gap-2 min-w-0"> <div className="flex flex-wrap items-center gap-2 min-w-0 w-full md:w-auto">
<Icon className={cn("size-4 shrink-0", color)} /> <Icon aria-hidden="true" className={cn("size-4 shrink-0", color)} />
<span className="text-sm font-medium">{label}</span> <span className="text-sm font-medium">{label}</span>
<span className="text-xs font-mono text-muted-foreground truncate">{test.target}</span> <span className="min-w-0 max-w-[70%] text-xs font-mono text-muted-foreground truncate" title={test.target}>{test.target}</span>
<span className="text-[10px] text-muted-foreground">← {test.srcServerName}</span> <span className="w-full md:w-auto max-w-full text-xs text-muted-foreground truncate" title={test.tool === "dns" ? "DNS приложения" : test.srcServerName}>{test.tool === "dns" ? "DNS приложения" : "← " + test.srcServerName}</span>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex flex-wrap items-center gap-2 min-w-0">
{test.status === "running" && ( {test.status === "running" && (
<> <>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground"> <span role="status" className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" /> <span className="size-1.5 rounded-full bg-info motion-safe:animate-pulse" />
запущен ожидаем ответ
</span> </span>
<Button size="sm" variant="outline" className="h-6 px-2 text-xs" <Button size="sm" variant="outline" type="button" className="h-11 md:h-7 px-2 text-xs"
onClick={() => stopTest(test.id)}> onClick={() => stopTest(test.id)}>
<SquareIcon className="size-3" />Стоп <SquareIcon aria-hidden="true" className="size-3" />Стоп
</Button> </Button>
</> </>
)} )}
{test.status === "done" && test.source !== "live" && ( {test.status === "done" && <span role="status" className="text-xs text-muted-foreground">{test.source === "live" ? "ответ API" : "демо"}</span>}
<span className="text-[11px] text-muted-foreground"> {test.status === "cancelled" && <span role="status" className="text-[11px] text-muted-foreground">ожидание отменено</span>}
{((test.lines.length * 0.4)).toFixed(1)}с
</span>
)}
{test.status === "done" && test.source === "live" && (
<span className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">live</span>
)}
{test.status === "error" && ( {test.status === "error" && (
<span className="text-[11px] text-destructive">ошибка</span> <span role="alert" className="text-xs text-destructive">ошибка</span>
)} )}
<button onClick={() => navigator.clipboard.writeText(test.lines.map(l => l.text).join("\n"))} <button type="button" aria-label={"Скопировать показанный вывод · " + label + " · " + test.id} onClick={() => void copyProbeText(test.lines.map(l => l.text).join("\n"))}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-colors"> className="size-11 md:size-8 flex items-center justify-center rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:text-foreground hover:bg-muted transition-colors">
<CopyIcon className="size-3.5" /> <CopyIcon aria-hidden="true" className="size-3.5" />
</button> </button>
<button onClick={() => clearTest(test.id)} <button type="button" aria-label={"Удалить результат · " + label + " · " + test.id} onClick={() => clearTest(test.id)}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors"> className="size-11 md:size-8 flex items-center justify-center rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:text-destructive hover:bg-destructive/10 transition-colors">
<Trash2Icon className="size-3.5" /> <Trash2Icon aria-hidden="true" className="size-3.5" />
</button> </button>
</div> </div>
</div> </div>
{/* terminal output */} {/* terminal output */}
<div className="p-3"> <div className="flex-1 min-h-0 p-3">
<TerminalOutput test={test} /> <TerminalOutput test={test} />
</div> </div>
</FramePanel> </FramePanel>
@@ -1183,9 +1124,10 @@ export default function ProbesPage() {
{/* schedule */} {/* schedule */}
{tab === "schedule" && ( {tab === "schedule" && (
isLive isLive
? <ScheduleSpeedProbesLive apiFetch={apiFetch} serversForName={allServers} /> ? <ProbesSpeedSchedule base={backendUrl} serversForName={allServers} />
: ( : (
<ScheduleTab <ScheduleTab
key={srcId}
rules={rules} rules={rules}
setRules={setRules} setRules={setRules}
serverOptions={allServers} serverOptions={allServers}
+373 -224
View File
@@ -1,48 +1,49 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useRef, useMemo, useState, Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { readRecursiveUrl, patchRecursiveUrl, type RecursiveUrlPatch, type RecursiveUrlState } from "@/lib/recursive-route-url-state"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { import {
RecursiveRoutesDataGrid, RecursiveRoutesDataGrid,
type RecursiveRouteGroup, type RecursiveRouteGroup,
inferCountry,
} from "@/components/data-grids/recursive-routes-data-grid" } from "@/components/data-grids/recursive-routes-data-grid"
import { RecursiveGatewayChoices, type GatewayOption } from "@/components/recursive-gateway-choices"
import { FormField, SectionTitle } from "@/components/form-kit" import { FormField, SectionTitle } from "@/components/form-kit"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { StatusDot } from "@/components/status-dot" import { StatusDot } from "@/components/status-dot"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { can } from "@/lib/auth"
import { recursiveReadSchema, recursiveReceiptSchema, recursiveGatewaysSchema, recursiveServersSchema, type RecursiveRouteRow } from "@/lib/recursive-route-response"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data" import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react" import { PlusIcon, TrashIcon, SearchIcon, XIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
import { toast } from "sonner"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
import { type ServerTileItem } from "@/components/server-tile-rail" import { type ServerTileItem } from "@/components/server-tile-rail"
interface BackendServer { interface BackendServer {
id: number id: number
name: string name: string
host: string host: string
type: "jump-host" | "exit-node" | "home-router" type?: "jump-host" | "exit-node" | "home-router"
site: string site: string
country: string country: string
asn: string asn: string
enabled: boolean enabled: boolean
status: "online" | "offline" | null status: "online" | "offline" | "degraded" | null
latency: number | null latency: number | null
} }
interface GatewayOption {
id: string
name: string
ip: string
status: "up" | "down"
}
const COUNTRY_OPTIONS = [ const COUNTRY_OPTIONS = [
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" }, { code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" }, { code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
@@ -58,21 +59,7 @@ const COUNTRY_OPTIONS = [
{ code: "NO", label: "Норвегия" }, { code: "NO", label: "Норвегия" },
] ]
interface RecursiveRouteRow { type RouteGroup = RecursiveRouteGroup
id: string
dstAddress: string
gateway: string
distance: number
scope: number | null
targetScope: number | null
routingTable: string
checkGateway: string
comment: string
disabled: boolean
country: string
}
interface RouteGroup extends RecursiveRouteGroup {}
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -80,7 +67,7 @@ function makeApiFetch(backendUrl: string) {
} }
} }
function mapBackendServer(s: BackendServer): Server { function mapBackendServer(s: BackendServer): Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] } {
return { return {
id: String(s.id), id: String(s.id),
name: s.name || s.host, name: s.name || s.host,
@@ -92,7 +79,7 @@ function mapBackendServer(s: BackendServer): Server {
asn: s.asn, asn: s.asn,
type: s.type, type: s.type,
enabled: s.enabled, enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"], status: s.status,
latency: s.latency != null ? Math.round(s.latency) : null, latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0, sessions: 0,
} }
@@ -107,6 +94,7 @@ interface RouteForm {
interface RouteEndpointForm { interface RouteEndpointForm {
id: string id: string
disabled: boolean
gateway: string gateway: string
distance: number distance: number
scope: number | null scope: number | null
@@ -117,6 +105,7 @@ interface RouteEndpointForm {
const newEndpoint = (): RouteEndpointForm => ({ const newEndpoint = (): RouteEndpointForm => ({
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
disabled: false,
gateway: "", gateway: "",
distance: 1, distance: 1,
scope: null, scope: null,
@@ -144,7 +133,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
<label className="text-sm font-medium">Страна</label> <label className="text-sm font-medium">Страна</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Input placeholder="Поиск или код (RU, DE…)" value={query} <Input aria-label="Код страны" placeholder="Поиск или код (RU, DE…)" value={query}
onChange={e => { onChange={e => {
const v = e.target.value.toUpperCase().slice(0, 3) const v = e.target.value.toUpperCase().slice(0, 3)
setQuery(v) setQuery(v)
@@ -167,7 +156,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
</span> </span>
)} )}
</div> </div>
<div className="grid grid-cols-5 gap-1.5 max-h-40 overflow-y-auto pr-0.5"> <div className="grid grid-cols-4 sm:grid-cols-5 gap-1.5">
{visible.map(c => ( {visible.map(c => (
<button key={c.code} type="button" <button key={c.code} type="button"
onClick={() => { onChange(c.code); setQuery("") }} onClick={() => { onChange(c.code); setQuery("") }}
@@ -188,133 +177,129 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
} }
function RouteSheet({ function RouteSheet({
open, mode, initial, onSave, onClose, gateways, open, mode, initial, onSave, onClose, gateways, busy, saveError, returnFocus,
}: { }: {
open: boolean open: boolean
mode: "create" | "edit" mode: "create" | "edit"
initial: RouteForm initial: RouteForm
onSave: (v: RouteForm) => void onSave: (v: RouteForm) => Promise<boolean>
onClose: () => void onClose: () => void
gateways: GatewayOption[] gateways: GatewayOption[]
busy: boolean
saveError: string | null
returnFocus: () => HTMLElement | null
}) { }) {
const [form, setForm] = useState<RouteForm>(initial) const [form, setForm] = useState<RouteForm>(initial)
const [endpointPage, setEndpointPage] = useState(0)
const lastEndpointPage = Math.max(0, Math.ceil(form.endpoints.length / 10) - 1)
if (endpointPage > lastEndpointPage) setEndpointPage(lastEndpointPage)
const currentEndpointPage = Math.min(endpointPage, lastEndpointPage)
const endpointOffset = currentEndpointPage * 10
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setForm(initial); setError(null) }, [initial, open]) useEffect(() => { setForm(initial); setError(null); setEndpointPage(0) }, [initial, open])
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v })) const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) => const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) })) setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
function handleSave() { const saveLock = useRef(false)
async function handleSave() {
if (busy || saveLock.current) return
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return } if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return } if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
if (form.endpoints.some(ep => !ep.gateway.trim())) { setError("У каждой конечной точки должен быть Gateway"); return } const emptyGateway = form.endpoints.findIndex(ep => !ep.gateway.trim())
if (emptyGateway >= 0) { setEndpointPage(Math.floor(emptyGateway / 10)); setError(`У каждой конечной точки должен быть Gateway (Endpoint ${emptyGateway + 1})`); return }
if (!form.routingTable.trim()) { setError("Укажите таблицу маршрутизации"); return }
const invalidEndpoint = form.endpoints.findIndex(ep => [ep.distance, ep.scope, ep.targetScope].some(value => value !== null && (!Number.isInteger(value) || value < 0 || value > 255)))
if (invalidEndpoint >= 0) { setEndpointPage(Math.floor(invalidEndpoint / 10)); setError(`Distance, Scope и T.Scope должны быть целыми числами от 0 до 255 (Endpoint ${invalidEndpoint + 1})`); return }
setError(null) setError(null)
onSave(form) saveLock.current = true
try { await onSave(form) } finally { saveLock.current = false }
} }
return ( return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}> <Sheet open={open} onOpenChange={v => { if (!v && !busy && !saveLock.current) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}> <SheetContent side="right" finalFocus={returnFocus} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0"> <SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div> <div>
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle> <SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription> <SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
</div> </div>
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5"><XIcon className="size-4" /></Button> <Button type="button" variant="ghost" size="icon-sm" disabled={busy} aria-label="Закрыть форму маршрута" onClick={onClose} className="shrink-0 mt-0.5"><XIcon aria-hidden="true" className="size-4" /></Button>
</div> </div>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5"> <fieldset disabled={busy} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle> <SectionTitle>Основные</SectionTitle>
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24"> <FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} /> <Input aria-label="Адрес назначения" className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
</FormField> </FormField>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SectionTitle>Конечные точки</SectionTitle> <SectionTitle>Конечные точки</SectionTitle>
{form.endpoints.length > 10 && <div className="flex flex-wrap items-center gap-2">
<Input key={currentEndpointPage} type="number" min={1} max={form.endpoints.length} defaultValue={endpointOffset + 1} aria-label="Перейти к endpoint" aria-describedby="recursive-endpoint-jump-hint" className="w-24 min-h-11" onKeyDown={event => {
if (event.key !== "Enter") return
event.preventDefault()
const number = Number(event.currentTarget.value)
if (Number.isInteger(number) && number >= 1 && number <= form.endpoints.length) { setEndpointPage(Math.floor((number - 1) / 10)); setError(null) }
else setError("Укажите номер endpoint от 1 до " + form.endpoints.length)
}} />
<span id="recursive-endpoint-jump-hint" className="text-xs text-muted-foreground">Номер endpoint → Enter</span>
<span role="status" className="text-xs text-muted-foreground">Endpoints {endpointOffset + 1}–{Math.min(endpointOffset + 10, form.endpoints.length)} из {form.endpoints.length}</span>
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущие endpoints формы" disabled={currentEndpointPage === 0} onClick={() => setEndpointPage(currentEndpointPage - 1)}>Назад</Button>
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующие endpoints формы" disabled={currentEndpointPage === lastEndpointPage} onClick={() => setEndpointPage(currentEndpointPage + 1)}>Далее</Button>
</div>}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{form.endpoints.map((ep, idx) => ( {form.endpoints.slice(endpointOffset, endpointOffset + 10).map((ep, idx) => (
<div key={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3"> <div key={ep.id} data-recursive-endpoint-form={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide"> <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Endpoint {idx + 1} Endpoint {endpointOffset + idx + 1}{ep.disabled ? " · отключён" : ""}
</span> </span>
<button <button
type="button" type="button"
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))} onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
className="text-muted-foreground hover:text-destructive transition-colors" className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive transition-colors"
aria-label={`Удалить конечную точку ${endpointOffset + idx + 1}`}
disabled={form.endpoints.length <= 1} disabled={form.endpoints.length <= 1}
> >
<TrashIcon className="size-3.5" /> <TrashIcon aria-hidden="true" className="size-3.5" />
</button> </button>
</div> </div>
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} /> <EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway"> <FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} /> <Input aria-label="Шлюз" className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
</FormField> </FormField>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<FormField label="Distance (приоритет)"> <FormField label="Distance (приоритет)">
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} /> <Input aria-label="Distance (приоритет)" type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value))} />
</FormField> </FormField>
<FormField label="Check Gateway"> <FormField label="Check Gateway">
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} /> <Input aria-label="Check Gateway" className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
</FormField> </FormField>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<FormField label="Scope"> <FormField label="Scope">
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} /> <Input aria-label="Scope" type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
</FormField> </FormField>
<FormField label="T.Scope"> <FormField label="T.Scope">
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} /> <Input aria-label="T.Scope" type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
</FormField> </FormField>
</div> </div>
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1"> <RecursiveGatewayChoices gateways={gateways} selectedValue={ep.gateway} endpointNumber={endpointOffset + idx + 1} onChange={value => setEp(ep.id, "gateway", value)} />
{gateways.map((g) => {
const value = `${g.ip}%${g.name}`
const selected = ep.gateway === value
const country = inferCountry(g.name)
return (
<button
key={`${ep.id}-${g.id}`}
type="button"
onClick={() => setEp(ep.id, "gateway", value)}
className={cn(
"text-left rounded-lg border px-3 py-2.5 transition-all",
selected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
)}
>
<div className="flex items-center gap-2">
<span className={cn("size-1.5 rounded-full shrink-0", g.status === "up" ? "bg-emerald-500" : "bg-red-500")} />
{country && <Flag code={country} className="shrink-0" />}
<span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span>
{selected && <CheckIcon className="size-3.5 text-primary shrink-0" />}
</div>
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono">
<span className="text-muted-foreground/50 break-all">{g.ip}</span>
<span className="text-muted-foreground/30">→</span>
<span className={cn("min-w-0 break-all whitespace-normal leading-tight", selected ? "text-primary font-medium" : "")}>
{value}
</span>
</div>
</button>
)
})}
</div>
</div> </div>
))} ))}
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}> <Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => { setEndpointPage(Math.floor(form.endpoints.length / 10)); setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] })) }}>
<PlusIcon className="size-3.5" />Добавить endpoint <PlusIcon aria-hidden="true" className="size-3.5" />Добавить endpoint
</Button> </Button>
{gateways.length === 0 && ( {gateways.length === 0 && (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground"> <div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
@@ -326,16 +311,18 @@ function RouteSheet({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SectionTitle>Параметры</SectionTitle> <SectionTitle>Параметры</SectionTitle>
<FormField label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField> <FormField label="Routing Table"><Input aria-label="Таблица маршрутизации" className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
<FormField label="Комментарий"> <FormField label="Комментарий">
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} /> <Input aria-label="Комментарий" className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</FormField> </FormField>
</div> </div>
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>} {saveError && <p role="alert" className="text-sm text-destructive">{saveError}</p>}
</div> {busy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения роутера…</p>}
{error && <div role="alert" className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon aria-hidden="true" className="size-4 shrink-0" />{error}</div>}
</fieldset>
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2"> <SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button> <Button type="button" variant="outline" disabled={busy} onClick={onClose} className="h-8 flex-none">Отмена</Button>
<Button onClick={handleSave} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button> <Button type="button" disabled={busy} onClick={() => void handleSave()} className="h-8 flex-none"><CheckIcon aria-hidden="true" className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -344,37 +331,81 @@ function RouteSheet({
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) { function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
return ( return (
<span className={cn( <Badge size="xs" className="shrink-0 text-foreground"
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0", variant={type === "home-router" ? "success-light" : type === "jump-host" ? "primary-light" : "info-light"}>
type === "home-router" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20" :
type === "jump-host"
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
)}>
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"} {type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
</span> </Badge>
) )
} }
export default function RecursiveRoutesPage() { export default function RecursiveRoutesPage() {
return <Suspense fallback={<p role="status" className="p-6 text-sm text-muted-foreground">Загрузка маршрутов…</p>}><RecursiveRoutesRoute /></Suspense>
}
function RecursiveRoutesRoute() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const params = useSearchParams(), context = readRecursiveUrl(new URLSearchParams(params.toString()))
const sourceKey = JSON.stringify([mode, backendUrl])
const pendingSources = useRef(new Set<string>())
const [pending, setPending] = useState<string[]>([])
const [readVersions, setReadVersions] = useState<Record<string, number>>({})
const beginWrite = useCallback(() => {
if (pendingSources.current.has(sourceKey)) return false
pendingSources.current.add(sourceKey); setPending([...pendingSources.current]); return true
}, [sourceKey])
const endWrite = useCallback(() => {
pendingSources.current.delete(sourceKey); setPending([...pendingSources.current])
setReadVersions(versions => ({ ...versions, [sourceKey]: (versions[sourceKey] ?? 0) + 1 }))
}, [sourceKey])
const patchUrl = useCallback((patch: RecursiveUrlPatch, replace = false) => {
const url = new URL(window.location.href); url.search = patchRecursiveUrl(url.searchParams, patch).toString()
const target = url.pathname + url.search + url.hash
if (replace) window.history.replaceState(null, "", target); else window.history.pushState(null, "", target)
}, [])
return <RecursiveRoutesContent key={JSON.stringify([mode, backendUrl, context.serverId, context.error])} mode={mode} backendUrl={backendUrl} context={context} patchUrl={patchUrl} beginWrite={beginWrite} endWrite={endWrite} sharedBusy={pending.includes(sourceKey)} readVersion={readVersions[sourceKey] ?? 0} />
}
function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrite, endWrite, sharedBusy, readVersion }: {
beginWrite: () => boolean; endWrite: () => void; sharedBusy: boolean; readVersion: number
mode: string; backendUrl: string; context: RecursiveUrlState; patchUrl: (patch: RecursiveUrlPatch, replace?: boolean) => void
}) {
const isLive = mode === "live" const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [servers, setServers] = useState<Server[]>([]) const [servers, setServers] = useState<Array<Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] }>>([])
const [selectedServerId, setSelectedServerId] = useState<string>("") const [selectedServerId, setSelectedServerId] = useState<string>(context.serverId ?? "")
const [rows, setRows] = useState<RecursiveRouteRow[]>([]) const [rows, setRows] = useState<RecursiveRouteRow[]>([])
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null) const [busy, setBusy] = useState<"load" | "apply" | null>(null)
const [search, setSearch] = useState("") const search = context.q
const setSearch = (q: string) => patchUrl({ q }, true)
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create") const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm()) const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null) const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([]) const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null) const expandedGroupKey = context.route && context.table ? JSON.stringify([context.route.toLowerCase(), context.table]) : null
const [opError, setOpError] = useState<string | null>(null) const [opError, setOpError] = useState<string | null>(null)
const [liveStale, setLiveStale] = useState(true)
const [routeOwner, setRouteOwner] = useState("")
const [readAt, setReadAt] = useState<string | null>(null)
const [writeAt, setWriteAt] = useState<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [gatewayError, setGatewayError] = useState<string | null>(null)
const historyEpoch = useRef(0)
const alive = useRef(false)
const readEpoch = useRef(0), gatewayEpoch = useRef(0), writeLock = useRef(false)
useEffect(() => {
alive.current = true
return () => { alive.current = false }
}, [])
const [historyOpen, setHistoryOpen] = useState(false)
const [historyLoading, setHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState<string | null>(null)
const [historyRestoring, setHistoryRestoring] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */ /** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
const [liveServerListReady, setLiveServerListReady] = useState(false) const [liveServerListReady, setLiveServerListReady] = useState(false)
const [inventoryTick, setInventoryTick] = useState(0)
useEffect(() => { useEffect(() => {
if (!isLive) { if (!isLive) {
@@ -385,126 +416,175 @@ export default function RecursiveRoutesPage() {
setRows([]) setRows([])
return return
} }
// eslint-disable-next-line react-hooks/set-state-in-effect
setLiveServerListReady(false) setLiveServerListReady(false)
apiFetch<BackendServer[]>("/api/servers") let active = true
apiFetch<unknown>("/api/servers")
.then((data) => { .then((data) => {
const mapped = data.map(mapBackendServer) if (!active) return
const parsed = recursiveServersSchema.safeParse(data)
if (!parsed.success) throw new Error("Некорректный список устройств в ответе API")
const mapped = parsed.data.map(s => mapBackendServer({ ...s, type: s.type, site: s.site ?? "", asn: s.asn ?? "", status: s.status ?? null, latency: s.latency ?? null }))
setServers(mapped) setServers(mapped)
setSelectedServerId(prev => (mapped.some(s => s.id === prev) ? prev : (mapped[0]?.id ?? ""))) setSelectedServerId(context.serverId ?? mapped[0]?.id ?? "")
if (!context.serverId && !context.error && mapped[0]) patchUrl({ serverId: mapped[0].id }, true)
}) })
.catch(() => { .catch(error => {
if (!active) return
setOpError(error instanceof Error ? error.message : "Не удалось загрузить устройства")
setServers([]) setServers([])
setSelectedServerId("") setSelectedServerId("")
}) })
.finally(() => { .finally(() => {
setLiveServerListReady(true) if (active) setLiveServerListReady(true)
}) })
}, [isLive, apiFetch]) return () => { active = false }
}, [isLive, apiFetch, inventoryTick, context.serverId, context.error, patchUrl])
const loadRoutes = useCallback(async () => { const loadRoutes = useCallback(async () => {
if (!isLive || !liveServerListReady || !selectedServerId) return if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId) || writeLock.current) return
setOpError(null) const epoch = ++readEpoch.current
setBusy("load") setOpError(null); setBusy("load"); setLiveStale(true)
try { try {
const res = await apiFetch<{ routes: RecursiveRouteRow[] }>(`/api/recursive-routes?serverId=${selectedServerId}`) const parsed = recursiveReadSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
setRows(res.routes) if (!parsed.success) throw new Error("Некорректный снимок маршрутов в ответе API")
} catch (e) { const res = parsed.data
setRows([]) if (String(res.serverId) !== selectedServerId) throw new Error("Маршруты принадлежат другому устройству")
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты") if (!alive.current || epoch !== readEpoch.current) return
setRows(res.routes); setRouteOwner(selectedServerId); setLiveStale(res.stale)
if (res.live) setReadAt(new Date().toISOString())
else setOpError(res.error ?? "Роутер недоступен")
} catch (error) {
if (!alive.current || epoch !== readEpoch.current) return
setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось загрузить маршруты")
} finally { } finally {
setBusy(null) if (alive.current && epoch === readEpoch.current) setBusy(null)
} }
}, [isLive, liveServerListReady, selectedServerId, apiFetch]) }, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
const loadGateways = useCallback(async () => { const loadGateways = useCallback(async () => {
if (!isLive || !liveServerListReady || !selectedServerId) return if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId)) return
const epoch = ++gatewayEpoch.current
try { try {
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`) const parsed = recursiveGatewaysSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/gateways/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
setGatewayOptions(res.gateways) if (!parsed.success) throw new Error("Некорректный список шлюзов в ответе API")
} catch { const res = parsed.data
setGatewayOptions([]) if (String(res.serverId) !== selectedServerId) throw new Error("Шлюзы принадлежат другому устройству")
if (!alive.current || epoch !== gatewayEpoch.current) return
setGatewayOptions(res.gateways); setGatewayError(null)
} catch (error) {
if (!alive.current || epoch !== gatewayEpoch.current) return
setGatewayOptions([]); setGatewayError(error instanceof Error ? error.message : "Не удалось загрузить шлюзы")
} }
}, [isLive, liveServerListReady, selectedServerId, apiFetch]) }, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
void loadRoutes() void loadRoutes(); void loadGateways()
}, [loadRoutes]) const reads = readEpoch, gateways = gatewayEpoch
return () => { reads.current++; gateways.current++ }
}, [loadRoutes, loadGateways, readVersion])
useEffect(() => { const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
// eslint-disable-next-line react-hooks/set-state-in-effect if (!isLive || !selectedServerId || liveStale || routeOwner !== selectedServerId || busy !== null || sharedBusy || writeLock.current || !can("mm:network:write")) return false
void loadGateways() if (!beginWrite()) return false
}, [loadGateways]) writeLock.current = true; readEpoch.current++
setOpError(null); setSaveError(null); setBusy("apply")
const saveToDb = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("save")
try { try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes", { const raw = await apiFetch<unknown>("/api/recursive-routes", { method: "PUT", body: JSON.stringify({ serverId: selectedServerId, routes: next }) })
method: "PUT", const parsed = recursiveReceiptSchema.safeParse(raw)
body: JSON.stringify({ serverId: selectedServerId, routes: rows }), if (!parsed.success || String(parsed.data.serverId) !== selectedServerId || parsed.data.pushedRoutes !== next.length) throw new Error("Сервер не подтвердил применение маршрутов")
}) if (!alive.current) return false
await loadRoutes() setRows(parsed.data.routes); setLiveStale(false); setWriteAt(new Date().toISOString())
} catch (e) { toast.success("Маршруты применены на роутер")
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД") return true
} catch (error) {
if (!alive.current) return false
const message = error instanceof Error ? error.message : "Не удалось применить маршруты на роутер"
setOpError(message); setSaveError(message); setLiveStale(true); toast.error(message)
return false
} finally { } finally {
setBusy(null) writeLock.current = false
endWrite()
if (alive.current) setBusy(null)
} }
}, [isLive, selectedServerId, rows, apiFetch, loadRoutes]) }, [isLive, selectedServerId, routeOwner, liveStale, busy, sharedBusy, apiFetch, beginWrite, endWrite])
const syncFromRouter = useCallback(async () => { useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("from")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/from-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
await loadRoutes()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось синхронизировать маршруты с роутера")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, apiFetch, loadRoutes])
const syncToRouter = useCallback(async () => { const loadRevisions = useCallback(async () => {
if (!isLive || !selectedServerId) return if (!isLive || !selectedServerId) return
setOpError(null) const epoch = ++historyEpoch.current
setBusy("to") setHistoryLoading(true)
setHistoryError(null)
try { try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/to-router", { const res = await apiFetch<unknown>(
method: "POST", `/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
body: JSON.stringify({ serverId: selectedServerId }), )
}) if (epoch !== historyEpoch.current) return
} catch (e) { setRevisions(parseConfigRevisions(res, selectedServerId, "recursive-routes"))
setOpError(e instanceof Error ? e.message : "Не удалось применить маршруты на роутер") } catch (error) {
if (epoch !== historyEpoch.current) return
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
} finally { } finally {
setBusy(null) if (epoch === historyEpoch.current) setHistoryLoading(false)
} }
}, [isLive, selectedServerId, apiFetch]) }, [isLive, selectedServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || writeLock.current || busy !== null || sharedBusy || !can("mm:network:write")) throw new Error("Восстановление сейчас недоступно")
if (!beginWrite()) throw new Error("Другая операция ещё выполняется")
writeLock.current = true; readEpoch.current++
setHistoryRestoring(true); setBusy("apply")
try {
const raw = await apiFetch<unknown>(`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({ serverId: selectedServerId }) })
const parsed = recursiveReceiptSchema.safeParse(raw)
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId) throw new Error("Сервер не подтвердил восстановление")
if (!alive.current) return
setRows(parsed.data.routes); setRouteOwner(selectedServerId); setLiveStale(false); setWriteAt(new Date().toISOString())
toast.success("Версия применена на роутер"); await loadRevisions()
} catch (error) {
if (alive.current) { setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось восстановить маршруты") }
throw error
} finally {
writeLock.current = false
endWrite()
if (alive.current) { setHistoryRestoring(false); setBusy(null) }
}
}, [isLive, selectedServerId, servers, busy, sharedBusy, apiFetch, loadRevisions, beginWrite, endWrite])
function groupKeyOf(row: RecursiveRouteRow): string { function groupKeyOf(row: RecursiveRouteRow): string {
return row.dstAddress.trim().toLowerCase() return JSON.stringify([row.dstAddress.trim().toLowerCase(), row.routingTable.trim()])
}
const sheetTrigger = useRef<HTMLElement | null>(null)
function returnSheetFocus() {
const target = sheetTrigger.current
if (target?.isConnected) return target
const label = target?.getAttribute("aria-label")
return label ? document.querySelector<HTMLElement>(`button[aria-label="${CSS.escape(label)}"]`) : null
} }
function openCreate() { function openCreate() {
if (mutationsLocked) return
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
setSaveError(null)
setSheetMode("create") setSheetMode("create")
setEditingGroupKey(null) setEditingGroupKey(null)
setSheetInitial(emptyForm()) setSheetInitial(emptyForm())
setSheetOpen(true) setSheetOpen(true)
} }
function openEdit(group: RouteGroup) { function openEdit(group: RouteGroup) {
if (mutationsLocked) return
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
setSaveError(null)
const endpoints = rows.filter(row => groupKeyOf(row) === group.key)
setSheetMode("edit") setSheetMode("edit")
setEditingGroupKey(group.key) setEditingGroupKey(group.key)
setSheetInitial({ setSheetInitial({
dstAddress: group.dstAddress, dstAddress: group.dstAddress,
routingTable: group.routingTable, routingTable: group.routingTable,
comment: group.comment, comment: group.comment,
endpoints: group.endpoints.map(ep => ({ endpoints: endpoints.map(ep => ({
id: ep.id, id: ep.id,
disabled: ep.disabled,
gateway: ep.gateway, gateway: ep.gateway,
distance: ep.distance, distance: ep.distance,
scope: ep.scope, scope: ep.scope,
@@ -515,7 +595,7 @@ export default function RecursiveRoutesPage() {
}) })
setSheetOpen(true) setSheetOpen(true)
} }
function handleSaveSheet(v: RouteForm) { async function handleSaveSheet(v: RouteForm) {
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({ const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
id, id,
dstAddress: v.dstAddress, dstAddress: v.dstAddress,
@@ -526,25 +606,31 @@ export default function RecursiveRoutesPage() {
routingTable: v.routingTable, routingTable: v.routingTable,
checkGateway: ep.checkGateway, checkGateway: ep.checkGateway,
comment: v.comment, comment: v.comment,
disabled: false, disabled: ep.disabled,
country: ep.country || inferCountry(ep.gateway) || "", country: ep.country || "",
}) })
let next: RecursiveRouteRow[]
if (sheetMode === "create") { if (sheetMode === "create") {
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`)) const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
setRows(prev => [...prev, ...expanded]) next = [...rows, ...expanded]
} else if (editingGroupKey) { } else if (editingGroupKey) {
setRows(prev => { const kept = rows.filter(r => groupKeyOf(r) !== editingGroupKey)
const kept = prev.filter(r => groupKeyOf(r) !== editingGroupKey) const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`)) next = [...kept, ...expanded]
return [...kept, ...expanded] } else {
}) return false
} }
setSheetOpen(false) const saved = await applyRoutes(next)
if (saved && alive.current) setSheetOpen(false)
return saved
} }
const contextError = context.error ?? (isLive && liveServerListReady && context.serverId && !servers.some(s => s.id === context.serverId) ? "Устройство из ссылки недоступно" : null)
const hasRouteSnapshot = routeOwner === selectedServerId && (!liveStale || rows.length > 0)
const currentServer = servers.find(s => s.id === selectedServerId) const currentServer = servers.find(s => s.id === selectedServerId)
const mutationsLocked = Boolean(contextError) || !isLive || busy !== null || sharedBusy || liveStale || routeOwner !== selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || !can("mm:network:write")
const rrRailItems = useMemo<ServerTileItem[]>(() => ( const rrRailItems = useMemo<ServerTileItem[]>(() => (
servers.map((s) => ({ servers.map((s) => ({
id: s.id, id: s.id,
@@ -552,7 +638,7 @@ export default function RecursiveRoutesPage() {
host: s.host, host: s.host,
site: s.site, site: s.site,
country: s.country, country: s.country,
status: s.status, status: s.status ?? undefined,
type: s.type, type: s.type,
enabled: s.enabled, enabled: s.enabled,
})) }))
@@ -592,7 +678,15 @@ export default function RecursiveRoutesPage() {
<ServerRailLayout <ServerRailLayout
items={rrRailItems} items={rrRailItems}
selectedId={selectedServerId} selectedId={selectedServerId}
onSelect={setSelectedServerId} onSelect={id => {
if (writeLock.current || sharedBusy || id === selectedServerId) return
readEpoch.current++; gatewayEpoch.current++; historyEpoch.current++
setSelectedServerId(id); setRows([]); setRouteOwner(""); setLiveStale(true); setBusy(null)
setGatewayOptions([]); setGatewayError(null); setOpError(null); setSaveError(null)
setReadAt(null); setWriteAt(null); setSheetOpen(false); setHistoryOpen(false); setRevisions([]); setHistoryError(null)
patchUrl({ serverId: id, route: null, table: null })
}}
preserveSelection={true}
showAll={false} showAll={false}
showCount={false} showCount={false}
loading={isLive && !liveServerListReady} loading={isLive && !liveServerListReady}
@@ -602,17 +696,34 @@ export default function RecursiveRoutesPage() {
actions={ actions={
<> <>
<ServerRailMobileButton /> <ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}> {isLive && (
{busy === "from" ? "Синхронизация..." : "Router => DB"} <>
</Button> <Button
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}> type="button" variant="outline"
{busy === "to" ? "Применение..." : "DB => Router"} size="sm"
</Button> onClick={() => { if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }}
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}> disabled={busy !== null}
<SaveIcon className="size-4" />Сохранить в БД title="Прочитать маршруты с роутера"
</Button> >
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}> <RefreshCwIcon aria-hidden="true" className={cn("size-4", busy === "load" && "animate-spin")} />
<PlusIcon className="size-4" />Добавить Обновить
</Button>
<Button
type="button" variant="outline"
size="sm"
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
disabled={busy !== null}
>
<HistoryIcon aria-hidden="true" className="size-4" />
История
</Button>
</>
)}
<Button type="button" size="sm" onClick={openCreate} disabled={mutationsLocked}>
<PlusIcon aria-hidden="true" className="size-4" />Добавить
</Button> </Button>
</> </>
} }
@@ -621,26 +732,39 @@ export default function RecursiveRoutesPage() {
banner={ banner={
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6"> <div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
<div className="relative min-w-[200px] max-w-xs flex-1"> <div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" /> <SearchIcon aria-hidden="true" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…" <Input aria-label="Поиск маршрутов" className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} /> value={search} onChange={(e) => setSearch(e.target.value)} />
{search && ( {search && (
<button onClick={() => setSearch("")} <button type="button" aria-label="Сбросить поиск маршрутов" onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"> className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" /> <XIcon aria-hidden="true" className="size-3.5" />
</button> </button>
)} )}
</div> </div>
<div className="flex items-center gap-1.5 text-xs"> <div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего маршрутов</span> <span className="text-muted-foreground">Всего маршрутов</span>
<span className="font-semibold tabular-nums">{rows.length}</span> <span className="font-semibold tabular-nums">{hasRouteSnapshot ? rows.length : "—"}</span>
</div> </div>
<p className="text-xs text-muted-foreground ml-auto"> <p className="text-xs text-muted-foreground ml-auto">
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`} {!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p> </p>
{opError && ( {busy === "load" && <p role="status" className="w-full text-xs text-muted-foreground">Обновление маршрутов…</p>}
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2"> {sharedBusy && busy !== "apply" && <p role="status" className="w-full text-sm text-muted-foreground">Операция предыдущего контекста ещё выполняется. Изменения заблокированы до ответа API.</p>}
{opError} {contextError && <p role="alert" className="w-full text-sm text-warning">{contextError}. Выберите устройство или сбросьте ссылку. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ serverId: null, q: null, route: null, table: null })}>Сбросить контекст маршрутов</Button></p>}
{expandedGroupKey && !liveStale && !groupedRoutes.some(group => group.key === expandedGroupKey) && <p role="status" className="w-full text-sm text-muted-foreground">Маршрут из ссылки отсутствует в текущем срезе. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ route: null, table: null })}>Закрыть маршрут</Button></p>}
{(opError || saveError) && (
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2" role="alert">
{opError || saveError}
</div>
)}
{isLive && readAt && <p role="status" className="w-full text-xs text-muted-foreground">Успешный ответ получен {new Date(readAt).toLocaleTimeString("ru-RU")}. Время получения браузером, не измерения RouterOS.</p>}
{isLive && writeAt && <p role="status" className="w-full text-xs text-muted-foreground">Запись подтверждена {new Date(writeAt).toLocaleTimeString("ru-RU")}.</p>}
{isLive && gatewayError && <p role="status" className="w-full text-xs text-warning">Шлюзы не загружены: {gatewayError}. Адрес можно ввести вручную.</p>}
{isLive && liveStale && (
<div className="w-full text-xs text-warning bg-warning/10 border border-warning/20 rounded-md px-3 py-2 flex items-center gap-2">
<AlertTriangleIcon aria-hidden="true" className="size-3.5 shrink-0" />
{routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы.
</div> </div>
)} )}
</div> </div>
@@ -655,35 +779,43 @@ export default function RecursiveRoutesPage() {
) : ( ) : (
<DataPageCard> <DataPageCard>
{currentServer && ( {currentServer && (
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10"> <div className="flex flex-wrap items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} /> {currentServer.status ? <StatusDot status={currentServer.status} pulse={currentServer.status === "online"} /> : <span className="text-xs text-muted-foreground">Состояние неизвестно</span>}
<Flag code={currentServer.country} size={16} /> <Flag code={currentServer.country} size={16} />
<span className="font-mono text-sm font-semibold">{currentServer.name}</span> <span className="min-w-0 break-all font-mono text-sm font-semibold">{currentServer.name}</span>
<TypeChip type={currentServer.type} /> {currentServer.type && <TypeChip type={currentServer.type} />}
<code className="text-[11px] font-mono text-muted-foreground">{currentServer.host}</code> <code className="break-all text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
<span className="text-xs text-muted-foreground">{currentServer.asn}</span> <span className="text-xs text-muted-foreground">{currentServer.asn}</span>
{currentServer.latency !== null && ( {currentServer.latency !== null && (
<span className={cn( <span className={cn(
"text-xs font-mono", "text-xs font-mono",
currentServer.latency > 60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", currentServer.latency > 60 ? "text-warning" : "text-muted-foreground",
)}>{currentServer.latency}мс</span> )}>{currentServer.latency}мс</span>
)} )}
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{rows.length} маршрутов</span> <span>{hasRouteSnapshot ? rows.length + " маршрутов" : "Количество неизвестно"}</span>
</div> </div>
</div> </div>
)} )}
<RecursiveRoutesDataGrid <RecursiveRoutesDataGrid
emptyTitle={liveStale ? "Маршруты пока неизвестны" : search ? "Нет маршрутов по текущему поиску" : undefined}
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))} groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
expandedKey={expandedGroupKey} expandedKey={expandedGroupKey}
onExpandedChange={setExpandedGroupKey} onExpandedChange={key => {
const group = groupedRoutes.find(group => group.key === key)
patchUrl({ route: group?.dstAddress ?? null, table: group?.routingTable ?? null })
}}
mutationsDisabled={mutationsLocked}
onEdit={openEdit} onEdit={openEdit}
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))} onDelete={(g) => {
if (mutationsLocked) return
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
}}
/> />
<button onClick={openCreate} <button type="button" onClick={openCreate} disabled={mutationsLocked}
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t"> className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t disabled:opacity-50">
<PlusIcon className="size-3.5" /> <PlusIcon aria-hidden="true" className="size-3.5" />
Добавить маршрут Добавить маршрут
</button> </button>
</DataPageCard> </DataPageCard>
@@ -691,13 +823,30 @@ export default function RecursiveRoutesPage() {
</ServerRailLayout> </ServerRailLayout>
<RouteSheet <RouteSheet
returnFocus={returnSheetFocus}
busy={busy !== null}
saveError={saveError}
open={sheetOpen} open={sheetOpen}
mode={sheetMode} mode={sheetMode}
initial={sheetInitial} initial={sheetInitial}
onSave={handleSaveSheet} onSave={handleSaveSheet}
onClose={() => setSheetOpen(false)} onClose={() => { if (!writeLock.current) setSheetOpen(false) }}
gateways={gatewayOptions} gateways={gatewayOptions}
/> />
<ConfigHistorySheet
open={historyOpen}
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
loadError={historyError}
onRetry={() => { void loadRevisions() }}
title="История маршрутов"
itemLabel="маршрутов"
revisions={revisions}
loading={historyLoading}
restoreDisabled={!can("mm:network:write") || !currentServer?.enabled || (busy !== null || sharedBusy) && !historyRestoring}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</> </>
) )
} }
+19 -18
View File
@@ -6,7 +6,6 @@ import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-v
import { import {
formatCommitSubject, formatCommitSubject,
getReleaseCommitPreview, getReleaseCommitPreview,
RELEASE_COMMIT_PREVIEW_LIMIT,
} from "@/lib/release-commits" } from "@/lib/release-commits"
import { readReleaseManifest } from "@/lib/release-manifest" import { readReleaseManifest } from "@/lib/release-manifest"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -16,23 +15,23 @@ export const dynamic = "force-dynamic"
function formatPublishedAt(value: string | null, isProdBuild: boolean): string { function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
if (value) { if (value) {
return new Intl.DateTimeFormat("ru-RU", { return "Дата манифеста: " + new Intl.DateTimeFormat("ru-RU", {
dateStyle: "long", dateStyle: "long",
timeStyle: "short", timeStyle: "short",
}).format(new Date(value)) }).format(new Date(value))
} }
return isProdBuild ? "Прод-сборка" : "Локальная сборка" return isProdBuild ? "Дата манифеста не указана" : "Локальная сборка"
} }
export default function ReleasesPage() { export default function ReleasesPage() {
const manifest = readReleaseManifest() const manifest = readReleaseManifest()
const version = getAppVersion() const version = getAppVersion()
const isProdBuild = !version.endsWith("-dev") const isProdBuild = !version.endsWith("-dev")
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null const releaseUrl = getReleaseUrl() || manifest?.releaseUrl || null
const commits = getReleaseCommitPreview(manifest.commits) const commits = getReleaseCommitPreview(manifest?.commits ?? [])
return ( return (
<div className="flex flex-col h-full"> <div className="flex min-h-0 flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Система" }, { label: "Релизы" }]} crumbs={[{ label: "Система" }, { label: "Релизы" }]}
actions={ actions={
@@ -40,53 +39,55 @@ export default function ReleasesPage() {
<Link <Link
href={releaseUrl} href={releaseUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")} className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
> >
<ExternalLinkIcon className="size-4" /> <ExternalLinkIcon className="size-4" aria-hidden />
Открыть в Gitea Открыть релиз
</Link> </Link>
) : null ) : null
} }
/> />
<div className="flex-1 overflow-y-auto p-6"> <div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
<div className="max-w-[960px] mx-auto space-y-6"> <div className="max-w-[960px] mx-auto space-y-6">
<OpsPanel <OpsPanel
title="Текущая версия" title="Текущая версия"
description="Сборка MikrotikManager, опубликованная через CI/CD." description={isProdBuild ? "Версия запущенной сборки MikrotikManager." : "Локальная разработка. Публикация релиза не подтверждена."}
contentClassName="flex flex-col gap-3 px-5 pb-5" contentClassName="flex flex-col gap-3 px-5 pb-5"
> >
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span> <span className="text-2xl font-semibold tracking-tight break-all">{formatAppVersionLabel(version)}</span>
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span> <span className="text-sm text-muted-foreground">{isProdBuild ? formatPublishedAt(manifest?.version === version ? manifest.publishedAt : null, true) : "Локальная сборка"}</span>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits. Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
</p> </p>
</OpsPanel> </OpsPanel>
{!manifest ? <p role="alert" className="text-sm text-destructive">Метаданные релиза недоступны или повреждены. Дата, ссылка и список коммитов не подтверждены.</p> : manifest.version !== version && isProdBuild ? <p role="status" className="text-sm text-muted-foreground">Манифест относится к {formatAppVersionLabel(manifest.version)}; версия запущенной сборки отличается.</p> : null}
<OpsPanel <OpsPanel
title="Сводка коммитов" title="Сводка коммитов"
description={ description={
commits.length > 0 commits.length > 0
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.` ? `Коммиты манифеста ${formatAppVersionLabel(manifest!.version)}: ${commits.length}.`
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI." : manifest ? "В манифесте нет коммитов. При публикации список формируется CI." : "Список коммитов не подтверждён."
} }
contentClassName="px-5 pb-5" contentClassName="px-5 pb-5"
> >
{commits.length === 0 ? ( {commits.length === 0 ? (
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p> <p className="text-sm text-muted-foreground">{manifest ? "Коммитов для отображения пока нет." : "Нет подтверждённых метаданных."}</p>
) : ( ) : (
<ul className="flex flex-col gap-3"> <ul className="flex flex-col gap-3">
{commits.map((commit) => ( {commits.map((commit) => (
<li key={commit.sha} className="rounded-md border px-3 py-2"> <li key={commit.sha} className="rounded-md border px-3 py-2 break-words">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono uppercase">{commit.type}</span> <span className="font-mono uppercase">{commit.type}</span>
<span className="font-mono">{commit.shortSha}</span> <span className="font-mono">{commit.shortSha}</span>
<span>{commit.author}</span> <span>{commit.author}</span>
</div> </div>
<p className="mt-1 text-sm">{formatCommitSubject(commit.subject)}</p> <p className="mt-1 text-sm break-words">{formatCommitSubject(commit.subject)}</p>
</li> </li>
))} ))}
</ul> </ul>
File diff suppressed because it is too large. Load diff
+262 -76
View File
@@ -1,13 +1,21 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { Suspense, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { can } from "@/lib/auth"
import { useSearchParams } from "next/navigation"
import { serverCreateSchema } from "@mmapp/contracts/servers"
import { OperationGate, pollServers, serverOperationError, type PollResult } from "@/lib/server-operations"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit" import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid" import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
import type { Filter } from "@/components/reui/filters" import { useServerUrlState } from "@/hooks/use-server-url-state"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters" import { ServerFilters } from "@/components/data-grids/server-filters"
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields" import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
import { SERVER_FILTER_ACCESSORS } from "@/lib/data-filters/server-filter-fields"
import { servers as initialServers } from "@/lib/data" import { servers as initialServers } from "@/lib/data"
import type { ServerType, Server, WanUplink } from "@/lib/data" import type { ServerType, Server, WanUplink } from "@/lib/data"
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers" import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
@@ -45,9 +53,9 @@ import {
StepperSeparator, StepperSeparator,
StepperTitle, StepperTitle,
StepperTrigger, StepperTrigger,
} from "@/components/reui/stepper" } from "@/components/reui-kit/accessible-stepper"
import { import {
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon, RefreshCwIcon, PlusIcon, TrashIcon,
EyeIcon, EyeOffIcon, EyeIcon, EyeOffIcon,
ChevronRightIcon, ChevronDownIcon, ChevronRightIcon, ChevronDownIcon,
CheckCircleIcon, XCircleIcon, LoaderCircleIcon, CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
@@ -213,15 +221,18 @@ type TypeFilter = "all" | ServerType
type SheetMode = "add" | "edit" type SheetMode = "add" | "edit"
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
export default function ServersPage() { function ServersPageContent() {
const searchParams = useSearchParams()
const selectedServerId = searchParams.get("server")
const clearParams = new URLSearchParams(searchParams.toString())
clearParams.delete("server")
const allServersHref = "/servers" + (clearParams.size ? "?" + clearParams : "")
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
const [serverList, setServerList] = useState<Server[]>([]) const [serverList, setServerList] = useState<Server[]>([])
const [_backendOk, setBackendOk] = useState(false) const [listState, setListState] = useState<"loading" | "ready" | "error">("loading")
const [search, setSearch] = useState("") const { search, setSearch, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset: resetFilters } = useServerUrlState()
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<SheetMode>("add") const [sheetMode, setSheetMode] = useState<SheetMode>("add")
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
@@ -231,35 +242,92 @@ export default function ServersPage() {
const [testMsg, setTestMsg] = useState("") const [testMsg, setTestMsg] = useState("")
const [pollingIds, setPollingIds] = useState<Set<string>>(new Set()) const [pollingIds, setPollingIds] = useState<Set<string>>(new Set())
const [pollAllBusy, setPollAllBusy] = useState(false) const [pollAllBusy, setPollAllBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [detailsLoading, setDetailsLoading] = useState(false)
const [detailsReady, setDetailsReady] = useState(true)
const [formError, setFormError] = useState("")
const [operationMessage, setOperationMessage] = useState("")
const [operationError, setOperationError] = useState("")
const [deleteTarget, setDeleteTarget] = useState<Server | null>(null)
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState("")
const [pollResults, setPollResults] = useState<PollResult[]>([])
const [rowBusy, setRowBusy] = useState(false)
const gate = useRef(new OperationGate())
const formVersion = useRef(0)
const testVersion = useRef(0)
const contextVersion = useRef(0)
const canWrite = can("mm:servers:write")
const accessDenied = "Недостаточно прав: изменение и опрос серверов требуют разрешения на запись."
function requireWrite(report: (message: string) => void = setOperationError) {
if (can("mm:servers:write")) return true
report(accessDenied)
return false
}
const controlsBusy = saving || rowBusy || pollAllBusy || pollingIds.size > 0
const set = <K extends keyof FormState>(k: K, v: FormState[K]) => const set = <K extends keyof FormState>(k: K, v: FormState[K]) => {
if (gate.current.has("save") || detailsLoading) return
if (["host", "port", "proto", "verifySsl", "apiPath", "username", "password"].includes(k)) {
testVersion.current++
setTestState("idle"); setTestMsg("")
}
setForm((f) => ({ ...f, [k]: v })) setForm((f) => ({ ...f, [k]: v }))
}
function closeForm(next: boolean) {
if (gate.current.has("save")) return
if (!next) { formVersion.current++; testVersion.current++ }
setOpen(next)
}
// ── load from backend when mode/url changes ─────────────────────────────── // ── load from backend when mode/url changes ───────────────────────────────
useEffect(() => { useEffect(() => {
const version = ++contextVersion.current
let cancelled = false
const invalidate = () => {
cancelled = true
contextVersion.current++
formVersion.current++
testVersion.current++
}
queueMicrotask(() => {
if (cancelled) return
formVersion.current++; testVersion.current++
setOpen(false); setDeleteTarget(null); setOperationError(""); setOperationMessage(""); setPollResults([])
setListState("loading")
})
if (mode !== "live") { if (mode !== "live") {
queueMicrotask(() => { queueMicrotask(() => {
if (cancelled) return
setServerList(initialServers) setServerList(initialServers)
setBackendOk(false) setListState("ready")
}) })
return return invalidate
} }
listServers(backendUrl) listServers(backendUrl)
.then(data => { .then(data => {
setBackendOk(true) if (cancelled || version !== contextVersion.current) return
setListState("ready")
setServerList(data.map(toFrontendServer)) setServerList(data.map(toFrontendServer))
}) })
.catch(() => { .catch((error) => {
setBackendOk(false) if (cancelled || version !== contextVersion.current) return
setOperationError(serverOperationError(error))
setListState("error")
setServerList([]) setServerList([])
}) })
// eslint-disable-next-line react-hooks/exhaustive-deps return invalidate
}, [mode, backendUrl]) }, [mode, backendUrl])
// ── actions ────────────────────────────────────────────────────────────── // ── actions ──────────────────────────────────────────────────────────────
function openAdd() { function openAdd() {
if (!requireWrite()) return
if (gate.current.has("save")) return
formVersion.current++; testVersion.current++
setFormError(""); setDetailsLoading(false); setDetailsReady(true)
setSheetMode("add"); setEditingId(null) setSheetMode("add"); setEditingId(null)
setForm(defaultForm); setTestState("idle"); setTestMsg("") setForm(defaultForm); setTestState("idle"); setTestMsg("")
setSheetStep(1) setSheetStep(1)
@@ -267,11 +335,16 @@ export default function ServersPage() {
} }
async function openEdit(s: Server) { async function openEdit(s: Server) {
if (!requireWrite()) return
if (gate.current.has("save")) return
const version = ++formVersion.current
testVersion.current++
setFormError(""); setDetailsLoading(isLive); setDetailsReady(!isLive)
setSheetMode("edit"); setEditingId(s.id) setSheetMode("edit"); setEditingId(s.id)
setForm({ setForm({
...defaultForm, ...defaultForm,
name: s.name, type: s.type, site: s.site, country: s.country ?? "RU", name: s.name, type: s.type, site: s.site, country: s.country ?? "RU",
asn: s.asn ?? "", enabled: s.status !== "offline", host: s.host, asn: s.asn ?? "", enabled: s.enabled, host: s.host,
comment: s.comment ?? "", comment: s.comment ?? "",
lanSubnet: s.lanSubnet ?? "", lanSubnet: s.lanSubnet ?? "",
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [], wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
@@ -282,9 +355,11 @@ export default function ServersPage() {
if (isLive) { if (isLive) {
try { try {
const full = await getServer(backendUrl, s.id) const full = await getServer(backendUrl, s.id)
if (version !== formVersion.current) return
setForm(prev => ({ setForm(prev => ({
...prev, ...prev,
username: full.username ?? "", username: full.username ?? "",
enabled: full.enabled,
password: full.password ?? "", password: full.password ?? "",
port: String(full.port ?? 443), port: String(full.port ?? 443),
proto: full.useSsl ? "https" : "http", proto: full.useSsl ? "https" : "http",
@@ -295,16 +370,24 @@ export default function ServersPage() {
? JSON.parse(JSON.stringify(full.wanUplinks)) ? JSON.parse(JSON.stringify(full.wanUplinks))
: prev.wanUplinks, : prev.wanUplinks,
})) }))
} catch { setDetailsReady(true)
// ignore — user can fill in manually } catch (error) {
if (version === formVersion.current) setFormError(serverOperationError(error))
} finally {
if (version === formVersion.current) setDetailsLoading(false)
} }
} }
} }
async function handleSave() { async function handleSave() {
if (!requireWrite(setFormError)) return
if (!detailsReady || detailsLoading || !gate.current.acquire("save")) return
const context = contextVersion.current
setSaving(true); setFormError("")
try {
const payload: ServerCreate = { const payload: ServerCreate = {
host: form.host, host: form.host,
port: Number(form.port) || (form.proto === "https" ? 443 : 80), port: Number(form.port),
username: form.username, username: form.username,
password: form.password, password: form.password,
useSsl: form.proto === "https", useSsl: form.proto === "https",
@@ -320,18 +403,32 @@ export default function ServersPage() {
wanUplinks: form.type === "home-router" ? form.wanUplinks : [], wanUplinks: form.type === "home-router" ? form.wanUplinks : [],
} }
if (!form.host.trim() || !form.username.trim() || !Number.isInteger(payload.port) || payload.port! > 65535 || payload.port! < 1) {
setFormError("Укажите хост, имя пользователя и порт от 1 до 65535.")
setSheetStep(3)
return
}
const validation = serverCreateSchema.safeParse(payload)
if (!validation.success) {
setFormError("Проверьте параметры сервера: " + validation.error.issues.map(i => i.path.join(".")).join(", "))
return
}
if (isLive) { if (isLive) {
try { try {
if (sheetMode === "edit" && editingId) { if (sheetMode === "edit" && editingId) {
const updatedPayload: ServerUpdate = payload const updatedPayload: ServerUpdate = payload
const updated = await updateServer(backendUrl, editingId, updatedPayload) const updated = await updateServer(backendUrl, editingId, updatedPayload)
if (context !== contextVersion.current) return
setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s)) setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s))
} else { } else {
const created = await createServer(backendUrl, payload) const created = await createServer(backendUrl, payload)
if (context !== contextVersion.current) return
setServerList(list => [...list, toFrontendServer(created)]) setServerList(list => [...list, toFrontendServer(created)])
} }
} catch (e) { } catch (e) {
console.error("Ошибка сохранения:", e) if (context === contextVersion.current) setFormError(serverOperationError(e))
return
} }
} else { } else {
// offline mode — update local state only // offline mode — update local state only
@@ -349,7 +446,7 @@ export default function ServersPage() {
comment: form.comment, comment: form.comment,
lanSubnet: form.lanSubnet || undefined, lanSubnet: form.lanSubnet || undefined,
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined, wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline", enabled: form.enabled,
} }
: s : s
)) ))
@@ -368,57 +465,85 @@ export default function ServersPage() {
} }
setOpen(false) setOpen(false)
formVersion.current++; testVersion.current++
setOperationMessage(isLive ? "Сервер сохранён." : "Сервер сохранён в демо-режиме.")
} finally {
gate.current.release("save"); setSaving(false)
}
} }
function handleToggleStatus(id: string) { async function handleToggleStatus(id: string) {
setServerList(list => list.map(s => if (!requireWrite()) return
s.id === id ? { ...s, status: s.status === "offline" ? "online" : "offline" } : s const server = serverList.find(s => s.id === id)
)) if (!server || !gate.current.acquire("rows")) return
const context = contextVersion.current
setRowBusy(true); setOperationError(""); setOperationMessage("")
try {
let enabled = !server.enabled
if (isLive) enabled = (await updateServer(backendUrl, id, { enabled })).enabled
if (context !== contextVersion.current) return
setServerList(list => list.map(s => s.id === id ? { ...s, enabled } : s))
setOperationMessage(`${server.name}: ${enabled ? "включён" : "отключён"}${isLive ? "." : " (демо)."}`)
} catch (error) {
if (context === contextVersion.current) setOperationError(serverOperationError(error))
} finally { gate.current.release("rows"); setRowBusy(false) }
} }
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (isLive) { if (!requireWrite(setDeleteError)) return
try { if (!gate.current.acquire("rows")) return
await deleteServer(backendUrl, id) const context = contextVersion.current
} catch (e) { setDeleting(true); setRowBusy(true); setDeleteError("")
console.error("Ошибка удаления:", e) try {
} if (isLive) await deleteServer(backendUrl, id)
} if (context !== contextVersion.current) return
setServerList(list => list.filter(s => s.id !== id)) setServerList(list => list.filter(s => s.id !== id))
setDeleteTarget(null); setOperationError(""); setOperationMessage("Сервер удалён из списка.")
} catch (error) {
if (context === contextVersion.current) setDeleteError(serverOperationError(error))
} finally { gate.current.release("rows"); setDeleting(false); setRowBusy(false) }
} }
async function handlePoll(id: string) { async function handlePoll(id: string) {
if (!isLive) return await runPoll([id])
setPollingIds(p => new Set(p).add(id))
try {
await pollServer(backendUrl, id)
// Reload full list to get updated status/version
const data = await listServers(backendUrl)
setServerList(data.map(toFrontendServer))
} catch (e) {
console.error("Poll error:", e)
} finally {
setPollingIds(p => { const n = new Set(p); n.delete(id); return n })
}
} }
async function handlePollAll() { async function handlePollAll() {
if (!isLive) return await runPoll(serverList.map(s => s.id))
setPollAllBusy(true) }
async function runPoll(ids: string[]) {
if (!requireWrite()) return
if (!isLive || !gate.current.acquire("rows")) return
const context = contextVersion.current
setPollAllBusy(true); setPollingIds(new Set(ids)); setOperationError(""); setOperationMessage(""); setPollResults([])
try { try {
const ids = serverList.map(s => s.id) const results = await pollServers(ids, async id => {
await Promise.all(ids.map(id => pollServer(backendUrl, id).catch(() => {}))) if (context !== contextVersion.current) throw new Error("Контекст изменился; опрос отменён.")
const data = await listServers(backendUrl) if (!can("mm:servers:write")) throw new Error(accessDenied)
setServerList(data.map(toFrontendServer)) await pollServer(backendUrl, id)
})
if (context !== contextVersion.current) return
setPollResults(results)
try {
const data = await listServers(backendUrl)
if (context === contextVersion.current) setServerList(data.map(toFrontendServer))
} catch (error) {
if (context === contextVersion.current) setOperationError("Опрос завершён, но список не обновлён. " + serverOperationError(error))
}
} finally { } finally {
setPollAllBusy(false) gate.current.release("rows"); setPollAllBusy(false); setPollingIds(new Set())
} }
} }
async function handleTest() { async function handleTest() {
if (!requireWrite(setFormError)) return
if (!detailsReady || gate.current.has("save") || gate.current.has("test")) return
const version = ++testVersion.current
if (!form.host || !form.username) { if (!form.host || !form.username) {
setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return
} }
gate.current.acquire("test")
setTestState("testing"); setTestMsg("") setTestState("testing"); setTestMsg("")
try { try {
const data = await testServerConnection(backendUrl, { const data = await testServerConnection(backendUrl, {
@@ -430,28 +555,36 @@ export default function ServersPage() {
username: form.username, username: form.username,
password: form.password, password: form.password,
}) })
if (version !== testVersion.current) return
if (data.success) { if (data.success) {
setTestState("ok"); setTestMsg(data.message) setTestState("ok"); setTestMsg(data.message)
} else { } else {
setTestState("error"); setTestMsg(data.message) setTestState("error"); setTestMsg(data.message)
} }
} catch { } catch (error) {
if (version !== testVersion.current) return
setTestState("error") setTestState("error")
setTestMsg(`Не удалось связаться с бекендом — убедитесь, что он запущен на ${backendUrl}`) setTestMsg(serverOperationError(error))
} finally {
gate.current.release("test")
} }
} }
// ── derived ────────────────────────────────────────────────────────────── // ── derived ──────────────────────────────────────────────────────────────
const selectedServer = serverList.find(server => server.id === selectedServerId)
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (urlError) return []
const base = serverList.filter(sv => { const base = serverList.filter(sv => {
if (selectedServerId !== null && sv.id !== selectedServerId) return false
if (typeFilter !== "all" && sv.type !== typeFilter) return false if (typeFilter !== "all" && sv.type !== typeFilter) return false
if (!search) return true if (!search) return true
const q = search.toLowerCase() const q = search.toLowerCase()
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q) return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
}) })
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS) return applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS)
}, [serverList, search, typeFilter, advancedFilters]) }, [serverList, search, typeFilter, advancedFilters, selectedServerId, urlError])
const counts = useMemo(() => ({ const counts = useMemo(() => ({
all: serverList.length, all: serverList.length,
@@ -478,11 +611,10 @@ export default function ServersPage() {
crumbs={[{ label: "Управление" }, { label: "Серверы" }]} crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
actions={ actions={
<> <>
<Button variant="outline" size="sm" onClick={handlePollAll} disabled={!isLive || pollAllBusy}> <Button variant="outline" size="sm" onClick={() => selectedServerId !== null ? selectedServer && void runPoll([selectedServer.id]) : void handlePollAll()} disabled={!canWrite || !isLive || controlsBusy || (selectedServerId !== null ? !selectedServer : serverList.length === 0)}>
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />Проверить все <RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />{selectedServerId !== null ? "Проверить сервер" : "Проверить все"}
</Button> </Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button> <Button size="sm" onClick={openAdd} disabled={!canWrite || controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
<Button size="sm" onClick={openAdd}><PlusIcon className="size-4" />Добавить сервер</Button>
</> </>
} }
/> />
@@ -490,6 +622,32 @@ export default function ServersPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{!canWrite && <Alert variant="info" role="status"><AlertTitle>Только просмотр</AlertTitle><AlertDescription>Изменение, удаление, опрос серверов и тест соединения требуют разрешения на запись. Просмотр деталей и фильтры доступны.</AlertDescription></Alert>}
{operationError && <Alert variant="destructive" role="alert"><AlertTitle>Действие не завершено</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
{operationMessage && <Alert variant="success" role="status"><AlertDescription>{operationMessage}</AlertDescription></Alert>}
{pollResults.length > 0 && (
<Alert variant={pollResults.some(r => r.error) ? "warning" : "success"} role="status">
<AlertTitle>Опрос: успешно {pollResults.filter(r => !r.error).length} из {pollResults.length}</AlertTitle>
<AlertDescription>
<ul>{pollResults.map(r => <li key={r.id}>{serverList.find(s => s.id === r.id)?.name ?? r.id}: {r.error ?? "опрос выполнен"}</li>)}</ul>
{pollResults.some(r => r.error) && <Button type="button" variant="outline" size="sm" disabled={!canWrite || controlsBusy} onClick={() => runPoll(pollResults.filter(r => r.error).map(r => r.id))}>Повторить неуспешные</Button>}
</AlertDescription>
</Alert>
)}
{selectedServerId !== null && (
<Alert variant={listState === "error" || (listState === "ready" && !selectedServer) ? "warning" : "info"} role="status" aria-label="Сервер по ссылке">
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedServer ? "Выбран сервер: " + selectedServer.name : "Сервер по ссылке"}</AlertTitle>
<AlertDescription>
<p>{listState === "loading" ? "Загрузка сервера…" : listState === "error" ? "Не удалось загрузить сервер. Проверьте сообщение об ошибке выше." : selectedServer ? selectedServer.host + " · Список ограничен выбранным сервером. Сводка относится ко всем серверам." : "Сервер не найден или недоступен. Возможно, он удалён или у вас нет доступа."}</p>
<div className="flex flex-wrap gap-4">
<Link href={allServersHref} className="underline underline-offset-2">Все серверы</Link>
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
</div>
</AlertDescription>
</Alert>
)}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка серверов" aria-label="Сводка серверов"
items={[ items={[
@@ -525,6 +683,8 @@ export default function ServersPage() {
/> />
{/* Table */} {/* Table */}
{urlError && <Alert variant="warning" role="alert"><AlertTitle>Фильтры требуют внимания</AlertTitle><AlertDescription>{urlError}</AlertDescription></Alert>}
{hasFilters && <div><Button variant="outline" size="sm" onClick={resetFilters}>Сбросить фильтры списка</Button></div>}
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
segmented={{ segmented={{
@@ -536,9 +696,7 @@ export default function ServersPage() {
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType], count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
})), })),
}} }}
filters={advancedFilters} filterControl={<ServerFilters query={advancedFilters} onQueryChange={setAdvancedFilters} />}
onFiltersChange={setAdvancedFilters}
filterFields={SERVER_FILTER_FIELDS}
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, хосту…" searchPlaceholder="Поиск по имени, хосту…"
@@ -546,11 +704,14 @@ export default function ServersPage() {
/> />
<ServersDataGrid <ServersDataGrid
servers={filtered} servers={filtered}
hasUnfilteredRows={serverList.length > 0}
isLive={isLive} isLive={isLive}
pollingIds={pollingIds} pollingIds={pollingIds}
busy={controlsBusy}
canWrite={canWrite}
onPoll={handlePoll} onPoll={handlePoll}
onEdit={openEdit} onEdit={openEdit}
onDelete={handleDelete} onDelete={id => { if (!requireWrite()) return; setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }}
onToggleStatus={handleToggleStatus} onToggleStatus={handleToggleStatus}
/> />
</DataPageCard> </DataPageCard>
@@ -558,13 +719,19 @@ export default function ServersPage() {
</div> </div>
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */} {/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */}
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={closeForm}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0"> <SheetContent side="right" showCloseButton={!saving} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0"> <SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"}</SheetTitle> <SheetTitle>{sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"}</SheetTitle>
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription> <SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
</SheetHeader> </SheetHeader>
{detailsLoading && <p className="px-6 py-2 text-sm" role="status">Загрузка параметров сервера…</p>}
{formError && <div className="px-6 py-2"><Alert variant="destructive" role="alert"><AlertDescription>{formError}</AlertDescription></Alert>
{!detailsReady && !detailsLoading && editingId && <Button type="button" variant="outline" onClick={() => { const server = serverList.find(s => s.id === editingId); if (server) void openEdit(server) }}>Повторить загрузку</Button>}
</div>}
<fieldset disabled={!canWrite || saving || !detailsReady} className="flex min-h-0 min-w-0 flex-1 flex-col" aria-busy={saving || detailsLoading}>
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5"> <Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5"> <StepperNav className="mb-5">
<StepperItem step={1}> <StepperItem step={1}>
@@ -605,6 +772,7 @@ export default function ServersPage() {
<FormField label="Тип узла" required> <FormField label="Тип узла" required>
<SegmentedControl <SegmentedControl
className="max-w-full flex-wrap"
value={form.type} value={form.type}
onChange={v => set("type", v)} onChange={v => set("type", v)}
options={[ options={[
@@ -725,7 +893,7 @@ export default function ServersPage() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button type="button" variant="outline" size="sm" className="w-fit gap-2" <Button type="button" variant="outline" size="sm" className="w-fit gap-2"
disabled={testState === "testing"} onClick={handleTest}> disabled={!canWrite || testState === "testing"} onClick={handleTest}>
{testState === "testing" {testState === "testing"
? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</> ? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</>
: <><WifiIcon className="size-4" />Проверить подключение</>} : <><WifiIcon className="size-4" />Проверить подключение</>}
@@ -776,22 +944,40 @@ export default function ServersPage() {
</StepperContent> </StepperContent>
</StepperPanel> </StepperPanel>
</Stepper> </Stepper>
</fieldset>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2"> <SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose> <SheetClose render={<Button variant="outline" disabled={saving} />}>Отмена</SheetClose>
{sheetStep > 1 && ( {sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button> <Button variant="outline" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)} )}
{sheetStep < 4 ? ( {sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button> <Button className="ml-auto" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : ( ) : (
<Button className="ml-auto" onClick={handleSave}> <Button className="ml-auto" disabled={!canWrite || saving || !detailsReady || testState === "testing"} onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"} {saving ? "Сохранение…" : sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button> </Button>
)} )}
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<AlertDialog open={deleteTarget !== null} onOpenChange={next => { if (!next && !gate.current.has("rows")) setDeleteTarget(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить сервер?</AlertDialogTitle>
<AlertDialogDescription>Сервер «{deleteTarget?.name}» ({deleteTarget?.host}) будет удалён из списка управления.</AlertDialogDescription>
</AlertDialogHeader>
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
<AlertDialogFooter>
<Button type="button" variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>Отмена</Button>
<Button type="button" variant="destructive" disabled={!canWrite || deleting} onClick={() => { if (deleteTarget) void handleDelete(deleteTarget.id) }}>{deleting ? "Удаление…" : "Удалить"}</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
) )
} }
export default function ServersPage() {
return <Suspense fallback={<p role="status" className="p-6">Загрузка серверов…</p>}><ServersPageContent /></Suspense>
}
+49 -805
View File
@@ -1,291 +1,44 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { useState } from "react"
import Link from "next/link" import Link from "next/link"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit" import { useTheme } from "@/components/theme-provider"
import { FileImportDialog } from "@/components/file-import-dialog" import { SystemDatabaseSection } from "@/components/settings/system-database-section"
import { EvoBgpSettingsSection } from "@/components/settings/evobgp-settings-section"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { useEvoBGP, type EvoBgpSavePayload, type EvoBgpTestDraft } from "@/lib/evobgp-context"
import { DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } from "@/lib/route-optimizer-data"
import { import {
AlertDialog, CheckIcon,
AlertDialogAction, AlertCircleIcon,
AlertDialogCancel, XIcon,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
SaveIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
AlertCircleIcon, EyeIcon, EyeOffIcon,
RefreshCwIcon, LoaderCircleIcon,
DownloadIcon, UploadIcon, XIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
import { purgeTrafficFlowData } from "@/shared/api/traffic-flow"
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
import { toast } from "sonner"
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
const INIT_API_KEYS: ApiKey[] = [
{ id: "k1", name: "Monitoring Script", prefix: "rl_live_mU9k2…", created: "12 дн назад", last: "2м назад", scopes: ["servers:read", "probes:read"] },
{ id: "k2", name: "CI/CD deploy hook", prefix: "rl_live_vB3p8…", created: "1 мес назад", last: "вчера", scopes: ["filters:write", "domains:write"] },
{ id: "k3", name: "Grafana datasource", prefix: "rl_live_xK7r1…", created: "2 мес назад", last: "сейчас", scopes: ["traffic:read", "servers:read"] },
]
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const
type NavSection = typeof SECTIONS_NAV[number] type NavSection = typeof SECTIONS_NAV[number]
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(backendUrl, path, init)
}
}
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) { function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
return ( return (
<div className="flex items-center justify-between gap-4 py-3.5"> <div className="flex flex-col items-start sm:flex-row sm:items-center sm:justify-between gap-3 py-3.5">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium">{label}</p> <p className="text-sm font-medium">{label}</p>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>} {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div> </div>
<div className="shrink-0">{children}</div> <div className="min-w-0 max-w-full">{children}</div>
</div> </div>
) )
} }
function DatabaseRestoreConfirm({
open,
filename,
busy,
onConfirm,
onCancel,
}: {
open: boolean
filename: string
busy?: boolean
onConfirm: () => void
onCancel: () => void
}) {
return (
<AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<AlertCircleIcon />
</AlertDialogMedia>
<AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle>
<AlertDialogDescription>
Файл: {filename}. Текущие данные PostgreSQL на бекенде будут полностью заменены.
Рекомендуется сначала скачать актуальный бэкап.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={busy}
onClick={onConfirm}
>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
{busy ? "Восстановление…" : "Восстановить"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// ─── page ───────────────────────────────────────────────────────────────────── // ─── page ─────────────────────────────────────────────────────────────────────
export default function SettingsPage() { export default function SettingsPage() {
const [section, setSection] = useState<NavSection>("Общие") const [section, setSection] = useState<NavSection>("Общие")
const [saved, setSaved] = useState(false) const { mode, setMode, backendUrl, backendUrlLocked, mockModeAvailable, backendStatus, backendChecking, checkBackend } = useDataSource()
const [copied, setCopied] = useState<string | null>(null) const { theme, setTheme } = useTheme()
// data source
const { mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, backendStatus, checkBackend } = useDataSource()
const evo = useEvoBGP()
const [evoBaseDraft, setEvoBaseDraft] = useState("")
const [evoEnabledDraft, setEvoEnabledDraft] = useState(false)
const [evoKeyDraft, setEvoKeyDraft] = useState("")
const [evoSaveBusy, setEvoSaveBusy] = useState(false)
const [evoSaveErr, setEvoSaveErr] = useState<string | null>(null)
const [urlDraft, setUrlDraft] = useState(backendUrl)
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
useEffect(() => {
setUrlDraft(backendUrl)
}, [backendUrl])
const commitBackendUrl = useCallback(() => {
const normalized = urlDraft.trim().replace(/\/$/, "")
setUrlDraft(normalized)
setBackendUrl(normalized)
void checkBackend()
}, [urlDraft, setBackendUrl, checkBackend])
const [evoTestResult, setEvoTestResult] = useState<{ ok: boolean; message: string } | null>(null)
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
const [showEvoKey, setShowEvoKey] = useState(false)
// general
const [lang, setLang] = useState("ru")
const [theme, setTheme] = useState("system")
const [timezone, setTimezone] = useState("Europe/Moscow")
const [refreshSec, setRefreshSec] = useState("30")
const [probeTimeout, setProbeTimeout] = useState("5")
const [dbBackupBusy, setDbBackupBusy] = useState(false)
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
const [dbPurgeOpen, setDbPurgeOpen] = useState(false)
const [dbPurgeBusy, setDbPurgeBusy] = useState(false)
// notifications
const [notifEmail, setNotifEmail] = useState(true)
const [notifSlack, setNotifSlack] = useState(false)
const [notifWh, setNotifWh] = useState(true)
const [notifDegr, setNotifDegr] = useState(true)
const [notifOffline, setNotifOffline] = useState(true)
const [notifBgp, setNotifBgp] = useState(true)
const [notifBackup, setNotifBackup] = useState(false)
// api keys
const [apiKeys, setApiKeys] = useState<ApiKey[]>(INIT_API_KEYS)
// security
const [mfa, setMfa] = useState(true)
const [sessMin, setSessMin] = useState("480")
const [ipAllow, setIpAllow] = useState("10.0.0.0/8\n192.168.0.0/16")
const [auditLog, setAuditLog] = useState(true)
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {})
setCopied(text); setTimeout(() => setCopied(null), 1500)
toast.success("Скопировано в буфер обмена")
}
const markSaved = useCallback(() => {
setSaved(true)
toast.success("Настройки сохранены")
setTimeout(() => setSaved(false), 2000)
}, [])
useEffect(() => {
if (section !== "EvoBGP") return
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
}, [section, mode, backendStatus, evo.loadSettings])
useEffect(() => {
if (section !== "EvoBGP" || !evo.settingsLoaded) return
setEvoBaseDraft(evo.baseUrl)
setEvoEnabledDraft(evo.enabled)
setEvoKeyDraft("")
setEvoSaveErr(null)
}, [section, evo.settingsLoaded, evo.baseUrl, evo.enabled])
const handleSave = useCallback(async () => {
if (section === "EvoBGP") {
if (mode !== "live" || backendStatus !== true) {
markSaved()
return
}
setEvoSaveBusy(true)
setEvoSaveErr(null)
try {
const patch: EvoBgpSavePayload = {
baseUrl: evoBaseDraft.trim(),
enabled: evoEnabledDraft,
}
if (evoKeyDraft.trim()) patch.apiKey = evoKeyDraft.trim()
await evo.saveSettings(patch)
setEvoKeyDraft("")
markSaved()
toast.success("Настройки EvoBGP сохранены")
} catch (e) {
const msg = e instanceof Error ? e.message : "Ошибка сохранения"
setEvoSaveErr(msg)
toast.error(msg)
} finally {
setEvoSaveBusy(false)
}
return
}
markSaved()
}, [
section,
mode,
backendStatus,
evoBaseDraft,
evoEnabledDraft,
evoKeyDraft,
markSaved,
evo.saveSettings,
])
const systemDbAvailable = mode === "live" && backendStatus === true
const handleSystemDatabaseBackup = useCallback(async () => {
if (!systemDbAvailable) return
setDbBackupBusy(true)
try {
const { blob, filename } = await downloadSystemDatabaseBackup(backendUrl)
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
toast.success("Бэкап базы приложения скачан")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось создать бэкап")
} finally {
setDbBackupBusy(false)
}
}, [backendUrl, systemDbAvailable])
const handleSystemDatabaseRestoreConfirm = useCallback(async () => {
if (!dbRestoreFile || !systemDbAvailable) return
setDbRestoreBusy(true)
try {
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
toast.success("База приложения восстановлена")
setDbRestoreFile(null)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
} finally {
setDbRestoreBusy(false)
}
}, [backendUrl, dbRestoreFile, systemDbAvailable])
const handleNetflowPurgeConfirm = useCallback(async () => {
if (!systemDbAvailable) return
setDbPurgeBusy(true)
try {
const result = await purgeTrafficFlowData(backendUrl)
toast.success(formatFlowPurgeResult(result))
setDbPurgeOpen(false)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сбросить NetFlow")
} finally {
setDbPurgeBusy(false)
}
}, [backendUrl, systemDbAvailable])
const renderContent = () => { const renderContent = () => {
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
// ── Общие ── // ── Общие ──
if (section === "Общие") return ( if (section === "Общие") return (
@@ -296,18 +49,18 @@ export default function SettingsPage() {
title="Источник данных" title="Источник данных"
description={ description={
mockModeAvailable mockModeAvailable
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts" ? "Выберите живые или демонстрационные данные. Режим применяется сразу в этом браузере."
: "В продакшен-образе доступны только живые данные от бекенда" : "Источник живых данных задан конфигурацией приложения."
} }
contentClassName="divide-y px-5" contentClassName="divide-y px-5"
> >
{/* mode toggle */} {/* mode toggle */}
{mockModeAvailable ? ( {mockModeAvailable ? (
<SettingRow label="Режим данных" description="Моковые — статичные тестовые данные; Живые — RouterOS REST API через бекенд"> <SettingRow label="Режим данных" description="Демонстрационный режим использует тестовые данные. Живой режим обращается к серверу.">
<div className="flex rounded-md border border-input overflow-hidden h-8"> <div className="flex rounded-md border border-input overflow-hidden h-8">
{([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => ( {([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => (
<button key={v} onClick={() => setMode(v)} <button key={v} type="button" aria-pressed={mode===v} onClick={() => setMode(v)}
className={cn( className={cn(
"px-3 text-xs transition-colors border-input", "px-3 text-xs transition-colors border-input",
i === 0 && "border-r", i === 0 && "border-r",
@@ -333,31 +86,11 @@ export default function SettingsPage() {
label="URL бекенда" label="URL бекенда"
description={ description={
backendUrlLocked backendUrlLocked
? "В Docker-образе URL API задаётся через прокси фронтенда на backend; поле только для просмотра." ? "Адрес подключения задан конфигурацией приложения."
: "Адрес Node.js/Fastify сервера (запускается командой npm run dev в /backend)" : "Адрес сервера приложения. Изменение применяется после Enter или выхода из поля."
} }
> >
<div className="flex items-center gap-2"> <BackendUrlField key={backendUrl} />
<Input
className="w-56 h-8 text-sm font-mono"
value={urlDraft}
onChange={e => setUrlDraft(e.target.value)}
onBlur={commitBackendUrl}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault()
commitBackendUrl()
}
}}
readOnly={backendUrlLocked}
placeholder="http://localhost:8000"
/>
<Button variant="outline" size="sm" className="h-8 px-2.5 shrink-0"
onClick={commitBackendUrl}
title="Проверить соединение">
<RefreshCwIcon className="size-3.5" />
</Button>
</div>
</SettingRow> </SettingRow>
{/* connection status */} {/* connection status */}
@@ -366,11 +99,11 @@ export default function SettingsPage() {
{backendStatus === undefined && ( {backendStatus === undefined && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="size-2 rounded-full bg-muted-foreground/40 inline-block" /> <span className="size-2 rounded-full bg-muted-foreground/40 inline-block" />
Не проверялось {backendChecking ? "Проверка…" : "Не проверялось"}
</span> </span>
)} )}
{backendStatus === true && ( {backendStatus === true && (
<span className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400"> <span className="flex items-center gap-1.5 text-xs text-emerald-700 dark:text-emerald-400">
<CheckIcon className="size-3.5" />Доступен <CheckIcon className="size-3.5" />Доступен
</span> </span>
)} )}
@@ -388,516 +121,49 @@ export default function SettingsPage() {
{/* hint when live but backend unreachable */} {/* hint when live but backend unreachable */}
{mode === "live" && backendStatus === false && ( {mode === "live" && backendStatus === false && (
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400"> <div className="flex items-start gap-2 py-3 text-xs text-amber-700 dark:text-amber-400">
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" /> <AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
<span>Бекенд недоступен — данные будут отображаться из кеша или моков. Запустите бекенд: <code className="font-mono bg-muted px-1 rounded">cd backend && npm run dev</code></span> <span>Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.</span>
</div> </div>
)} )}
</OpsPanel> </OpsPanel>
{/* ── Основные настройки ── */} <OpsPanel title="Интерфейс" description="Тема применяется сразу в этом браузере." contentClassName="divide-y px-5">
<OpsPanel title="Основные настройки" contentClassName="divide-y px-5"> <SettingRow label="Язык интерфейса"><span className="text-sm">Русский</span></SettingRow>
<SettingRow label="Язык интерфейса"> <SettingRow label="Тема оформления"><div className="flex flex-wrap gap-2">
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring" {([["light","Светлая"],["dark","Тёмная"],["system","Авто"]] as const).map(([value,label]) => <Button key={value} size="sm" variant={theme===value?"default":"outline"} aria-pressed={theme===value} disabled={!theme} onClick={()=>setTheme(value)}>{label}</Button>)}
value={lang} onChange={e => setLang(e.target.value)}> </div></SettingRow>
<option value="ru">Русский</option> <p className="py-3 text-xs text-muted-foreground">Периоды сбора данных и параметры диагностики настраиваются в соответствующих разделах.</p>
<option value="en">English</option> <div className="flex flex-wrap gap-2 pb-4"><Link href="/data-collection" className={buttonVariants({variant:"outline",size:"sm"})}>Сбор данных</Link><Link href="/probes" className={buttonVariants({variant:"outline",size:"sm"})}>Диагностика</Link></div>
</select>
</SettingRow>
<SettingRow label="Тема оформления">
<div className="flex rounded-md border border-input overflow-hidden h-8">
{[["light","Светлая"],["dark","Тёмная"],["system","Авто"]].map(([v, l]) => (
<button key={v} onClick={() => setTheme(v)}
className={cn("px-3 text-xs transition-colors border-r last:border-r-0 border-input",
theme === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}>
{l}
</button>
))}
</div>
</SettingRow>
<SettingRow label="Часовой пояс">
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
value={timezone} onChange={e => setTimezone(e.target.value)}>
{["Europe/Moscow","Europe/Berlin","Europe/Amsterdam","Asia/Singapore","UTC"].map(z => (
<option key={z} value={z}>{z}</option>
))}
</select>
</SettingRow>
<SettingRow label="Интервал автообновления (сек)"
description="Как часто страницы обновляют данные">
<Input className="w-20 h-8 text-sm" value={refreshSec} onChange={e => setRefreshSec(e.target.value)} />
</SettingRow>
<SettingRow label="Таймаут диагностики (сек)"
description="Максимальное время ожидания при probe-тестах">
<Input className="w-20 h-8 text-sm" value={probeTimeout} onChange={e => setProbeTimeout(e.target.value)} />
</SettingRow>
</OpsPanel> </OpsPanel>
<OpsPanel <SystemDatabaseSection />
title="База данных приложения"
description="Резервная копия PostgreSQL (pg_dump custom) и сброс таблиц NetFlow. На время операции планировщик и коллектор IPFIX приостанавливаются. Preview: https://reui.io/preview/base/settings-16"
contentClassName="divide-y px-5"
>
{!systemDbAvailable && (
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
<span>Доступно только в live-режиме при доступном бекенде.</span>
</div>
)}
<SettingRow
label="Скачать бэкап"
description="Консистентный дамп PostgreSQL (pg_dump -Fc)"
>
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
onClick={() => { void handleSystemDatabaseBackup() }}
>
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
{dbBackupBusy ? "Подготовка…" : "Скачать"}
</Button>
</SettingRow>
<SettingRow
label="Восстановить из файла"
description="Полностью заменяет текущую базу PostgreSQL (pg_restore)"
>
<div className="flex flex-col items-end gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
onClick={() => setDbRestoreDialogOpen(true)}
>
<UploadIcon className="size-4" />
Выбрать файл
</Button>
{dbRestoreFile && (
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
)}
</div>
</SettingRow>
<SettingRow
label="Сбросить данные NetFlow"
description="Удалит сессии и агрегаты NetFlow в PostgreSQL. Ключи WG и пиры не трогает"
>
<Button
variant="destructive"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
onClick={() => setDbPurgeOpen(true)}
>
{dbPurgeBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <TrashIcon className="size-4" />}
{dbPurgeBusy ? "Сброс…" : "Сбросить"}
</Button>
</SettingRow>
</OpsPanel>
<OpsPanel <OpsPanel id="route-ai" title="Оптимизатор маршрутов" description="Текущие пороги, веса и режим применения доступны на странице оптимизатора." contentClassName="px-5 py-4"><Link href="/route-optimizer" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оптимизатор</Link></OpsPanel>
id="route-ai"
className="scroll-mt-4"
title="Route AI"
description="Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и веса настраиваются в инструменте, не через отдельный API."
contentClassName="flex flex-col gap-4 px-5 pb-5"
>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Мин. выигрыш (переключение)</dt>
<dd className="font-mono tabular-nums">{ra.switchThreshold}%</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Гистерезис</dt>
<dd className="font-mono tabular-nums">{ra.hysteresisThreshold}%</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Вес задержки (ping)</dt>
<dd className="font-mono tabular-nums">{ra.pingWeight}%</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Интервал зондирования</dt>
<dd className="font-mono tabular-nums">{ra.probeIntervalMin} мин</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5 sm:col-span-2">
<dt className="text-muted-foreground">Автоприменение</dt>
<dd>{ra.autoApply ? `да, каждые ${ra.autoApplyIntervalMin} мин` : "нет"}</dd>
</div>
</dl>
<Link
href="/route-optimizer"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
>
Открыть оптимизатор маршрутов
</Link>
</OpsPanel>
</div> </div>
) )
// ── EvoBGP ── if (section === "EvoBGP") return <EvoBgpSettingsSection />
if (section === "EvoBGP") return (
<div className="flex flex-col gap-4">
{(mode !== "live" || backendStatus !== true) && (
<Frame dense className="w-full">
<FramePanel className="px-4 py-4">
<p className="text-sm font-medium">Интеграция доступна в live-режиме</p>
<p className="text-xs text-muted-foreground mt-1">
Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).
</p>
</FramePanel>
</Frame>
)}
<OpsPanel if (section === "Уведомления") return <OpsPanel title="Оповещения" description="Каналы и правила оповещений настраиваются в отдельном разделе." contentClassName="px-5 py-4"><Link href="/alerts" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оповещения</Link></OpsPanel>
title="EvoBGP API" if (section === "API-ключи") return <OpsPanel title="API-ключи" contentClassName="px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Создание и управление отдельными API-ключами в приложении пока недоступны.</p></OpsPanel>
description={ return <OpsPanel title="Безопасность" description="Параметры доступа задаются в сервисе авторизации и конфигурации сервера." contentClassName="flex flex-col gap-3 px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Состояние MFA, ограничений IP и настроек аудита в этом разделе не получено. Изменение этих параметров здесь недоступно.</p><Link href="/users" className={buttonVariants({variant:"outline",size:"sm"})}>Управление пользователями</Link></OpsPanel>
<>
Control plane EvoBGP: Bearer-ключ и роль viewer+ — см.{" "}
<a
className="underline underline-offset-2"
href="https://git.shx.one/denozord/EvoBGP/src/branch/main/docs/access.md"
target="_blank"
rel="noreferrer"
>
access.md
</a>
. URL и ключ хранятся в PostgreSQL на сервере бекенда (
<code className="font-mono bg-muted px-1 rounded">evobgp_settings</code>
). Каталог —{" "}
<code className="font-mono bg-muted px-1 rounded">GET /v1/router-lists/catalog</code> через прокси.
</>
}
contentClassName="divide-y px-5 flex flex-col gap-4 pb-5"
>
<SettingRow
label="Базовый URL API"
description="Например http://control.example:8080 — без суффикса /v1"
>
<Input
className="w-full max-w-md h-8 text-sm font-mono"
value={evoBaseDraft}
onChange={(e) => setEvoBaseDraft(e.target.value)}
placeholder="http://localhost:8080"
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
/>
</SettingRow>
<SettingRow
label="API-ключ"
description={
evo.secretConfigured
? "В БД уже есть ключ — введите новый только для замены"
: "Сохраняется общей кнопкой «Сохранить» в шапке страницы"
}
>
<div className="flex items-center gap-2">
<Input
className="w-full max-w-md h-8 text-sm font-mono"
type={showEvoKey ? "text" : "password"}
autoComplete="off"
value={evoKeyDraft}
onChange={(e) => setEvoKeyDraft(e.target.value)}
placeholder={evo.secretConfigured ? "Оставьте пустым, чтобы не менять" : "Bearer-токен"}
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0"
onClick={() => setShowEvoKey((v) => !v)}
>
{showEvoKey ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</Button>
</div>
</SettingRow>
<SettingRow
label="Подставлять данные EvoBGP"
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
>
<FormToggle
checked={evoEnabledDraft}
onChange={(v) => setEvoEnabledDraft(v)}
/>
</SettingRow>
<div className="flex flex-wrap items-center gap-2 pt-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-destructive border-destructive/40 hover:bg-destructive/10"
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy || !evo.secretConfigured}
onClick={async () => {
setEvoSaveBusy(true)
setEvoSaveErr(null)
try {
await evo.saveSettings({ apiKey: null })
setEvoKeyDraft("")
markSaved()
} catch (e) {
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка")
} finally {
setEvoSaveBusy(false)
}
}}
>
Удалить ключ из БД
</Button>
</div>
{evoSaveErr && <p className="text-xs text-destructive">{evoSaveErr}</p>}
<p className="text-[11px] text-muted-foreground">
«Проверить ключ» использует поля формы; незаполненное поле подставляется из БД. «Обновить каталог» — только по сохранённым в БД настройкам.
</p>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={
mode !== "live" ||
backendStatus !== true ||
evoBusy !== null ||
evoSaveBusy ||
!(
(evoBaseDraft.trim() || evo.baseUrl.trim()) &&
(evoKeyDraft.trim() || evo.secretConfigured)
)
}
onClick={async () => {
setEvoBusy("test")
setEvoTestResult(null)
const b = evoBaseDraft.trim()
const k = evoKeyDraft.trim()
let draft: EvoBgpTestDraft | undefined
if (b || k) {
draft = {}
if (b) draft.baseUrl = b
if (k) draft.apiKey = k
}
const r = await evo.testConnection(draft)
setEvoTestResult({ ok: r.ok, message: r.message })
setEvoBusy(null)
}}
>
{evoBusy === "test" ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
Проверить ключ
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={mode !== "live" || backendStatus !== true || evoBusy !== null || evoSaveBusy || !evo.enabled}
onClick={async () => {
setEvoBusy("refresh")
await evo.refresh()
setEvoBusy(null)
}}
>
{evoBusy === "refresh" || evo.loading ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
Обновить каталог
</Button>
{evo.snapshot?.fetchedAt && (
<span className="text-xs text-muted-foreground">
Загружено: {new Date(evo.snapshot.fetchedAt).toLocaleString("ru-RU")}
</span>
)}
</div>
{evoTestResult && (
<p className={cn(
"text-xs",
evoTestResult.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive",
)}>
{evoTestResult.message}
</p>
)}
{evo.error && (
<p className="text-xs text-destructive">{evo.error}</p>
)}
</OpsPanel>
</div>
)
// ── Уведомления ──
if (section === "Уведомления") return (
<div className="flex flex-col gap-4">
<OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5">
<SettingRow label="Email" description="Отправка уведомлений на [email protected]">
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
</SettingRow>
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="[email protected]" /></div>}
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
<FormToggle checked={notifSlack} onChange={setNotifSlack} />
</SettingRow>
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
<FormToggle checked={notifWh} onChange={setNotifWh} />
</SettingRow>
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
</OpsPanel>
<OpsPanel title="Триггеры" contentClassName="divide-y px-5">
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
</SettingRow>
<SettingRow label="Узел ушёл offline">
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
</SettingRow>
<SettingRow label="Падение BGP-сессии">
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
</SettingRow>
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
</SettingRow>
</OpsPanel>
</div>
)
// ── API-ключи ──
if (section === "API-ключи") return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button>
</div>
<div className="flex flex-col gap-3">
{apiKeys.map(k => (
<Frame key={k.id} dense className="w-full">
<FramePanel className="pt-4 pb-3 px-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">{k.name}</p>
<div className="flex items-center gap-2 mt-1">
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{k.prefix}</code>
<button onClick={() => handleCopy(k.prefix)} className="text-muted-foreground/40 hover:text-muted-foreground transition-colors">
{copied === k.prefix ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
</button>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{k.scopes.map(s => (
<span key={s} className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{s}</span>
))}
</div>
</div>
<div className="text-right shrink-0">
<p className="text-xs text-muted-foreground">Создан: {k.created}</p>
<p className="text-xs text-muted-foreground mt-0.5">Использован: {k.last}</p>
<div className="flex gap-1 justify-end mt-2">
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs">Переиздать</Button>
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"
onClick={() => setApiKeys(p => p.filter(x => x.id !== k.id))}>
<TrashIcon className="size-3.5" />
</Button>
</div>
</div>
</div>
</FramePanel>
</Frame>
))}
</div>
<OpsPanel
title="Документация API"
description="Base URL: https://api.routerlists.io/v1"
contentClassName="px-4 pb-4"
>
<div className="grid grid-cols-2 gap-2 text-xs">
{[
["GET", "/servers", "Список серверов"],
["GET", "/traffic", "Данные трафика"],
["GET", "/filters", "Фильтры маршрутов"],
["POST", "/filters", "Создать фильтр"],
["GET", "/probes", "Диагностические пробы"],
["POST", "/probes/run", "Запустить тест"],
].map(([method, path, desc], i) => (
<div key={`${method}-${path}-${i}`} className="flex items-baseline gap-2">
<span className={cn("font-mono text-[10px] font-bold w-8 shrink-0",
method === "GET" ? "text-emerald-500" : "text-sky-500")}>{method}</span>
<code className="font-mono text-muted-foreground">{path}</code>
<span className="text-muted-foreground/60 text-[10px]">{desc}</span>
</div>
))}
</div>
</OpsPanel>
</div>
)
// ── Безопасность ──
if (section === "Безопасность") return (
<div className="flex flex-col gap-4">
<OpsPanel title="Аутентификация" contentClassName="divide-y px-5">
<SettingRow label="Двухфакторная аутентификация (MFA)"
description="TOTP / Authenticator app для всех администраторов">
<FormToggle checked={mfa} onChange={setMfa} />
</SettingRow>
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
</SettingRow>
</OpsPanel>
<OpsPanel
title="Allowlist IP-адресов"
description="Доступ разрешён только с этих сетей. Одна запись на строку."
contentClassName="px-4 pb-4"
>
<textarea value={ipAllow} onChange={e => setIpAllow(e.target.value)} rows={4}
className="w-full rounded-md border bg-muted px-3 py-2 text-xs font-mono resize-none outline-none focus:ring-1 focus:ring-ring" />
</OpsPanel>
<OpsPanel title="Аудит" contentClassName="divide-y px-5">
<SettingRow label="Расширенный журнал аудита"
description="Записывать все изменения конфигурации с указанием пользователя и IP">
<FormToggle checked={auditLog} onChange={setAuditLog} />
</SettingRow>
</OpsPanel>
<OpsPanel title={<span className="text-destructive">Опасная зона</span>} contentClassName="px-4 pb-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Сбросить все настройки</p>
<p className="text-xs text-muted-foreground">Удалит пользовательские параметры и вернёт заводские значения</p>
</div>
<Button variant="outline" size="sm" className="border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground">
Сбросить
</Button>
</div>
</OpsPanel>
</div>
)
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Система" }, { label: "Настройки" }]} crumbs={[{ label: "Система" }, { label: "Настройки" }]}
actions={
<Button
size="sm"
onClick={() => { void handleSave() }}
disabled={
section === "EvoBGP" &&
(evoSaveBusy || mode !== "live" || backendStatus !== true)
}
>
{section === "EvoBGP" && evoSaveBusy ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : saved ? (
<CheckIcon className="size-4" />
) : (
<SaveIcon className="size-4" />
)}
{section === "EvoBGP" && evoSaveBusy
? "Сохранение…"
: saved
? "Сохранено!"
: "Сохранить"}
</Button>
}
/> />
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="max-w-[960px] mx-auto grid grid-cols-[180px_1fr] gap-6"> <div className="max-w-[960px] mx-auto grid grid-cols-1 md:grid-cols-[180px_minmax(0,1fr)] gap-6">
{/* nav */} {/* nav */}
<nav className="flex flex-col gap-1 self-start sticky top-0"> <nav className="flex flex-wrap md:flex-col gap-1 self-start md:sticky md:top-0">
{SECTIONS_NAV.map(s => ( {SECTIONS_NAV.map(s => (
<button key={s} onClick={() => setSection(s)} <button key={s} aria-current={s===section?"page":undefined} onClick={() => setSection(s)}
className={cn( className={cn(
"text-left px-3 py-2 rounded-md text-sm transition-colors", "text-left px-3 py-2 rounded-md text-sm transition-colors",
s === section ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground hover:text-foreground", s === section ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground hover:text-foreground",
@@ -912,40 +178,18 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* delete confirm */}
{dbRestoreFile && (
<DatabaseRestoreConfirm
open={!!dbRestoreFile}
filename={dbRestoreFile.name}
busy={dbRestoreBusy}
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
onCancel={() => {
if (dbRestoreBusy) return
setDbRestoreFile(null)
}}
/>
)}
<NetflowPurgeConfirm
open={dbPurgeOpen}
busy={dbPurgeBusy}
onConfirm={() => { void handleNetflowPurgeConfirm() }}
onCancel={() => { if (!dbPurgeBusy) setDbPurgeOpen(false) }}
/>
<FileImportDialog
open={dbRestoreDialogOpen}
onOpenChange={setDbRestoreDialogOpen}
title="Восстановление базы данных"
description="Выберите файл pg_dump custom (.dump) — текущая база будет полностью заменена"
accept=".dump,.backup,application/octet-stream"
onImport={async (files) => {
const file = files[0]
if (!file) return
setDbRestoreFile(file)
}}
/>
</div> </div>
) )
} }
function BackendUrlField(){
const {backendUrl,setBackendUrl,backendUrlLocked}=useDataSource()
const [draft,setDraft]=useState(backendUrl),[error,setError]=useState<string|null>(null)
function commit(){
if(backendUrlLocked||draft===backendUrl)return
try{const url=new URL(draft.trim());if(!["http:","https:"].includes(url.protocol)||url.username||url.password||url.search||url.hash)throw Error();setBackendUrl(url.toString().replace(/\/$/,""));setError(null)}
catch{setError("Укажите HTTP(S) адрес без учётных данных и параметров")}
}
return <div className="flex min-w-0 flex-col gap-2"><Input aria-label="URL бекенда" className="w-full sm:w-64 font-mono" value={draft} onChange={e=>{setDraft(e.target.value);setError(null)}} onBlur={commit} onKeyDown={e=>{if(e.key==="Enter"){e.preventDefault();commit()}}} readOnly={backendUrlLocked} aria-invalid={!!error} placeholder="http://localhost:8000"/>{error?<p role="alert" className="text-xs text-destructive">{error}</p>:null}</div>
}
+531
View File
@@ -0,0 +1,531 @@
"use client"
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
import { useSearchParams } from "next/navigation"
import {
ActivityIcon,
DatabaseIcon,
GaugeIcon,
ServerIcon,
UsersIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { PageHeader } from "@/components/page-header"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { SegmentedControl } from "@/components/form-kit"
import { EmptyState } from "@/components/empty-state"
import { PeriodSelector, rangeForPreset, type DateRangeYmd } from "@/components/statistics/period-selector"
import { StatisticsVolumeChart } from "@/components/statistics/statistics-volume-chart"
import { DimensionSelect, PivotDimSelect } from "@/components/statistics/dimension-select"
import { SliceChips } from "@/components/statistics/slice-chips"
import { BreakdownDashboard } from "@/components/statistics/breakdown-dashboard"
import { StatisticsPivotGrid } from "@/components/statistics/statistics-pivot-grid"
import {
StatisticsBreakdownDataGrid,
type StatisticsSliceKind,
} from "@/components/data-grids/statistics-breakdown-data-grid"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { StatisticsFilters } from "@/components/statistics/statistics-filters"
import {
isStatisticsPivotDim,
isStatisticsSliceKind,
STATISTICS_DIMS,
} from "@/lib/statistics-dims"
import { statisticsContextError, STATISTICS_CONTEXT_KEYS } from "@/lib/statistics-context"
import { readStatisticsRange } from "@/lib/statistics-range"
import { useDataSource } from "@/lib/data-source"
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
import {
getStatistics,
getStatisticsPivot,
STATISTICS_UNBOUND_USER_ID,
type StatisticsDto,
type StatisticsPivotDto,
type StatisticsQuery,
} from "@/shared/api/statistics"
import type { StatisticsBreakdownRow, StatisticsPivotDim } from "@mmapp/contracts/statistics"
/**
* BI-куб трафика: критерий → остальные разрезы + pivot.
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
* · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/solution-analytics-8
* · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
*/
const EMPTY: StatisticsDto = {
from: "",
to: "",
grain: "day",
kpis: {
bytes: 0,
packets: 0,
avgBps: 0,
users: 0,
servers: 0,
ifaces: 0,
topCountry: "",
topService: "",
},
series: [],
users: [],
servers: [],
interfaces: [],
countries: [],
services: [],
asns: [],
}
const EMPTY_PIVOT: StatisticsPivotDto = {
rowDim: "country",
colDim: "service",
metric: "bytes",
columns: [],
rows: [],
otherBytes: 0,
}
interface CubeSlices {
country?: string
service?: string
asn?: string
serverId?: string
userId?: string
iface?: string
}
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
function readDim(sp: URLSearchParams): StatisticsSliceKind {
const t = sp.get("dim") ?? sp.get("tab")
return t && isStatisticsSliceKind(t) ? t : "users"
}
function readView(sp: URLSearchParams): "explore" | "pivot" {
return sp.get("view") === "pivot" ? "pivot" : "explore"
}
function readPlanes(sp: URLSearchParams): "unique" | "all" {
return sp.get("planes") === "all" ? "all" : "unique"
}
function readPivotDim(sp: URLSearchParams, key: string, fallback: StatisticsPivotDim): StatisticsPivotDim {
const v = sp.get(key)
return v && isStatisticsPivotDim(v) ? v : fallback
}
function readSlices(sp: URLSearchParams): CubeSlices {
const next: CubeSlices = {}
for (const key of SLICE_KEYS) {
const v = sp.get(key)?.trim()
if (v) next[key] = v
}
return next
}
function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
const serverId = slices.serverId ? Number(slices.serverId) : undefined
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
return {
from: range.from,
to: range.to,
serverId: Number.isFinite(serverId) && (serverId ?? 0) > 0 ? serverId : undefined,
userId: slices.userId,
iface: slices.iface,
country: slices.country && slices.country.length === 2 ? slices.country : undefined,
service: slices.service,
asn: Number.isFinite(asn) ? asn : undefined,
planes,
}
}
function selectedIdForKind(kind: StatisticsSliceKind, slices: CubeSlices): string | undefined {
if (kind === "users") return slices.userId
if (kind === "servers") return slices.serverId
if (kind === "countries") return slices.country
if (kind === "services") return slices.service
if (kind === "asns") return slices.asn
if (kind === "interfaces" && slices.serverId && slices.iface) {
return `${slices.serverId}:${slices.iface}`
}
if (kind === "interfaces") return slices.iface
return undefined
}
function rowsForKind(data: StatisticsDto, kind: StatisticsSliceKind) {
if (kind === "users") return data.users
if (kind === "servers") return data.servers
if (kind === "interfaces") return data.interfaces
if (kind === "countries") return data.countries
if (kind === "services") return data.services
return data.asns
}
function hasAnySlice(slices: CubeSlices): boolean {
return SLICE_KEYS.some((k) => Boolean(slices[k]))
}
function hiddenKinds(slices: CubeSlices): Set<StatisticsSliceKind> {
const hidden = new Set<StatisticsSliceKind>()
if (slices.userId) hidden.add("users")
if (slices.serverId) hidden.add("servers")
if (slices.iface) hidden.add("interfaces")
if (slices.country) hidden.add("countries")
if (slices.service) hidden.add("services")
if (slices.asn) hidden.add("asns")
return hidden
}
function applyDimValue(slices: CubeSlices, kind: StatisticsSliceKind, rowId: string): CubeSlices {
const next: CubeSlices = { ...slices }
if (kind === "users") {
if (rowId === STATISTICS_UNBOUND_USER_ID) return next
if (next.userId === rowId) delete next.userId
else next.userId = rowId
} else if (kind === "servers") {
if (next.serverId === rowId) delete next.serverId
else next.serverId = rowId
} else if (kind === "countries") {
if (next.country === rowId) delete next.country
else next.country = rowId
} else if (kind === "services") {
if (next.service === rowId) delete next.service
else next.service = rowId
} else if (kind === "asns") {
if (next.asn === rowId) delete next.asn
else next.asn = rowId
} else {
const colon = rowId.indexOf(":")
const sid = colon >= 0 ? rowId.slice(0, colon) : undefined
const iface = colon >= 0 ? rowId.slice(colon + 1) : rowId
if (next.iface === iface && next.serverId === sid) {
delete next.iface
delete next.serverId
} else {
next.iface = iface
if (sid) next.serverId = sid
}
}
return next
}
function applyPivotDim(slices: CubeSlices, dim: StatisticsPivotDim, id: string): CubeSlices {
const kind = STATISTICS_DIMS.find((d) => d.pivot === dim)?.id ?? "users"
return applyDimValue(slices, kind, id)
}
function chipList(slices: CubeSlices): { key: string; label: string }[] {
const chips: { key: string; label: string }[] = []
if (slices.country) chips.push({ key: "country", label: `страна ${slices.country}` })
if (slices.service) chips.push({ key: "service", label: `сервис ${slices.service}` })
if (slices.asn) chips.push({ key: "asn", label: `ASN ${slices.asn}` })
if (slices.serverId) chips.push({ key: "serverId", label: `сервер ${slices.serverId}` })
if (slices.userId) chips.push({ key: "userId", label: `пользователь ${slices.userId}` })
if (slices.iface) chips.push({ key: "iface", label: `iface ${slices.iface}` })
return chips
}
function StatisticsPageInner() {
const searchParams = useSearchParams()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live"
const { range, error: rangeError } = useMemo(() => readStatisticsRange(searchParams, rangeForPreset("7d")), [searchParams])
const paramsError = useMemo(() => statisticsContextError(searchParams), [searchParams])
const slices = useMemo(() => readSlices(searchParams), [searchParams])
const dim = useMemo(() => readDim(searchParams), [searchParams])
const view = useMemo(() => readView(searchParams), [searchParams])
const planes = useMemo(() => readPlanes(searchParams), [searchParams])
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
const [retry, setRetry] = useState(0)
const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry, rangeError, paramsError })
const [result, setResult] = useState<{
key: string; data: StatisticsDto; pivot: StatisticsPivotDto; error: string | null
} | null>(null)
const current = result?.key === requestKey ? result : null
const loading = isLive && !rangeError && !paramsError && current === null
const error = rangeError ?? paramsError ?? current?.error ?? null
const dataReady = isLive && current !== null && !error
const pivot = dataReady ? current.pivot : EMPTY_PIVOT
const updateParams = useCallback(
(patch: Record<string, string | undefined>) => {
const sp = new URLSearchParams(window.location.search)
for (const [k, v] of Object.entries(patch)) {
if (v) sp.set(k, v)
else sp.delete(k)
}
const qs = sp.toString()
if (qs === searchParams.toString()) return
window.history.pushState(null, "", (qs ? `/statistics?${qs}` : "/statistics") + window.location.hash)
},
[searchParams],
)
const setRange = useCallback(
(next: DateRangeYmd) => {
updateParams({ from: next.from, to: next.to })
},
[updateParams],
)
const setSlices = useCallback(
(next: CubeSlices) => {
updateParams({
country: next.country,
service: next.service,
asn: next.asn,
serverId: next.serverId,
userId: next.userId,
iface: next.iface,
})
},
[updateParams],
)
useEffect(() => {
if (!prefsHydrated || !isLive || rangeError || paramsError) return
let cancelled = false
void (async () => {
try {
const query = toQuery(range, slices, planes)
const dto = await getStatistics(backendUrl, query)
if (cancelled) return
let matrix = EMPTY_PIVOT
if (view === "pivot" && pivotRow !== pivotCol) {
matrix = await getStatisticsPivot(backendUrl, {
...query,
row: pivotRow,
col: pivotCol,
metric: "bytes",
})
}
if (!cancelled) setResult({ key: requestKey, data: dto, pivot: matrix, error: null })
} catch (e: unknown) {
if (!cancelled) {
setResult({ key: requestKey, data: EMPTY, pivot: EMPTY_PIVOT, error: e instanceof Error ? e.message : "Не удалось загрузить статистику" })
}
}
})()
return () => {
cancelled = true
}
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey, rangeError, paramsError])
const viewData = dataReady ? current.data : EMPTY
const sliced = hasAnySlice(slices)
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) {
if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return
if (kind === "interfaces" && row.label.includes("· дубль")) return
setSlices(applyDimValue(slices, kind, row.id))
}
function handlePivotCell(rowId: string, colId: string) {
if (rowId === "__other__" || colId === "__other__") return
let next = applyPivotDim(slices, pivotRow, rowId)
next = applyPivotDim(next, pivotCol, colId)
updateParams({
country: next.country,
service: next.service,
asn: next.asn,
serverId: next.serverId,
userId: next.userId,
iface: next.iface,
view: "explore",
})
}
const kpis = viewData.kpis
const chips = chipList(slices)
const countLabel =
view === "pivot"
? `${pivot.rows.length} × ${pivot.columns.length}`
: sliced
? `${STATISTICS_DIMS.filter((d) => !hiddenKinds(slices).has(d.id)).length} разрезов`
: `${rowsForKind(viewData, dim).length} строк`
return (
<div className="flex h-full flex-col">
<PageHeader
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
actions={rangeError ? <Button type="button" variant="outline" onClick={() => setRange(rangeForPreset("7d"))}>Выбрать последние 7 дней</Button> : <PeriodSelector range={range} onChange={setRange} />}
/>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
{!isLive ? (
<Alert>
<AlertTitle>Живые данные выключены</AlertTitle>
<AlertDescription>
Куб статистики строится из IPFIX. Переключитесь на живой источник, чтобы увидеть отчёт.
</AlertDescription>
</Alert>
) : null}
{error ? (
<Alert variant="destructive" role="alert">
<AlertTitle>{rangeError ? "Некорректный период" : paramsError ? "Параметры отчёта требуют внимания" : "Ошибка загрузки"}</AlertTitle>
<AlertDescription>{error}{!rangeError && !paramsError && <Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button>}{paramsError && <Button type="button" variant="outline" size="sm" onClick={() => updateParams(Object.fromEntries(STATISTICS_CONTEXT_KEYS.map(key => [key, undefined])))}>Сбросить параметры отчёта</Button>}</AlertDescription>
</Alert>
) : null}
{!slices.serverId && dataReady && !emptyCube ? (
<Alert>
<AlertTitle>Уникальный объём</AlertTitle>
<AlertDescription>
Объём — трафик клиентов на GRE/WG, без повторного учёта JH↔EN и WAN.
</AlertDescription>
</Alert>
) : null}
{(dataReady || loading) && <KpiStatGrid
aria-label="Сводка трафика"
isLoading={loading}
skeletonCount={5}
items={[
{
id: "bytes",
label: "Объём",
value: formatBytes(kpis.bytes),
hint: "GRE/WG клиентов, без hops",
icon: <DatabaseIcon />,
iconClassName: "text-muted-foreground",
},
{
id: "packets",
label: "Пакеты",
value: kpis.packets.toLocaleString("ru-RU"),
hint: kpis.topService ? `топ: ${kpis.topService}` : undefined,
icon: <ActivityIcon />,
iconClassName: "text-muted-foreground",
},
{
id: "bps",
label: "Средний bitrate",
value: fmtBps(kpis.avgBps),
icon: <GaugeIcon />,
iconClassName: "text-muted-foreground",
},
{
id: "users",
label: "Пользователи",
value: String(kpis.users),
icon: <UsersIcon />,
iconClassName: "text-muted-foreground",
},
{
id: "servers",
label: "Серверы",
value: String(kpis.servers),
hint: slices.serverId
? (kpis.ifaces ? `${kpis.ifaces} iface` : undefined)
: planes === "all"
? "WAN и дубли в списке"
: "без WAN и overlay",
icon: <ServerIcon />,
iconClassName: "text-muted-foreground",
},
]}
/>}
{dataReady && <StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />}
<DataPageCard>
<DataPageToolbar
leading={
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl
value={view}
onChange={(next) => updateParams({ view: next === "pivot" ? "pivot" : "explore" })}
options={[
{ value: "explore", label: "Разрез" },
{ value: "pivot", label: "Сводка" },
]}
/>
<SegmentedControl
value={planes}
onChange={(next) => updateParams({ planes: next === "all" ? "all" : undefined })}
options={[
{ value: "unique", label: "Уникальный" },
{ value: "all", label: "Все плоскости" },
]}
/>
{view === "explore" && !sliced ? (
<DimensionSelect
label="Критерий"
value={dim}
onChange={(next) => updateParams({ dim: next, tab: undefined })}
/>
) : null}
{view === "pivot" ? (
<>
<PivotDimSelect
label="Строки"
value={pivotRow}
exclude={pivotCol}
onChange={(next) => updateParams({ pivotRow: next })}
/>
<PivotDimSelect
label="Колонки"
value={pivotCol}
exclude={pivotRow}
onChange={(next) => updateParams({ pivotCol: next })}
/>
</>
) : null}
</div>
}
filterControl={<StatisticsFilters key={JSON.stringify(slices)} slices={slices} onApply={setSlices} />}
countLabel={dataReady ? countLabel : undefined}
/>
<SliceChips
chips={chips}
onRemove={(key) => {
const next = { ...slices }
delete next[key as keyof CubeSlices]
setSlices(next)
}}
/>
{isLive && !dataReady ? (
<div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : rangeError || paramsError ? "Исправьте параметры отчёта для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}</div>
) : emptyCube ? (
<EmptyState
title="Нет данных куба"
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
/>
) : view === "pivot" ? (
<StatisticsPivotGrid data={isLive ? pivot : EMPTY_PIVOT} onCellClick={handlePivotCell} isLoading={loading} />
) : sliced ? (
<BreakdownDashboard
data={viewData}
hidden={hiddenKinds(slices)}
selectedIdFor={(kind) => selectedIdForKind(kind, slices)}
onRowClick={handleRowClick}
isLoading={loading}
/>
) : (
<StatisticsBreakdownDataGrid
rows={rowsForKind(viewData, dim)}
kind={dim}
selectedId={selectedIdForKind(dim, slices)}
onRowClick={(row) => handleRowClick(dim, row)}
isLoading={loading}
/>
)}
</DataPageCard>
</div>
</div>
)
}
export default function StatisticsPage() {
return (
<Suspense fallback={null}>
<StatisticsPageInner />
</Suspense>
)
}
+111 -421
View File
@@ -1,27 +1,30 @@
"use client" "use client"
import { useState, useRef, useEffect, useCallback, useMemo } from "react" import { useState, useRef, useEffect, Suspense } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { servers as mockServers } from "@/lib/data" import { servers as mockServers } from "@/lib/data"
import type { ServerStatus } from "@/lib/data" import {useSearchParams} from "next/navigation"
import {can} from "@/lib/auth"
import {terminalServersSchema,readTerminalOutput,readTerminalUrl,patchTerminalUrl} from "@/lib/terminal-response"
import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert"
import {Input} from "@/components/ui/input"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { Badge } from "@/components/reui/badge"
import { import {
Frame, Frame,
FrameDescription,
FrameFooter, FrameFooter,
FrameHeader, FrameHeader,
FramePanel, FramePanel,
FrameTitle, FrameTitle,
} from "@/components/reui/frame" } from "@/components/reui/frame"
import { ScrollArea } from "@/components/ui/scroll-area"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { import {
type ServerTileItem, TrashIcon, RefreshCwIcon,
} from "@/components/server-tile-rail"
import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -171,231 +174,72 @@ function mockResolve(raw: string, serverName: string): string {
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)` return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
} }
// ─── MOTD builders ────────────────────────────────────────────────────────────
function mockMotd(server: TermServer): string { function Terminal({server,isLive,backendUrl,canExecute,blockedReason}:{server:TermServer;isLive:boolean;backendUrl:string;canExecute:boolean;blockedReason?:string}) {
return [ const [lines,setLines]=useState<TermLine[]>([{id:0,kind:"info",text:isLive?"REST: каждая команда — отдельный запрос. Результат появится после ответа API.":"Демо: команды возвращают синтетические данные."}])
"", const [input,setInput]=useState(""),[history,setHistory]=useState<string[]>([]),[histIdx,setHistIdx]=useState(-1),[executing,setExecuting]=useState(false),[error,setError]=useState<string|null>(null),[completed,setCompleted]=useState<string|null>(null)
" MMM MMM KKK TTTTTTTTTTT KKK", const nextId=useRef(1),lock=useRef(false),reader=useRef<{alive:boolean;controller:AbortController|null}>({alive:true,controller:null})
" MMM MMMM MMM III KKK KKK RRR OOOOOO TTT TTT II KKK KKK", const inputRef=useRef<HTMLInputElement>(null),scrollRef=useRef<HTMLDivElement>(null),followOutput=useRef(true),pendingFocus=useRef(false)
" MMM MMM III KKK KKK RRR RRROOOOO TTT TTT II KKK KKK", useEffect(()=>{const state=reader.current;state.alive=true;return()=>{state.alive=false;state.controller?.abort()}},[])
"", useEffect(()=>{if(followOutput.current)scrollRef.current?.scrollTo({top:scrollRef.current.scrollHeight,behavior:"auto"})},[lines])
` MikroTik RouterOS ${server.rosVersion ?? "7.14.3"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`, // Focus only after React has committed the re-enabled input.
"", useEffect(()=>{
"[?] Gives the list of available commands", if(executing||!pendingFocus.current)return
"[Tab] Completes the command/word", pendingFocus.current=false
"[/] Move up to base level", if(reader.current.alive&&!inputRef.current?.disabled)inputRef.current?.focus()
"[..] Move up one level", },[executing,lines])
"", function append(kind:TermLine["kind"],text:string) {
].join("\n") const added=text.split("\n").map(text=>({id:nextId.current++,kind,text}))
} setLines(previous=>{const merged=[...previous.filter(line=>line.id!==-1),...added];return merged.length>1000||previous.some(line=>line.id===-1)?[{id:-1,kind:"info",text:"Показаны последние 1000 строк; ранний вывод удалён."},...merged.slice(-1000)]:merged})
}
function liveMotd(server: TermServer): string { async function submit(){
return [ const command=input.trim();if(lock.current||!command||!canExecute||!server.enabled||blockedReason)return
"", if(command.toLowerCase()==="clear"||command.toLowerCase()==="/clear"){setLines([]);setInput("");return}
` MikroTik RouterOS ${server.rosVersion ?? "7.x"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`, lock.current=true;setExecuting(true);setError(null);setCompleted(null);setHistIdx(-1)
"", append("prompt",server.name+" > "+command)
` Connected to ${server.name} (${server.host})`, setHistory(previous=>[command,...previous.filter(item=>item!==command)].slice(0,100))
"", const controller=new AbortController();reader.current.controller=controller
"[?] Gives the list of available commands — type help or ?", let timedOut=false;const timer=window.setTimeout(()=>{timedOut=true;controller.abort()},60000)
"[Tab] Completes the command/word", try {
"[/] Move up to base level", const output=isLive?readTerminalOutput(await requestJson<unknown>(backendUrl,"/api/servers/"+server.backendId+"/exec",{method:"POST",body:JSON.stringify({command}),signal:controller.signal})):mockResolve(command,server.name)
"", if(!reader.current.alive)return
].join("\n") if(output==="__CLEAR__")setLines([]);else append("output",output||"Пустой вывод.")
} setInput("");setCompleted((isLive?"Ответ API получен":"Результат демо")+" · "+new Date().toLocaleTimeString("ru-RU"));followOutput.current=true
} catch(err) {
// ─── terminal component ─────────────────────────────────────────────────────── if(!reader.current.alive)return
const message=timedOut?"Время ожидания истекло. Результат запроса не подтверждён.":err instanceof Error?err.message:"Не удалось получить вывод команды."
function Terminal({ append("error",message);setError(message)
server, } finally {
isLive, window.clearTimeout(timer);lock.current=false
backendUrl, if(reader.current.alive){pendingFocus.current=true;setExecuting(false);reader.current.controller=null}
}: {
server: TermServer
isLive: boolean
backendUrl: string
}) {
const [lines, setLines] = useState<TermLine[]>([])
const [input, setInput] = useState("")
const [history, setHistory] = useState<string[]>([])
const [_histIdx, setHistIdx] = useState(-1)
const [_idCtr, setIdCtr] = useState(0)
const [executing, setExecuting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const nextId = useCallback((): number => {
let id = 0
setIdCtr(prev => { id = prev + 1; return prev + 1 })
return id
}, [])
// Initialize MOTD on mount / server change
useEffect(() => {
const motd = isLive ? liveMotd(server) : mockMotd(server)
const init = motd.split("\n").map((text, i) => ({ id: i, kind: "output" as const, text }))
setLines(init)
setIdCtr(init.length)
setInput("")
setHistory([])
setHistIdx(-1)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [server.uid, isLive])
// Auto-scroll
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
}, [lines])
const prompt = `[admin@${server.identityName ?? server.name}] > `
const submit = useCallback(async () => {
if (executing) return
const cmd = input.trim()
setLines(prev => [...prev, { id: nextId(), kind: "prompt", text: prompt + input }])
setInput("")
setHistIdx(-1)
if (!cmd) return
setHistory(h => [cmd, ...h.filter(x => x !== cmd)].slice(0, 100))
// local always-available commands
const lower = cmd.toLowerCase()
if (lower === "clear" || lower === "/clear") { setLines([]); return }
if (lower === "quit" || lower === "exit") {
setLines(prev => [...prev, { id: nextId(), kind: "info", text: "Connection closed." }])
return
}
if (isLive && server.backendId !== null) {
setExecuting(true)
try {
const data = await requestJson<{ output?: string; error?: string }>(
backendUrl,
`/api/servers/${server.backendId}/exec`,
{
method: "POST",
body: JSON.stringify({ command: cmd }),
},
)
const text = data.output ?? data.error ?? "(empty response)"
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
text.split("\n").forEach(line =>
setLines(prev => [...prev, { id: nextId(), kind, text: line }])
)
} catch (err) {
setLines(prev => [...prev, { id: nextId(), kind: "error", text: `network error: ${String(err)}` }])
} finally {
setExecuting(false)
}
} else {
// mock mode
const result = mockResolve(cmd, server.name)
if (result === "__CLEAR__") { setLines([]); return }
if (result) {
result.split("\n").forEach(line =>
setLines(prev => [...prev, { id: nextId(), kind: "output", text: line }])
)
}
}
}, [executing, input, nextId, prompt, isLive, server, backendUrl])
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault(); void submit()
} else if (e.key === "ArrowUp") {
e.preventDefault()
setHistIdx(i => {
const next = Math.min(i + 1, history.length - 1)
if (history[next] !== undefined) setInput(history[next])
return next
})
} else if (e.key === "ArrowDown") {
e.preventDefault()
setHistIdx(i => {
const next = i - 1
if (next < 0) { setInput(""); return -1 }
if (history[next] !== undefined) setInput(history[next])
return next
})
} else if (e.key === "l" && e.ctrlKey) {
e.preventDefault(); setLines([])
}
} }
}
const lineColor = (kind: TermLine["kind"]) => { function handleKey(e:React.KeyboardEvent<HTMLInputElement>){
if (kind === "error") return "text-red-400" if(e.key==="Enter"){e.preventDefault();void submit()}
if (kind === "prompt") return "text-emerald-400" else if(e.key==="ArrowUp"){e.preventDefault();const index=Math.min(histIdx+1,history.length-1);setHistIdx(index);if(history[index]!==undefined)setInput(history[index])}
if (kind === "info") return "text-sky-400" else if(e.key==="ArrowDown"){e.preventDefault();const index=histIdx-1;setHistIdx(Math.max(-1,index));setInput(index<0?"":history[index]??"")}
return "text-[#c9d1d9]" else if(e.key.toLowerCase()==="l"&&e.ctrlKey){e.preventDefault();setLines([])}
} }
const blocked=blockedReason??(!server.enabled?"Устройство отключено в каталоге.":!canExecute?"Для выполнения требуется mm:servers:write.":null)
return ( return <Frame spacing="sm" className="h-full min-h-0 min-w-0">
<div <FramePanel className="flex flex-1 flex-col min-h-0 min-w-0 p-0">
className="flex flex-col bg-[#0d1117] rounded-lg border border-[#30363d] overflow-hidden font-mono text-xs h-full" <FrameHeader className="border-b shrink-0 flex-row items-start justify-between gap-2">
onClick={() => inputRef.current?.focus()} <div className="min-w-0"><FrameTitle className="[overflow-wrap:anywhere]">{server.name}</FrameTitle><FrameDescription className="[overflow-wrap:anywhere]">{server.host} · {isLive?"RouterOS REST":"Демо"} · версия {server.rosVersion??"неизвестна"}</FrameDescription></div>
> <Button type="button" variant="ghost" size="icon-sm" aria-label="Очистить вывод терминала" onClick={()=>setLines([])}><TrashIcon aria-hidden="true" className="size-4"/></Button>
{/* title bar */} </FrameHeader>
<div className="flex items-center gap-2 px-3 py-2 border-b border-[#30363d] bg-[#161b22] shrink-0"> <div ref={scrollRef} role="region" aria-label="Вывод терминала" tabIndex={0} onScroll={e=>{const el=e.currentTarget;followOutput.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}} className="flex-1 min-h-0 min-w-0 overflow-auto p-3 font-mono text-xs focus-visible:outline-2 focus-visible:outline-ring">
<CircleIcon className="size-3 text-red-500 fill-red-500" /> {lines.length?lines.map(line=><div key={line.id} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="error"?"text-destructive":line.kind==="prompt"?"text-primary":line.kind==="info"?"text-muted-foreground":"text-foreground")}>{line.text}</div>):<p className="text-muted-foreground">Вывод очищен. Следующая команда добавит новый результат.</p>}
<CircleIcon className="size-3 text-amber-400 fill-amber-400" /> </div>
<CircleIcon className="size-3 text-emerald-500 fill-emerald-500" /> <FrameFooter className="shrink-0 border-t flex flex-col items-stretch gap-2">
<span className="mx-auto text-[#8b949e] text-[11px]"> <p role="status" className="text-xs text-muted-foreground">{executing?"Выполняем команду; ожидаем ответ…":completed??"Команда ещё не выполнена."}</p>
{server.name} — {isLive ? "RouterOS REST" : "SSH Terminal (mock)"} {error&&<p role="alert" className="text-xs text-destructive [overflow-wrap:anywhere]">{error} Команда сохранена для повторного выполнения.</p>}
</span> {blocked&&<p role="status" className="text-xs text-muted-foreground">{blocked}</p>}
{isLive && ( <label className="text-xs" htmlFor="terminal-command">Команда · {server.name}</label>
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium border border-emerald-500/30 bg-emerald-500/10 text-emerald-400"> <div className="flex gap-2 min-w-0"><Input ref={inputRef} id="terminal-command" value={input} maxLength={1024} onChange={e=>setInput(e.target.value)} onKeyDown={handleKey} disabled={executing||!!blocked} className="flex-1 min-w-0 font-mono text-xs" autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false}/><Button type="button" size="sm" disabled={executing||!!blocked||!input.trim()} onClick={()=>void submit()}>{executing?"Ожидание…":"Выполнить"}</Button></div>
<span className="size-1 rounded-full bg-emerald-400" />LIVE <div className="flex flex-col sm:flex-row gap-2 text-xs text-muted-foreground"><select aria-label="Быстрая команда" value="" disabled={executing||!!blocked} className="min-w-0 rounded-md border bg-background px-2 py-1" onChange={e=>{setInput(e.target.value);setHistIdx(-1);inputRef.current?.focus()}}><option value="">Подставить команду…</option>{QUICK_CMDS.map(item=><option key={item.cmd} value={item.cmd}>{item.label}</option>)}</select><p>↑↓ — история · Ctrl+L — очистить · до 1024 символов</p></div>
</span> </FrameFooter>
)} </FramePanel>
<button </Frame>
onClick={e => { e.stopPropagation(); setLines([]) }}
className="text-[#8b949e] hover:text-white transition-colors ml-1"
title="Очистить"
>
<TrashIcon className="size-3.5" />
</button>
</div>
{/* output */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-0.5 min-h-0">
{lines.map((line, i) => (
<div key={`${line.id}-${i}`} className={cn("whitespace-pre-wrap break-all leading-5", lineColor(line.kind))}>
{line.text}
</div>
))}
{/* executing indicator */}
{executing && (
<div className="flex items-center gap-2 text-amber-400 opacity-80">
<Loader2Icon className="size-3 animate-spin" />
<span>executing…</span>
</div>
)}
{/* input line */}
{!executing && (
<div className="flex items-center gap-0">
<span className="text-emerald-400 select-none shrink-0">{prompt}</span>
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKey}
className="terminal-input-active flex-1 bg-transparent outline-none text-[#c9d1d9] caret-emerald-400"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
</div>
)}
</div>
</div>
)
} }
// ─── quick commands ─────────────────────────────────────────────────────────── // ─── quick commands ───────────────────────────────────────────────────────────
@@ -423,201 +267,47 @@ function mockServersToTermServers(): TermServer[] {
name: s.name, name: s.name,
host: s.host, host: s.host,
country: s.country, country: s.country,
status: s.status as TermServer["status"], status: s.status==="unknown"?null:s.status,
enabled: s.enabled, enabled: s.enabled,
rosVersion: s.os ?? null, rosVersion: s.os ?? null,
identityName: s.name, identityName: s.name,
})) }))
} }
/** Backend ServerRead shape (only fields we need) */
interface BackendServer { export default function TerminalPage(){
id: number return <Suspense fallback={<p role="status" className="p-4">Загружаем терминал…</p>}><TerminalRoute/></Suspense>
name: string
host: string
country: string
status: "online" | "offline" | null
enabled: boolean
os: string | null
identityName: string | null
} }
function TerminalRoute(){
export default function TerminalPage() { const {mode,backendUrl,prefsHydrated}=useDataSource(),params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
const { mode, backendUrl, prefsHydrated } = useDataSource() function reset(){const href=patchTerminalUrl(new URLSearchParams(params.toString()),null)+window.location.hash;window.history.replaceState(null,"",href)}
const isLive = mode === "live" if(url.error)return <Alert variant="destructive"><AlertTitle>Некорректная ссылка терминала</AlertTitle><AlertDescription>{url.error}<Button type="button" variant="outline" onClick={reset}>Сбросить контекст терминала</Button></AlertDescription></Alert>
if(!prefsHydrated)return <p role="status" className="p-4">Загружаем источник…</p>
// Server list state return <TerminalContent key={JSON.stringify([mode,backendUrl])}/>
const [liveServers, setLiveServers] = useState<TermServer[]>([]) }
const [serversLoading, setServersLoading] = useState(false) function TerminalContent(){
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect const {mode,backendUrl}=useDataSource(),isLive=mode==="live",params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
const [liveServers,setLiveServers]=useState<TermServer[]>([]),[loading,setLoading]=useState(false),[error,setError]=useState<string|null>(null),[loaded,setLoaded]=useState(false),[readAt,setReadAt]=useState<string|null>(null),[refresh,setRefresh]=useState(0)
// Load servers from backend when in live mode const canRead=can("mm:servers:read"),canExecute=!isLive||can("mm:servers:write")
useEffect(() => { useEffect(()=>{
if (!isLive || !prefsHydrated) return if(!isLive||!canRead)return
let cancelled = false let cancelled=false;const controller=new AbortController()
queueMicrotask(() => { queueMicrotask(()=>{if(cancelled)return;setLoading(true);void requestJson<unknown>(backendUrl,"/api/servers",{signal:controller.signal}).then(raw=>{
if (cancelled) return const rows=terminalServersSchema.parse(raw);if(cancelled)return
setServersLoading(true) setLiveServers(rows.map(row=>({uid:String(row.id),backendId:row.id,name:row.name||row.host,host:row.host,country:row.country,status:row.status,enabled:row.enabled,rosVersion:row.os??null,identityName:row.identityName??null})));setLoaded(true);setReadAt(new Date().toLocaleTimeString("ru-RU"));setError(null)
void requestJson<BackendServer[]>(backendUrl, "/api/servers") }).catch(err=>{if(!cancelled)setError(err instanceof Error?(err.name==="ZodError"?"Некорректный каталог устройств.":err.message):"Не удалось загрузить устройства.")}).finally(()=>{if(!cancelled)setLoading(false)})})
.then((data) => { return()=>{cancelled=true;controller.abort()}
if (cancelled) return },[isLive,backendUrl,canRead,refresh])
setLiveServers(data.map(s => ({ const termServers=isLive?liveServers:mockServersToTermServers()
uid: String(s.id), const matches=url.server?termServers.filter(server=>server.uid===url.server):url.host?termServers.filter(server=>server.host===url.host):[]
backendId: s.id, const selected=matches.length===1?matches[0]:null
name: s.name || s.host, const unavailable=(loaded||!isLive)&&(url.server||url.host)&&!selected
host: s.host, const items=termServers.map(server=>({id:server.uid,name:server.name,host:server.host,country:server.country||undefined,status:server.status??"unknown" as const,enabled:server.enabled,selectable:true,title:[server.name,server.host].join(" · ")}))
country: s.country || "", function select(id:string){const href=patchTerminalUrl(new URLSearchParams(params.toString()),id||null)+window.location.hash;window.history.pushState(null,"",href)}
status: s.status, const blocked=!!error||loading||isLive&&!loaded
enabled: s.enabled, return <ServerRailLayout items={items} selectedId={selected?.uid??url.server??""} onSelect={select} preserveSelection showAll={false} showCount={false} showType={false} loading={loading} contentClassName="min-h-0 min-w-0 overflow-hidden p-2 md:p-3"
rosVersion: s.os, header={<PageHeader crumbs={[{label:"Инструменты"},{label:"Терминал"}]} actions={<><ServerRailMobileButton/>{isLive&&<Button type="button" variant="outline" size="sm" disabled={loading||!canRead} onClick={()=>setRefresh(value=>value+1)}><RefreshCwIcon aria-hidden="true" className="size-4"/>Обновить устройства</Button>}</>}/>}
identityName: s.identityName, banner={<div className="px-3 py-2 space-y-2"><p role="status" className="text-xs text-muted-foreground">{isLive?(readAt?"Каталог · ответ "+readAt+" (время браузера)":"Каталог ещё не подтверждён."):"Демо: команды не отправляются на устройство."} REST выполняет только поддерживаемые диагностические команды.</p>{!canRead&&isLive&&<Alert variant="destructive"><AlertTitle>Нет доступа к каталогу</AlertTitle><AlertDescription>Требуется mm:servers:read.</AlertDescription></Alert>}{error&&<Alert variant="destructive"><AlertTitle>{loaded?"Каталог не обновлён":"Устройства пока неизвестны"}</AlertTitle><AlertDescription>{error} {loaded?"Сохранён предыдущий каталог; выполнение приостановлено до успешного обновления.":"Повторите загрузку устройств."}</AlertDescription></Alert>}{unavailable&&<Alert variant="destructive"><AlertTitle>Устройство терминала недоступно</AlertTitle><AlertDescription>{matches.length>1?"Host неоднозначен: выберите точное устройство.":"Указанный server/host отсутствует в текущем каталоге."}<Button type="button" variant="outline" onClick={()=>select("")}>Сбросить выбор устройства</Button></AlertDescription></Alert>}</div>}>
}))) {selected?<Terminal key={JSON.stringify([selected.uid,selected.host])} server={selected} isLive={isLive} backendUrl={backendUrl} canExecute={canExecute} blockedReason={blocked?"Каталог не подтверждён; выполнение приостановлено до успешного обновления.":undefined}/>:<Frame spacing="sm" className="h-full min-h-0"><FramePanel className="h-full flex flex-col gap-2 items-center justify-center p-4 text-center"><FrameTitle>{loading?"Загружаем устройства…":loaded&&!termServers.length?"Устройств пока нет":"Выберите устройство для терминала"}</FrameTitle><FrameDescription>Выбор сохраняется в URL. Команда отправляется только после нажатия «Выполнить».</FrameDescription></FramePanel></Frame>}
setServersLoading(false) </ServerRailLayout>
})
.catch(() => { if (!cancelled) setServersLoading(false) })
})
return () => { cancelled = true }
}, [isLive, backendUrl, refreshKey, prefsHydrated])
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
// Select first available server by default
const [selectedUid, setSelectedUid] = useState<string>("")
const defaultUid = useMemo(() => {
const first = termServers.find(s => s.status !== "offline" && s.enabled)
?? termServers[0]
return first?.uid ?? ""
}, [termServers])
useEffect(() => {
queueMicrotask(() => {
if (!selectedUid && defaultUid) setSelectedUid(defaultUid)
})
}, [defaultUid, selectedUid])
// Reset selection when switching modes
useEffect(() => {
queueMicrotask(() => setSelectedUid(""))
}, [isLive])
const selected = termServers.find(s => s.uid === selectedUid) ?? termServers[0]
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
const railItems = useMemo<ServerTileItem[]>(() => {
return termServers.map((s) => ({
id: s.uid,
name: s.name,
host: s.host,
country: s.country || undefined,
status: (s.status ?? undefined) as ServerStatus | undefined,
enabled: s.enabled,
selectable: s.enabled && s.status !== "offline",
title: [s.name, s.host].filter(Boolean).join(" · "),
}))
}, [termServers])
const handleSelectServer = useCallback((id: string) => {
setSelectedUid(id)
setRefreshKey((k) => k + 1)
}, [])
const railHeaderRight = isLive && !serversLoading
? <Badge variant="success-light" size="xs">LIVE</Badge>
: undefined
function QuickCmds() {
return (
<Frame dense spacing="sm" className="min-h-0 shrink-0">
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
<FrameHeader className="border-b px-3 py-2">
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Быстрые команды
</FrameTitle>
</FrameHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 p-1.5">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
type="button"
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</ScrollArea>
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
<p>↑↓ — история команд</p>
<p>Ctrl+L — очистить экран</p>
{isLive
? <p className="text-info">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>}
</FrameFooter>
</FramePanel>
</Frame>
)
}
function injectCommand(cmd: string) {
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
if (!el) return
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set
nativeSetter?.call(el, cmd)
el.dispatchEvent(new Event("input", { bubbles: true }))
el.focus()
}
return (
<ServerRailLayout
items={railItems}
selectedId={selected?.uid ?? selectedUid}
onSelect={handleSelectServer}
showAll={false}
showCount={false}
showType={false}
headerRight={railHeaderRight}
loading={serversLoading}
extra={<QuickCmds />}
contentClassName="overflow-hidden p-3 md:p-4"
header={
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => {
setRefreshKey((k) => k + 1)
if (isLive) setSelectedUid("")
}}
>
<RefreshCwIcon className="size-4" />
Переподключить
</Button>
</>
}
/>
}
>
{selected ? (
<Terminal
key={termKey}
server={selected}
isLive={isLive}
backendUrl={backendUrl}
/>
) : (
<div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
</div>
)}
</ServerRailLayout>
)
} }
File diff suppressed because it is too large. Load diff
+596 -502
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
.container { container-type: inline-size; }
.header { display: none; }
.serverName { flex: 1 1 160px; }
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: "name name" "status toggle" "iface filter" "rtt loss" "bt graph" "star actions";
}
.row > * { min-width: 0; overflow-wrap: anywhere; }
.name { grid-area: name; }
.status { grid-area: status; display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; }
.toggle { grid-area: toggle; display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; justify-content: flex-end; }
.iface { grid-area: iface; }
.filter { grid-area: filter; }
.rtt { grid-area: rtt; }
.loss { grid-area: loss; }
.bt { grid-area: bt; }
.graph { grid-area: graph; }
.star { grid-area: star; display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; }
.actions { grid-area: actions; }
.label { display: block; font-size: 0.625rem; color: var(--muted-foreground); }
.actions button, .star button { min-width: 2rem; min-height: 2rem; }
@container (min-width: 1100px) {
.header, .row {
display: grid;
grid-template-columns: 36px 16px 130px 120px 140px 70px 44px minmax(132px, 1fr) 96px 36px 72px;
grid-template-areas: "toggle status iface name filter rtt loss bt graph star actions";
}
.label, .mobile { display: none; }
.toggle, .star { justify-content: center; }
.actions button, .star button { min-width: unset; min-height: unset; }
}
+204 -136
View File
@@ -1,7 +1,8 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useRef, useState, useMemo } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { UsersDataGrid } from "@/components/data-grids/users-data-grid" import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
@@ -23,22 +24,25 @@ import { useDataSource } from "@/lib/data-source"
import { import {
ALL_SECTIONS, ALL_SECTIONS,
INIT_USERS, INIT_USERS,
bindingDiffKey,
userInitials, userInitials,
type AppUser, type AppUser,
type AppUserForm, type AppUserForm,
type UserServerOption, type UserServerOption,
} from "@/lib/users" } from "@/lib/users"
import { listServers } from "@/shared/api/servers" import { serverListSchema } from "@mmapp/contracts/servers"
import { requestJson } from "@/shared/api/http-client"
import { can } from "@/lib/auth"
import { import {
userApiError,
createAppUser, createAppUser,
getAppUser,
createUserBinding, createUserBinding,
deleteAppUser, deleteAppUser,
deleteUserBinding, deleteUserBinding,
listAppUsers, listAppUsers,
updateAppUser, updateAppUser,
} from "@/shared/api/users" } from "@/shared/api/users"
import { ApiClientError } from "@/shared/api/http-client" import { saveUserSteps, userProfileRequest, type UserSaveCheckpoint } from "@/lib/user-save"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
CableIcon, CableIcon,
@@ -55,168 +59,222 @@ function toServerOptionsFromMock(): UserServerOption[] {
host: s.host, host: s.host,
site: s.site, site: s.site,
country: s.country, country: s.country,
status: s.status, status: s.status === "unknown" ? null : s.status,
})) }))
} }
export default function UsersPage() { export default function UsersPage() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return <p role="status">Загрузка настроек пользователей…</p>
return <UsersContent key={JSON.stringify([mode, backendUrl])} />
}
function UsersContent() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
const canRead = can("mm:users:read")
const canWrite = can("mm:users:write")
const canReadServers = can("mm:servers:read")
const [users, setUsers] = useState<AppUser[]>(INIT_USERS) const [users, setUsers] = useState<AppUser[]>(() => isLive ? [] : INIT_USERS)
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock) const [userSearch, setUserSearch] = useState("")
const [loading, setLoading] = useState(false) const visibleUsers = useMemo(() => {
const query = userSearch.trim().toLocaleLowerCase("ru-RU")
return query ? users.filter(user => [user.name, user.login, user.email].some(value => value.toLocaleLowerCase("ru-RU").includes(query))) : users
}, [users, userSearch])
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(() => isLive ? [] : toServerOptionsFromMock())
const [loading, setLoading] = useState(isLive && canRead)
const [usersReadAt, setUsersReadAt] = useState<number | null>(null)
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
const [usersError, setUsersError] = useState<string | null>(null)
const [serversError, setServersError] = useState<string | null>(null)
const loadController = useRef<AbortController | null>(null)
const loadRevision = useRef(0)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [saveBlocked, setSaveBlocked] = useState(false)
const [saveResume, setSaveResume] = useState(false)
const saveCheckpoint = useRef<UserSaveCheckpoint | null>(null)
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [operationNotice, setOperationNotice] = useState<string | null>(null)
const mutationLock = useRef(false)
const mutationController = useRef<AbortController | null>(null)
const alive = useRef(true)
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
const formReturnFocus = useRef<HTMLElement | null>(null)
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [editUser, setEditUser] = useState<AppUser | null>(null) const [editUser, setEditUser] = useState<AppUser | null>(null)
useEffect(() => {
if (sheetOpen || !formReturnFocus.current) return
const label = formReturnFocus.current.getAttribute("aria-label")
const frame = requestAnimationFrame(() => {
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === label)
;(target ?? document.querySelector<HTMLButtonElement>('button[aria-label="Добавить пользователя"]'))?.focus()
})
return () => cancelAnimationFrame(frame)
}, [sheetOpen])
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null) const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
const loadLive = useCallback(async () => { const loadLive = useCallback(async () => {
if (!isLive || !canRead) return false
loadController.current?.abort()
const controller = new AbortController()
loadController.current = controller
const revision = ++loadRevision.current
const current = () => !controller.signal.aborted && revision === loadRevision.current
setLoading(true) setLoading(true)
try { const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
const [list, srvs] = await Promise.all([ const [list, srvs] = await Promise.allSettled([
listAppUsers(backendUrl), listAppUsers(backendUrl, { signal }).then(rows => {
listServers(backendUrl), if (current()) { setUsers(rows); setUsersReadAt(Date.now()); setUsersError(null) }
]) return rows
setUsers(list) }).catch(error => {
setServerOptions(srvs.map((s) => ({ if (current()) setUsersError(userApiError(error))
id: String(s.id), throw error
name: s.name || s.host, }),
host: s.host, canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(raw => {
site: s.site, const rows = serverListSchema.parse(raw)
country: s.country, if (new Set(rows.map(row => row.id)).size !== rows.length || rows.some(row => row.id <= 0)) throw new Error("Некорректный каталог серверов.")
status: s.status, if (current()) {
}))) setServerOptions(rows.map(s => ({
} catch (err) { id: String(s.id), name: s.name || s.host, host: s.host, site: s.site, country: s.country, status: s.status,
toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей") })))
} finally { setServersReadAt(Date.now()); setServersError(null)
setLoading(false) }
} return rows
}, [backendUrl]) }).catch(error => {
if (current()) setServersError(userApiError(error))
throw error
}) : Promise.resolve(null),
])
if (!current()) return false
setLoading(false)
return list.status === "fulfilled" && srvs.status === "fulfilled"
}, [isLive, canRead, canReadServers, backendUrl])
useEffect(() => { useEffect(() => {
if (!isLive) { let cancelled = false
setUsers(INIT_USERS) if (isLive && canRead) queueMicrotask(() => { if (!cancelled) void loadLive() })
setServerOptions(toServerOptionsFromMock()) return () => { cancelled = true; loadController.current?.abort() }
return }, [isLive, canRead, loadLive])
}
void loadLive()
}, [isLive, loadLive])
const usersKnown = !isLive || usersReadAt !== null
const serversKnown = !isLive || serversReadAt !== null
const writeReady = canWrite && (!isLive || (usersKnown && serversKnown && !loading && !usersError && !serversError))
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0) const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
const activeCount = users.filter((u) => u.active).length const activeCount = users.filter((u) => u.active).length
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
const nextKeys = new Set(next.map(bindingDiffKey))
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
for (const b of prev) {
if (!nextKeys.has(bindingDiffKey(b))) {
await deleteUserBinding(backendUrl, userId, b.id)
}
}
for (const b of next) {
if (!prevKeys.has(bindingDiffKey(b))) {
await createUserBinding(backendUrl, userId, {
serverId: Number(b.serverId),
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
peerPublicKey: b.peerPublicKey,
peerName: b.peerName,
comment: b.comment,
})
}
}
}
const handleSave = async (form: AppUserForm) => { const handleSave = async (form: AppUserForm) => {
if (!writeReady || mutationLock.current || saveBlocked) return
if (!isLive) { if (!isLive) {
if (!editUser) { if (!editUser) {
const id = `u${Date.now()}` const id = "u" + Date.now()
const created: AppUser = { setUsers(prev => [...prev, { id, ...form, last: "—", avatar: userInitials(form.name), bindings: form.bindings.map((b, i) => ({ ...b, id: "b" + id + "-" + i, userId: id })) }])
id, } else setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
...form, setSheetOpen(false); toast.success("Профиль сохранён в демо."); return
last: "только что",
avatar: userInitials(form.name),
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
}
setUsers((prev) => [...prev, created])
} else {
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
}
setSheetOpen(false)
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
return
} }
mutationLock.current = true
setSaving(true) const controller = new AbortController()
mutationController.current = controller
const current = () => alive.current && !controller.signal.aborted
const init = () => ({ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
const checkpoint = saveCheckpoint.current ?? { user: editUser }
saveCheckpoint.current = checkpoint
setSaving(true); setSaveError(null); setOperationNotice(null)
try { try {
if (!editUser) { const saved = await saveUserSteps(form, checkpoint, {
const created = await createAppUser(backendUrl, { create: f => createAppUser(backendUrl, userProfileRequest(f), init()),
name: form.name, update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f), init()),
login: form.login, read: id => getAppUser(backendUrl, id, init()),
email: form.email, removeBinding: (userId, id) => deleteUserBinding(backendUrl, userId, id, init()),
role: form.role, addBinding: (userId, b) => createUserBinding(backendUrl, userId, {
active: form.active, serverId: Number(b.serverId), interfaceName: b.interfaceName, interfaceType: b.interfaceType,
avatar: userInitials(form.name), peerPublicKey: b.peerPublicKey, peerName: b.peerName, comment: b.comment,
sections: form.sections, }, init()),
servers: form.servers, }, current, state => {
}) if (!current()) return
await applyBindingsDiff(created.id, form.bindings, []) setSaveBlocked(!!state.createUncertain)
await loadLive() if (state.user) {
} else { if (!editUser) setSaveResume(true)
await updateAppUser(backendUrl, editUser.id, { const confirmed = state.user
name: form.name, setUsers(prev => prev.some(u => u.id === confirmed.id) ? prev.map(u => u.id === confirmed.id ? confirmed : u) : [...prev, confirmed])
login: form.login, }
email: form.email, })
role: form.role, if (!current() || !saved) return
active: form.active, setSheetOpen(false); saveCheckpoint.current = null
avatar: userInitials(form.name), setOperationNotice("Профиль и привязки подтверждены API.")
sections: form.sections, toast.success("Пользователь сохранён.")
servers: form.servers, const refreshed = await loadLive()
}) if (current() && !refreshed) setOperationNotice("Профиль и привязки подтверждены API. Обновление каталогов не удалось; повторите чтение.")
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings) } catch (error) {
await loadLive() if (!current()) return
} const detail = userApiError(error)
setSheetOpen(false) setSaveError(checkpoint.createUncertain
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан") ? "Результат создания неизвестен. Повтор заблокирован: проверьте список пользователей. " + detail
} catch (err) { : checkpoint.bindingsNeedRead
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения" ? "Профиль подтверждён API; привязки сохранены частично. При повторе сначала прочитаем их текущее состояние. " + detail
toast.error(msg) : detail)
} finally { } finally {
setSaving(false) if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
if (alive.current) setSaving(false)
} }
} }
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget) return if (!deleteTarget || !writeReady || mutationLock.current) return
const target = deleteTarget
if (!isLive) { if (!isLive) {
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id)) setUsers(prev => prev.filter(u => u.id !== target.id)); setDeleteTarget(null); setDeleteError(null)
setDeleteTarget(null) toast.success("Пользователь удалён в демо.")
toast.success("Пользователь удалён")
return return
} }
mutationLock.current = true
const controller = new AbortController()
mutationController.current = controller
setDeleting(true); setDeleteError(null); setOperationNotice(null)
const current = () => alive.current && !controller.signal.aborted
try { try {
await deleteAppUser(backendUrl, deleteTarget.id) await deleteAppUser(backendUrl, target.id, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
if (!current()) return
setUsers(prev => prev.filter(u => u.id !== target.id))
setDeleteTarget(null) setDeleteTarget(null)
await loadLive() setOperationNotice("Удаление пользователя подтверждено API.")
toast.success("Пользователь удалён") toast.success("Пользователь удалён.")
} catch (err) { const refreshed = await loadLive()
toast.error(err instanceof Error ? err.message : "Не удалось удалить") if (current() && !refreshed) setOperationNotice("Удаление пользователя подтверждено API. Обновление каталогов не удалось; повторите чтение.")
} catch (error) {
if (current()) setDeleteError(userApiError(error))
} finally {
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
if (alive.current) setDeleting(false)
} }
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full min-w-0">
<PageHeader <PageHeader
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]} crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
actions={ actions={
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}> <>
<PlusIcon className="size-4" />Пригласить {isLive && <Button type="button" size="sm" variant="outline" aria-label="Обновить пользователей" disabled={!canRead || loading || saving || deleting || sheetOpen || !!deleteTarget} onClick={() => void loadLive()}>Обновить</Button>}
<Button type="button" size="sm" aria-label="Добавить пользователя" disabled={!writeReady || saving || deleting} onClick={() => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(null); setSheetOpen(true) }}>
<PlusIcon aria-hidden="true" className="size-4" />Добавить <span className="hidden sm:inline">пользователя</span>
</Button> </Button>
</>
} }
/> />
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5"> <div className="flex-1 overflow-y-auto p-4 md:p-6 min-w-0 flex flex-col gap-5">
{operationNotice && <p role="status" className="text-sm text-muted-foreground">{operationNotice}</p>}
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: изменения сохраняются только в текущем экране.</p>}
{!canRead && <p role="status">Для чтения пользователей требуется mm:users:read.</p>}
{!canWrite && <p role="status">Для изменения пользователей требуется mm:users:write.</p>}
{isLive && !canReadServers && <p role="status">Каталог серверов недоступен: требуется mm:servers:read. Изменения приостановлены.</p>}
{usersError && <div role="alert" aria-label="Пользователи" className="rounded-lg border border-destructive/30 p-3 text-sm"><p>{usersKnown ? "Не удалось обновить пользователей. Последний список сохранён." : "Пользователи пока неизвестны."}</p><p>{usersError}</p></div>}
{serversError && <div role="alert" aria-label="Серверы пользователей" className="rounded-lg border border-destructive/30 p-3 text-sm"><p>{serversKnown ? "Не удалось обновить серверы. Последний каталог сохранён." : "Каталог серверов пока неизвестен."} Изменения приостановлены.</p><p>{serversError}</p></div>}
{isLive && usersReadAt !== null && <p role="status" className="text-xs text-muted-foreground">Пользователи получены: {new Date(usersReadAt).toLocaleTimeString("ru-RU")}{loading ? " · обновляются" : ""}</p>}
{isLive && loading && !usersKnown && <p role="status">Загрузка пользователей…</p>}
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */} {/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка пользователей" aria-label="Сводка пользователей"
@@ -224,21 +282,21 @@ export default function UsersPage() {
{ {
id: "all", id: "all",
label: "Пользователи", label: "Пользователи",
value: users.length, value: usersKnown ? users.length : "—",
icon: <UsersIcon className="size-4" />, icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "active", id: "active",
label: "Активные", label: "Активные",
value: activeCount, value: usersKnown ? activeCount : "—",
icon: <UserCheckIcon className="size-4" />, icon: <UserCheckIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "ifaces", id: "ifaces",
label: "Привязанные ifaces", label: "Привязанные интерфейсы",
value: boundCount, value: usersKnown ? boundCount : "—",
icon: <CableIcon className="size-4" />, icon: <CableIcon className="size-4" />,
iconClassName: "text-info", iconClassName: "text-info",
}, },
@@ -246,19 +304,24 @@ export default function UsersPage() {
/> />
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */} {/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
<DataPageCard> {canRead && (usersKnown || loading) && <DataPageCard>
<DataPageToolbar search={userSearch} onSearchChange={setUserSearch} searchLabel="Поиск по имени, логину или email" searchPlaceholder="Поиск по имени, логину или email…" countLabel={usersKnown ? `${visibleUsers.length} из ${users.length}` : "Данные пока неизвестны"} />
<UsersDataGrid <UsersDataGrid
users={users} users={visibleUsers}
serversCount={serverOptions.length} emptyTitle={userSearch.trim() ? "Нет совпадений" : "Нет пользователей"}
emptyDescription={userSearch.trim() ? "Измените поиск" : "Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"}
serversCount={serversKnown ? serverOptions.length : null}
allSectionsCount={ALL_SECTIONS.length} allSectionsCount={ALL_SECTIONS.length}
isLoading={loading} isLoading={loading && !usersKnown}
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }} actionsDisabled={!writeReady || saving || deleting}
onDelete={setDeleteTarget} onEdit={(u) => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(u); setSheetOpen(true) }}
onDelete={u => { setDeleteError(null); setDeleteTarget(u) }}
/> />
</DataPageCard> </DataPageCard>}
</div> </div>
<UserSheet <UserSheet
returnFocus={formReturnFocus}
key={sheetOpen ? (editUser?.id ?? "create") : "closed"} key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
open={sheetOpen} open={sheetOpen}
user={editUser} user={editUser}
@@ -267,12 +330,16 @@ export default function UsersPage() {
isLive={isLive} isLive={isLive}
backendUrl={backendUrl} backendUrl={backendUrl}
saving={saving} saving={saving}
saveError={saveError}
saveBlocked={saveBlocked}
saveResume={saveResume}
onCheckCatalogs={() => void loadLive()}
onSave={(f) => { void handleSave(f) }} onSave={(f) => { void handleSave(f) }}
onClose={() => setSheetOpen(false)} onClose={() => { if (!saving) { setSheetOpen(false); saveCheckpoint.current = null } }}
/> />
{deleteTarget && ( {deleteTarget && (
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(null) }}> <AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v && !deleting) { setDeleteTarget(null); setDeleteError(null) } }}>
<AlertDialogContent size="default"> <AlertDialogContent size="default">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive"> <AlertDialogMedia className="bg-destructive/10 text-destructive">
@@ -284,9 +351,10 @@ export default function UsersPage() {
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel> {deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}> <AlertDialogCancel disabled={deleting} onClick={() => { setDeleteTarget(null); setDeleteError(null) }}>Отмена</AlertDialogCancel>
Удалить <AlertDialogAction type="button" disabled={deleting || !writeReady} aria-busy={deleting} variant="destructive" onClick={() => { void handleDelete() }}>
{deleting ? "Удаление…" : "Удалить"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+292 -139
View File
@@ -1,77 +1,94 @@
"use client" "use client"
import { useMemo, useState } from "react" import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Alert as ContextAlert, AlertTitle as ContextTitle, AlertDescription as ContextDescription } from "@/components/reui/alert"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data" import { vxlanTunnels as mockVxlanTunnels, servers as mockServers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data" import type { Server, VxlanTunnel } from "@/lib/data"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid" import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { import {
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon, NetworkIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
} from "lucide-react" } from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { useDataSource } from "@/lib/data-source"
import { generateVxlanRsc } from "@/lib/vxlan-export"
import { vxlanResponseSchema, vxlanServersSchema } from "@/lib/vxlan-response"
import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── helpers ────────────────────────────────────────────────────────────────── interface BackendServer {
id: number
function serverFor(id: string) { name: string
return servers.find((s) => s.id === id) host: string
type?: Server["type"]
site?: string
country: string
asn?: string
enabled: boolean
status?: Server["status"] | null
latency?: number | null
} }
// ─── RSC generator ─────────────────────────────────────────────────────────── interface VxlanApiResponse {
failures?: Array<{ serverId: string; serverName?: string; error: string }>
tunnels: VxlanTunnel[]
}
function generateVxlanRsc(t: VxlanTunnel): string { type InventoryServer = Server & { monitoringUnknown?: boolean }
const srv = serverFor(t.serverId)
const lines: string[] = []
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
lines.push(`# RouterOS 7.x · /interface/vxlan`)
lines.push(``)
lines.push(`/interface/vxlan/add \\`)
lines.push(` name=${t.name} \\`)
lines.push(` vni=${t.vni} \\`)
lines.push(` port=${t.dstPort} \\`)
lines.push(` vtep-mac-address=auto \\`)
lines.push(` arp-proxy=${t.arpProxy ? "yes" : "no"} \\`)
lines.push(` mac-learning=${t.macLearning ? "yes" : "no"} \\`)
lines.push(` l2mtu=${t.l2mtu} \\`)
if (t.comment) lines.push(` comment="${t.comment}" \\`)
if (!t.enabled) lines.push(` disabled=yes \\`)
lines.push(``)
// FDB entries for remote VTEPs function mapBackendServer(s: BackendServer): InventoryServer {
for (const vtep of t.remoteVteps) { return {
lines.push(`/interface/vxlan/vteps/add \\`) id: String(s.id),
lines.push(` interface=${t.name} \\`) name: s.name || s.host,
lines.push(` remote-ip=${vtep}`) host: s.host,
lines.push(``) model: "—",
os: "—",
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? "offline",
monitoringUnknown: s.status == null,
latency: s.latency ?? null,
sessions: 0,
} }
// Bridge
lines.push(`# Добавить в bridge:`)
lines.push(`/interface/bridge/port/add \\`)
lines.push(` bridge=bridge-overlay \\`)
lines.push(` interface=${t.name}`)
return lines.join("\n")
} }
// ─── Export Sheet ───────────────────────────────────────────────────────────── function ExportSheet({
open, tunnel, onClose, serverById, receivedAt, stale, mock,
function ExportSheet({ open, tunnel, onClose }: { }: {
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void open: boolean
tunnel: VxlanTunnel | null
onClose: () => void
serverById: Record<string, Server>
receivedAt: string | null
stale: boolean
mock: boolean
}) { }) {
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel]) const code = useMemo(
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
[tunnel, serverById],
)
return ( return (
<CodeExportSheet <CodeExportSheet
open={open} open={open}
onClose={onClose} onClose={onClose}
className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
title="Экспорт VXLAN" title="Экспорт VXLAN"
description="RouterOS 7.x · /interface/vxlan + vteps" description={mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "время получения неизвестно"} · ${stale ? "актуальность не подтверждена" : "последний успешный опрос"}`}
formats={[ formats={[
{ {
id: "rsc", id: "rsc",
@@ -84,38 +101,200 @@ function ExportSheet({ open, tunnel, onClose }: {
) )
} }
// ─── Export Sheet ───────────────────────────────────────────────────────────── function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, onSelectServer }: {
export default function VxlanPage() { selectedServerId: string
const [search, setSearch] = useState("") selectedTunnelId: string | null
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null) allTunnelsHref: string
onSelectServer: (id: string) => void
}) {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [search, setSearch] = useState("")
const [exportSnapshot, setExportSnapshot] = useState<{ tunnel: VxlanTunnel; servers: Record<string, Server>; at: string | null; stale: boolean; mock: boolean } | null>(null)
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
const [responseReceipt, setResponseReceipt] = useState<string | null>(null)
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
const [loading, setLoading] = useState(true)
const [failures, setFailures] = useState<NonNullable<VxlanApiResponse["failures"]>>([])
const [hasLoaded, setHasLoaded] = useState(false)
const loadEpoch = useRef(0)
useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive])
const [liveError, setLiveError] = useState<string | null>(null)
const loadLive = useCallback(async () => {
if (!isLive) return
const epoch = ++loadEpoch.current
setLoading(true)
try {
const [tunnelsRes, serversRes] = await Promise.all([
requestJson<unknown>(backendUrl, "/api/vxlan"),
requestJson<unknown>(backendUrl, "/api/servers"),
])
if (epoch !== loadEpoch.current) return
const parsedTunnels = vxlanResponseSchema.safeParse(tunnelsRes)
const parsedServers = vxlanServersSchema.safeParse(serversRes)
if (!parsedTunnels.success || !parsedServers.success) throw new Error("Некорректный ответ загрузки VXLAN")
const nextTunnels = parsedTunnels.data.tunnels
const nextServers = parsedServers.data
const nextFailures = parsedTunnels.data.failures
const failedIds = new Set(nextFailures.map(f => f.serverId))
const enabledIds = new Set(nextServers.filter(s => s.enabled).map(s => String(s.id)))
setLiveTunnels(previous => [...nextTunnels.filter(t => !failedIds.has(t.serverId)), ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))])
setFailures(nextFailures)
setLiveError(null)
setHasLoaded(true)
const at = new Date().toISOString()
setResponseReceipt(at)
setReadReceipt(previous => Object.fromEntries([...enabledIds].map(id => [id, failedIds.has(id) ? previous[id] : at]).filter((entry): entry is [string, string] => !!entry[1])))
setLiveServers(nextServers.filter((s) => s.enabled).map(mapBackendServer))
} catch (e) {
if (epoch !== loadEpoch.current) return
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
} finally {
if (epoch === loadEpoch.current) setLoading(false)
}
}, [isLive, backendUrl])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveTunnels([])
setLiveServers([])
setLiveError(null)
})
return
}
queueMicrotask(() => {
void loadLive()
})
}, [isLive, loadLive])
const displayTunnels = isLive ? liveTunnels : mockVxlanTunnels
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId = selectedServerId
const scopedTunnels = useMemo(() => {
if (selectedTunnelId !== null) return displayTunnels.filter(t => t.serverId === effectiveServerId && t.id === selectedTunnelId)
if (effectiveServerId === ALL_SERVERS_ID) return displayTunnels
return displayTunnels.filter((t) => t.serverId === effectiveServerId)
}, [displayTunnels, effectiveServerId, selectedTunnelId])
const selectedTunnel = selectedTunnelId === null ? undefined : scopedTunnels[0]
const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
)
const railItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: isLive && (s as InventoryServer).monitoringUnknown ? undefined : s.status,
type: s.type,
enabled: s.enabled,
meta: `${displayTunnels.filter((t) => t.serverId === s.id).length}${isLive && (s as InventoryServer).monitoringUnknown ? " · Статус неизвестен" : ""}`,
}))
), [displayServers, displayTunnels, isLive])
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) return vxlanTunnels if (!search) return scopedTunnels
const q = search.toLowerCase() const q = search.toLowerCase()
return vxlanTunnels.filter((t) => return scopedTunnels.filter((t) =>
t.name.includes(q) || t.name.includes(q) ||
String(t.vni).includes(q) || String(t.vni).includes(q) ||
t.vtepIp.includes(q) || t.vtepIp.includes(q) ||
(serverFor(t.serverId)?.name.toLowerCase().includes(q) ?? false) (serverById[t.serverId]?.name.toLowerCase().includes(q) ?? false),
) )
}, [search]) }, [search, scopedTunnels, serverById])
const upCount = vxlanTunnels.filter((t) => t.status === "up").length const failedServerIds = useMemo(() => !isLive ? [] : liveError ? [...new Set([...liveTunnels.map(t => t.serverId), ...liveServers.map(s => s.id)])] : failures.map(f => f.serverId), [isLive, liveError, liveTunnels, liveServers, failures])
const vnis = new Set(vxlanTunnels.map((t) => t.vni)).size const scopedFailures = failures.filter(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId)
const upCount = scopedTunnels.filter((t) => t.status === "up").length
const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
return ( return (
<div className="flex flex-col h-full"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]} items={railItems}
actions={ selectedId={effectiveServerId}
<Button size="sm"> preserveSelection
<PlusIcon className="size-4" />Новый VXLAN onSelect={onSelectServer}
</Button> showAll
} allCount={displayServers.length}
/> loading={isLive && loading && displayServers.length === 0}
header={
<div className="flex-1 overflow-y-auto p-6"> <PageHeader
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || loading}
>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Badge variant="outline">Только просмотр</Badge>
</>
}
/>
}
>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{isLive && scopedFailures.length > 0 && <ContextAlert variant="warning" role="alert">
<ContextTitle>Не все устройства ответили</ContextTitle>
<ContextDescription>
<p>Не удалось опросить: {scopedFailures.map(f => f.serverName ?? f.serverId).join(", ")}. Для этих устройств сохранены последние данные; текущее состояние туннелей неизвестно.</p>
<Button variant="outline" size="sm" disabled={loading} onClick={() => void loadLive()}>Повторить опрос</Button>
</ContextDescription>
</ContextAlert>}
{selectedTunnelId !== null && (
<ContextAlert variant={liveError || ((!isLive || !loading) && !selectedTunnel) ? "warning" : "info"} role="status" aria-label="VXLAN по ссылке">
<ContextTitle className="line-clamp-none wrap-anywhere">{selectedTunnel ? "Выбран VXLAN: " + selectedTunnel.name : "VXLAN по ссылке"}</ContextTitle>
<ContextDescription>
<p className="wrap-anywhere">{isLive && loading ? "Загрузка туннеля…" : isLive && liveError ? "Не удалось обновить VXLAN. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · VNI " + selectedTunnel.vni + ". Список и сводка ограничены выбранным туннелем." : "Туннель не получен для указанного сервера. Проверьте ссылку и доступ к устройству: туннель может отсутствовать или устройство не ответило на опрос."}</p>
<div className="flex flex-wrap gap-4">
<Link href={allTunnelsHref} className="underline underline-offset-2">Все туннели сервера</Link>
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
</div>
</ContextDescription>
</ContextAlert>
)}
{isLive && liveError && selectedTunnelId === null && (
<Alert variant="warning" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
</Alert>
)}
{isLive && <div role="group" aria-label="Свежесть VXLAN" className="text-xs text-muted-foreground">
{effectiveServerId === ALL_SERVERS_ID ? "Последний ответ получен в приложении: " : "Ответ устройства получен в приложении: "}
{(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId]) ? <time dateTime={(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId])!} title="Клиентское время ответа, не время измерения RouterOS">{new Date((effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId])!).toLocaleString("ru-RU")}</time> : "неизвестно"}
</div>}
{isLive && loading && <p role="status" className="text-sm text-muted-foreground">{hasLoaded ? "Обновление VXLAN…" : "Загрузка VXLAN…"}</p>}
{isLive && liveError && hasLoaded && <p className="text-sm text-muted-foreground">Показаны последние полученные данные; они могут быть устаревшими.</p>}
{selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && scopedFailures.length === 0 && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
На опрошенных серверах нет VXLAN-интерфейсов
</div>
)}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка VXLAN" aria-label="Сводка VXLAN"
@@ -123,122 +302,96 @@ export default function VxlanPage() {
{ {
id: "tunnels", id: "tunnels",
label: "Туннелей", label: "Туннелей",
value: vxlanTunnels.length, value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : scopedTunnels.length,
icon: <NetworkIcon className="size-4" />, icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "up", id: "up",
label: "Активных", label: "Активных",
value: upCount, value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : upCount,
icon: <LayersIcon className="size-4" />, icon: <LayersIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "vni", id: "vni",
label: "Уникальных VNI", label: "VNI",
value: vnis, value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis,
icon: <LayersIcon className="size-4" />, icon: <LayersIcon className="size-4" />,
iconClassName: "text-info", iconClassName: "text-info",
}, },
{ {
id: "servers", id: "servers",
label: "Серверов", label: "Серверов",
value: new Set(vxlanTunnels.map((t) => t.serverId)).size, value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size,
icon: <NetworkIcon className="size-4" />, icon: <NetworkIcon className="size-4" />,
iconClassName: "text-primary", iconClassName: "text-primary",
}, },
]} ]}
/> />
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm"> <div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" /> <NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium text-sky-600 dark:text-sky-400">VXLAN — L2-over-L3 оверлей для RouterOS 7.x</p> <p className="font-medium text-foreground">VXLAN — L2-over-L3 оверлей для RouterOS 7.x</p>
<p className="text-muted-foreground text-xs mt-0.5"> <p className="text-muted-foreground text-xs mt-0.5">
Доступен с RouterOS 7.1+. VNI (Virtual Network Identifier) — уникальный идентификатор сегмента (0–16777215). VXLAN переносит L2 через IP-сеть. VNI определяет сегмент; шифрование зависит от настроенного транспорта.
Рекомендуется использовать совместно с WireGuard или GRE туннелями для шифрования.
</p> </p>
</div> </div>
</div> </div>
{/* Table */}
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, VNI, серверу…" searchPlaceholder="Поиск по имени, VNI, серверу…"
countLabel={`${filtered.length} туннелей`} countLabel={isLive && !hasLoaded ? "Туннели пока неизвестны" : `${filtered.length} туннелей`}
/> />
<VxlanDataGrid {(!isLive || hasLoaded) && <VxlanDataGrid
tunnels={filtered} tunnels={filtered}
servers={servers} failedServerIds={failedServerIds}
onExport={setExportTunnel} servers={displayServers}
/> onExport={tunnel => setExportSnapshot({ tunnel, servers: serverById, at: isLive ? readReceipt[tunnel.serverId] ?? null : null, stale: isLive && (!!liveError || failedServerIds.includes(tunnel.serverId)), mock: !isLive })}
/>}
</DataPageCard> </DataPageCard>
{/* Reference */} <OpsPanel title="Справка VXLAN" contentClassName="px-5 py-4">
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4"> <p className="text-xs text-muted-foreground">Экспорт содержит интерфейс, известный MTU и статические VTEP выбранного туннеля. IP-адреса, bridge и FDB в него не входят. <a href="https://help.mikrotik.com/docs/spaces/ROS/pages/100007937/VXLAN" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">Параметры VXLAN в документации MikroTik</a></p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Создать VXLAN",
lines: [
"/interface/vxlan/add \\",
" name=vxlan-10 \\",
" vni=10010 \\",
" port=8472 \\",
" arp-proxy=yes \\",
" mac-learning=yes",
],
},
{
title: "Добавить VTEP",
lines: [
"/interface/vxlan/vteps/add \\",
" interface=vxlan-10 \\",
" remote-ip=10.0.1.1",
"",
"/interface/vxlan/vteps/add \\",
" interface=vxlan-10 \\",
" remote-ip=10.0.2.1",
],
},
{
title: "Bridge + IP",
lines: [
"/interface/bridge/add \\",
" name=br-overlay",
"",
"/interface/bridge/port/add \\",
" bridge=br-overlay \\",
" interface=vxlan-10",
"",
"/ip/address/add \\",
" address=10.100.0.1/24 \\",
" interface=br-overlay",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</OpsPanel> </OpsPanel>
</div> </div>
</div> </ServerRailLayout>
<ExportSheet <ExportSheet
open={!!exportTunnel} open={!!exportSnapshot}
tunnel={exportTunnel} tunnel={exportSnapshot?.tunnel ?? null}
onClose={() => setExportTunnel(null)} onClose={() => setExportSnapshot(null)}
serverById={exportSnapshot?.servers ?? {}}
receivedAt={exportSnapshot?.at ?? null}
stale={exportSnapshot?.stale ?? false}
mock={exportSnapshot?.mock ?? !isLive}
/> />
</div> </>
) )
} }
function VxlanRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const router = useRouter()
const serverId = params.get("server") ?? ALL_SERVERS_ID
const tunnelId = params.get("tunnel")
const rest = new URLSearchParams(params.toString())
rest.delete("tunnel")
const allTunnelsHref = "/vxlan" + (rest.size ? "?" + rest.toString() : "")
return <VxlanPageContent key={JSON.stringify([serverId, tunnelId, mode, backendUrl])} selectedServerId={serverId} selectedTunnelId={tunnelId} allTunnelsHref={allTunnelsHref} onSelectServer={id => {
const next = new URLSearchParams(rest)
if (id === ALL_SERVERS_ID) next.delete("server")
else next.set("server", id)
router.push("/vxlan" + (next.size ? "?" + next.toString() : ""))
}} />
}
export default function VxlanPage() {
return <Suspense fallback={<div role="status">Загрузка VXLAN…</div>}><VxlanRoute /></Suspense>
}
+330 -94
View File
@@ -1,6 +1,13 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { can } from "@/lib/auth"
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { wireGuardServersSchema } from "@/lib/wireguard-response"
import { useWireGuardUrlState } from "@/hooks/use-wireguard-url-state"
import type { WireGuardTab as WgWorkspaceTab } from "@/lib/wireguard-url-state"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { servers as mockServers } from "@/lib/data" import { servers as mockServers } from "@/lib/data"
import type { Server } from "@/lib/data" import type { Server } from "@/lib/data"
@@ -44,6 +51,7 @@ import {
exportWireGuard, exportWireGuard,
importWireGuard, importWireGuard,
listWireGuard, listWireGuard,
listWireGuardRevisions,
patchWireGuardInterface, patchWireGuardInterface,
} from "@/shared/api/wireguard" } from "@/shared/api/wireguard"
import type { WgIfaceDto } from "@mmapp/contracts/wireguard" import type { WgIfaceDto } from "@mmapp/contracts/wireguard"
@@ -57,14 +65,15 @@ import {
type ServerTileItem, type ServerTileItem,
} from "@/components/server-tile-rail" } from "@/components/server-tile-rail"
import { toast } from "sonner" import { toast } from "sonner"
import { selectWgExportPeer } from "@/lib/wg-export-peer"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
import { import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon, ShieldCheckIcon, PlusIcon, KeyRoundIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon, UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
Trash2Icon, CodeXmlIcon, AlertCircleIcon, Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon,
} from "lucide-react" } from "lucide-react"
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
type WgStatusFilter = "all" | "up" | "down"
type WgFailure = { serverId: string; serverName?: string; error: string } type WgFailure = { serverId: string; serverName?: string; error: string }
type PendingDelete = type PendingDelete =
| { kind: "iface"; iface: WgIfaceWithServer } | { kind: "iface"; iface: WgIfaceWithServer }
@@ -129,7 +138,7 @@ interface BackendServer {
country: string country: string
type?: Server["type"] type?: Server["type"]
enabled: boolean enabled: boolean
status?: Server["status"] status?: Server["status"] | null
latency?: number | null latency?: number | null
asn?: string asn?: string
} }
@@ -146,7 +155,7 @@ function mapBackendServer(s: BackendServer): Server {
asn: s.asn ?? "", asn: s.asn ?? "",
type: s.type ?? "exit-node", type: s.type ?? "exit-node",
enabled: s.enabled, enabled: s.enabled,
status: s.status ?? "online", status: s.status ?? "unknown",
latency: s.latency ?? null, latency: s.latency ?? null,
sessions: 0, sessions: 0,
} }
@@ -167,56 +176,96 @@ function peerRowId(iface: WgIfaceWithServer, peer: WgIfaceWithServer["peers"][nu
return peer.id ?? peer.rosId ?? `${iface.id}-${peer.publicKey}-${index}` return peer.id ?? peer.rosId ?? `${iface.id}-${peer.publicKey}-${index}`
} }
export default function WireGuardPage() { function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterfacesHref, onSelectServer }: {
selectedServerId: string
selectedInterfaceId: string | null
allInterfacesHref: string
onSelectServer: (id: string) => void
}) {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
const canWrite = !isLive || can("mm:network:write")
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([]) const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([]) const [liveServers, setLiveServers] = useState<Server[]>([])
const [failures, setFailures] = useState<WgFailure[]>([]) const [failures, setFailures] = useState<WgFailure[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState(false)
const [readError, setReadError] = useState<string | null>(null)
const [receivedAt, setReceivedAt] = useState<number | null>(null)
const catalogKnown = !isLive || receivedAt !== null
const active = useRef(true)
const readRevision = useRef(0)
useEffect(() => { active.current = true; return () => { active.current = false } }, [])
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const toggleLock = useRef(false)
const [togglePending, setTogglePending] = useState<string | null>(null)
const [toggleError, setToggleError] = useState<string | null>(null)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const { tab: workspaceTab, setTab: setWorkspaceTab, status: statusFilter, setStatus: setStatusFilter, search, setSearch, error: urlError, resetFilters } = useWireGuardUrlState()
const [workspaceTab, setWorkspaceTab] = useState<WgWorkspaceTab>("interfaces")
const [statusFilter, setStatusFilter] = useState<WgStatusFilter>("all")
const [search, setSearch] = useState("")
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
const formLock = useRef(false)
const [createError, setCreateError] = useState<string | null>(null)
const [peerError, setPeerError] = useState<string | null>(null)
const [importError, setImportError] = useState<string | null>(null)
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null) const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc") const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
const [exportPeerId, setExportPeerId] = useState<string | null>(null) const [exportPeerId, setExportPeerId] = useState<string | null>(null)
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null) const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null) const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
const pendingPeer = pendingDelete?.kind === "peer" ? pendingDelete.iface.peers.find((peer, index) => peerRowId(pendingDelete.iface, peer, index) === pendingDelete.peerId) : undefined
const deleteLock = useRef(false)
const [deleteBusy, setDeleteBusy] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState<string | null>(null)
const historyEpoch = useRef(0)
useEffect(() => () => { historyEpoch.current += 1 }, [])
const restoreLock = useRef(false)
const [historyRestoring, setHistoryRestoring] = useState(false)
const [liveExport, setLiveExport] = useState<{ const [liveExport, setLiveExport] = useState<{
rsc?: string rsc?: string
conf?: string conf?: string
peerConf?: string peerConf?: string
} | null>(null) } | null>(null)
const [exportBusy, setExportBusy] = useState(false) const [exportBusy, setExportBusy] = useState(false)
const exportEpoch = useRef(0)
const exportPending = useRef<number | null>(null)
useEffect(() => () => { exportEpoch.current += 1 }, [])
const loadLive = useCallback(async () => { const loadLive = useCallback(async () => {
if (!isLive) return if (!isLive || !active.current) return
const revision = ++readRevision.current
setLoading(true) setLoading(true)
try { try {
const [wg, servers] = await Promise.all([ const [wg, servers] = await Promise.all([
listWireGuard(backendUrl), listWireGuard(backendUrl),
requestJson<BackendServer[]>(backendUrl, "/api/servers"), requestJson<unknown>(backendUrl, "/api/servers"),
]) ])
setLiveIfaces(wg.interfaces.map(dtoToRow)) if (!active.current || revision !== readRevision.current) return
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer)) const parsedServers = wireGuardServersSchema.safeParse(servers)
if (!parsedServers.success) throw new Error("Ответ сервера содержит некорректный каталог устройств WireGuard")
const nextIfaces = wg.interfaces.map(dtoToRow), nextServers = parsedServers.data.filter(s => s.enabled).map(mapBackendServer)
setLiveIfaces(nextIfaces)
setLiveServers(nextServers)
setReceivedAt(Date.now())
setReadError(null)
setFailures(wg.failures ?? []) setFailures(wg.failures ?? [])
setLoadError(false)
if (wg.failures?.length) { if (wg.failures?.length) {
toast.warning( if (active.current) toast.warning(
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`, `Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
) )
} }
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard") if (!active.current || revision !== readRevision.current) return
setLiveIfaces([]) setReadError(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
setFailures([]) setLoadError(true)
} finally { } finally {
setLoading(false) if (active.current && revision === readRevision.current) setLoading(false)
} }
}, [isLive, backendUrl]) }, [isLive, backendUrl])
@@ -237,15 +286,61 @@ export default function WireGuardPage() {
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces() const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled) const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId = const effectiveServerId = selectedServerId
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId) const selectedSourceError = !!urlError || isLive && (!catalogKnown || loadError || failures.some(f => f.serverId === selectedServerId))
? selectedServerId
: ALL_SERVERS_ID const completeScope = catalogKnown && !urlError && (!isLive || !failures.some(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId))
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
const loadRevisions = useCallback(async () => {
if (!isLive || !historyServerId || !active.current) return
const epoch = ++historyEpoch.current
setHistoryLoading(true)
setHistoryError(null)
try {
const revisions = await listWireGuardRevisions(backendUrl, historyServerId)
if (epoch !== historyEpoch.current) return
setRevisions(revisions)
} catch (error) {
if (epoch !== historyEpoch.current) return
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
} finally {
if (epoch === historyEpoch.current) setHistoryLoading(false)
}
}, [isLive, historyServerId, backendUrl])
const restoreRevision = useCallback(async (id: string) => {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение конфигурации")
if (!isLive || !historyServerId || restoreLock.current) return
restoreLock.current = true
setHistoryRestoring(true)
try {
const result = await requestJson<{ ok: boolean }>(
backendUrl,
`/api/wireguard/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
)
if (result?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
if (active.current) toast.success("Версия применена на роутер")
await loadLive()
await loadRevisions()
} catch (err) {
throw err
} finally {
restoreLock.current = false
setHistoryRestoring(false)
}
}, [isLive, historyServerId, backendUrl, loadLive, loadRevisions])
const scopedIfaces = useMemo(() => { const scopedIfaces = useMemo(() => {
if (urlError) return []
if (selectedInterfaceId !== null) return displayIfaces.filter(i => i.serverId === effectiveServerId && i.id === selectedInterfaceId)
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
return displayIfaces.filter((i) => i.serverId === effectiveServerId) return displayIfaces.filter((i) => i.serverId === effectiveServerId)
}, [displayIfaces, effectiveServerId]) }, [displayIfaces, effectiveServerId, selectedInterfaceId, urlError])
const selectedInterface = selectedInterfaceId === null ? undefined : scopedIfaces[0]
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {
let up = 0 let up = 0
@@ -309,8 +404,8 @@ export default function WireGuardPage() {
}, [scopedIfaces, search]) }, [scopedIfaces, search])
const totalPeers = scopedIfaces.reduce((s, i) => s + i.peers.length, 0) const totalPeers = scopedIfaces.reduce((s, i) => s + i.peers.length, 0)
const onlinePeers = scopedIfaces.reduce( const peersWithHandshakeData = scopedIfaces.reduce(
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, (s, i) => s + i.peers.filter((p) => !!p.latestHandshake?.trim()).length,
0, 0,
) )
const upIfaces = scopedIfaces.filter((i) => i.status === "up").length const upIfaces = scopedIfaces.filter((i) => i.status === "up").length
@@ -336,19 +431,23 @@ export default function WireGuardPage() {
country: s.country, country: s.country,
status: s.status, status: s.status,
type: s.type, type: s.type,
count: counts.get(s.id) ?? 0, count: !catalogKnown || failures.some(f => f.serverId === s.id) ? undefined : counts.get(s.id) ?? 0,
enabled: s.enabled, enabled: s.enabled,
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "), title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
})) }))
}, [displayServers, displayIfaces]) }, [displayServers, displayIfaces, catalogKnown, failures])
async function handleCreate(form: WgCreateFormState) { async function handleCreate(form: WgCreateFormState) {
if (urlError || !catalogKnown || loadError || formLock.current) return
if (!isLive) { if (!isLive) {
toast.info("Создание на роутер доступно только в live-режиме") if (active.current) toast.info("Создание на роутер доступно только в live-режиме")
return return
} }
formLock.current = true
setCreateError(null)
setBusy(true) setBusy(true)
try { try {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
const ep = parseEndpoint(form.peerEndpoint) const ep = parseEndpoint(form.peerEndpoint)
await createWireGuardInterface(backendUrl, { await createWireGuardInterface(backendUrl, {
serverId: form.serverId, serverId: form.serverId,
@@ -372,12 +471,13 @@ export default function WireGuardPage() {
} }
: undefined, : undefined,
}) })
toast.success(`Интерфейс ${form.name} создан`) if (active.current) toast.success(`Интерфейс ${form.name} создан`)
setCreateOpen(false) setCreateOpen(false)
await loadLive() await loadLive()
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка создания") setCreateError(e instanceof Error ? e.message : "Ошибка создания")
} finally { } finally {
formLock.current = false
setBusy(false) setBusy(false)
} }
} }
@@ -388,19 +488,27 @@ export default function WireGuardPage() {
format: "auto" | "rsc" | "conf" format: "auto" | "rsc" | "conf"
dryRun: boolean dryRun: boolean
}) { }) {
if (formLock.current) return
if (!isLive) { if (!isLive) {
toast.info("Импорт на роутер доступен только в live-режиме") if (active.current) toast.info("Импорт на роутер доступен только в live-режиме")
return return
} }
formLock.current = true
setImportError(null)
setBusy(true) setBusy(true)
try { try {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
const res = await importWireGuard(backendUrl, { const res = await importWireGuard(backendUrl, {
serverId: args.serverId, serverId: args.serverId,
content: args.content, content: args.content,
format: args.format, format: args.format,
dryRun: args.dryRun, dryRun: args.dryRun,
}) })
toast.success( if (res.dryRun) {
if (active.current) toast.info("Конфигурация проверена без применения на роутер")
return
}
if (active.current) toast.success(
res.applied res.applied
? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)` ? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)`
: "Импорт выполнен", : "Импорт выполнен",
@@ -408,61 +516,95 @@ export default function WireGuardPage() {
setImportOpen(false) setImportOpen(false)
await loadLive() await loadLive()
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка импорта") setImportError(e instanceof Error ? e.message : "Ошибка импорта")
} finally { } finally {
formLock.current = false
setBusy(false) setBusy(false)
} }
} }
function sourceUnavailable(serverId: string | undefined) {
const unavailable = !!urlError || isLive && (loadError || failures.some(f => f.serverId === serverId))
if (unavailable && active.current) toast.error("Обновите данные сервера перед изменением объекта")
return unavailable
}
async function handleToggle(iface: WgIfaceWithServer) { async function handleToggle(iface: WgIfaceWithServer) {
if (toggleLock.current) return
if (sourceUnavailable(iface.serverId)) return
if (!isLive || !iface.rosId) { if (!isLive || !iface.rosId) {
toast.info("Доступно только в live-режиме") if (active.current) toast.info("Доступно только в live-режиме")
return return
} }
toggleLock.current = true
const context = `${iface.name} · ${iface.serverName}`
setTogglePending(context)
setToggleError(null)
try { try {
await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, { if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
const result = await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, {
disabled: iface.enabled, disabled: iface.enabled,
}) })
toast.success(iface.enabled ? "Отключено" : "Включено") if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение интерфейса")
if (active.current) toast.success(iface.enabled ? "Отключено" : "Включено")
await loadLive() await loadLive()
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка") setToggleError(`${context}: ${e instanceof Error ? e.message : "Ошибка переключения"}`)
} finally {
toggleLock.current = false
setTogglePending(null)
} }
} }
async function confirmDelete() { async function confirmDelete() {
if (!pendingDelete) return if (!pendingDelete || deleteLock.current) return
if (sourceUnavailable(pendingDelete.iface.serverId)) return
if (!isLive) { if (!isLive) {
toast.info("Доступно только в live-режиме") if (active.current) toast.info("Доступно только в live-режиме")
setPendingDelete(null) setPendingDelete(null)
return return
} }
deleteLock.current = true
setDeleteBusy(true)
setDeleteError(null)
try { try {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
if (pendingDelete.kind === "iface") { if (pendingDelete.kind === "iface") {
if (!pendingDelete.iface.rosId) { if (!pendingDelete.iface.rosId) {
toast.info("Доступно только в live-режиме") if (active.current) toast.info("Доступно только в live-режиме")
return return
} }
await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId) const result = await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId)
toast.success("Удалено") if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
if (active.current) toast.success("Удалено")
} else { } else {
await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, pendingDelete.peerId) const peer = pendingDelete.iface.peers.find(p => p.id === pendingDelete.peerId || p.rosId === pendingDelete.peerId)
toast.success("Пир удалён") const result = await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, peer?.rosId ?? pendingDelete.peerId)
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
if (active.current) toast.success("Пир удалён")
} }
setPendingDelete(null) setPendingDelete(null)
await loadLive() await loadLive()
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка удаления") setDeleteError(e instanceof Error ? e.message : "Ошибка удаления")
} finally {
deleteLock.current = false
setDeleteBusy(false)
} }
} }
async function handleAddPeer(form: WgPeerFormState) { async function handleAddPeer(form: WgPeerFormState) {
if (formLock.current) return
if (sourceUnavailable(peerIface?.serverId)) return
if (!isLive || !peerIface) { if (!isLive || !peerIface) {
toast.info("Доступно только в live-режиме") if (active.current) toast.info("Доступно только в live-режиме")
return return
} }
formLock.current = true
setPeerError(null)
setBusy(true) setBusy(true)
try { try {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
const ep = parseEndpoint(form.endpoint) const ep = parseEndpoint(form.endpoint)
await createWireGuardPeer(backendUrl, { await createWireGuardPeer(backendUrl, {
serverId: peerIface.serverId, serverId: peerIface.serverId,
@@ -477,27 +619,34 @@ export default function WireGuardPage() {
persistentKeepalive: Number.parseInt(form.keepalive, 10) || undefined, persistentKeepalive: Number.parseInt(form.keepalive, 10) || undefined,
comment: form.comment || undefined, comment: form.comment || undefined,
}) })
toast.success("Пир добавлен") if (active.current) toast.success("Пир добавлен")
setPeerIface(null) setPeerIface(null)
await loadLive() await loadLive()
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка") setPeerError(e instanceof Error ? e.message : "Ошибка")
} finally { } finally {
formLock.current = false
setBusy(false) setBusy(false)
} }
} }
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") { async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
if (!exportIface || !isLive) return if (!exportIface || !isLive || exportPending.current !== null) return
const epoch = exportEpoch.current
exportPending.current = epoch
setExportBusy(true) setExportBusy(true)
try { try {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
const peer = format === "peer-conf" ? selectWgExportPeer(exportIface.peers, exportPeerId) : undefined
if (format === "peer-conf" && !peer) throw new Error("Выбранный пир не найден. Обновите список и выберите пира заново.")
const res = await exportWireGuard(backendUrl, { const res = await exportWireGuard(backendUrl, {
serverId: exportIface.serverId, serverId: exportIface.serverId,
interfaceName: exportIface.name, interfaceName: exportIface.name,
format, format,
includePrivateKey: format !== "peer-conf", includePrivateKey: format !== "peer-conf",
peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined, peerId: peer?.rosId ?? peer?.id,
}) })
if (epoch !== exportEpoch.current) return
setLiveExport((prev) => ({ setLiveExport((prev) => ({
...prev, ...prev,
...(format === "rsc" ...(format === "rsc"
@@ -506,15 +655,22 @@ export default function WireGuardPage() {
? { conf: res.content } ? { conf: res.content }
: { peerConf: res.content }), : { peerConf: res.content }),
})) }))
toast.success("Конфиг загружен с роутера") if (active.current) toast.success("Конфиг загружен с роутера")
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка экспорта") if (epoch !== exportEpoch.current) return
if (active.current) toast.error(e instanceof Error ? e.message : "Ошибка экспорта")
} finally { } finally {
setExportBusy(false) if (epoch === exportEpoch.current) {
exportPending.current = null
setExportBusy(false)
}
} }
} }
function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) { function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) {
exportEpoch.current += 1
exportPending.current = null
setExportBusy(false)
setLiveExport(null) setLiveExport(null)
setExportInitialTab(tab) setExportInitialTab(tab)
setExportPeerId(peerId ?? null) setExportPeerId(peerId ?? null)
@@ -522,7 +678,7 @@ export default function WireGuardPage() {
} }
const emptyCreateAction = ( const emptyCreateAction = (
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
Новый интерфейс Новый интерфейс
</Button> </Button>
@@ -533,10 +689,12 @@ export default function WireGuardPage() {
<ServerRailLayout <ServerRailLayout
items={railItems} items={railItems}
selectedId={effectiveServerId} selectedId={effectiveServerId}
onSelect={setSelectedServerId} preserveSelection
onSelect={onSelectServer}
showAll showAll
allCount={displayIfaces.length} allCount={completeScope ? displayIfaces.length : undefined}
loading={isLive && loading && displayServers.length === 0} loading={isLive && loading && displayServers.length === 0}
banner={!canWrite ? <p role="status" className="border-b px-4 py-2 text-sm text-muted-foreground">Режим просмотра. Изменение конфигурации и экспорт ключей недоступны.</p> : undefined}
header={ header={
<PageHeader <PageHeader
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]} crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
@@ -544,6 +702,7 @@ export default function WireGuardPage() {
<> <>
<ServerRailMobileButton /> <ServerRailMobileButton />
{isLive && ( {isLive && (
<>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -553,12 +712,26 @@ export default function WireGuardPage() {
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} /> <RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить Обновить
</Button> </Button>
<Button
size="sm"
variant="outline"
disabled={loading || !historyServerId}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
>
<HistoryIcon className="size-4" />
История
</Button>
</>
)} )}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}> <Button size="sm" variant="outline" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setImportError(null); setImportOpen(true) }}>
<UploadIcon className="size-4" /> <UploadIcon className="size-4" />
Импорт Импорт
</Button> </Button>
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
Новый интерфейс Новый интерфейс
</Button> </Button>
@@ -568,34 +741,51 @@ export default function WireGuardPage() {
} }
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{urlError && <Alert variant="warning" role="alert" aria-label="Фильтры WireGuard"><AlertTitle>Проверьте ссылку</AlertTitle><AlertDescription><p>{urlError}</p><Button type="button" variant="outline" onClick={resetFilters}>Сбросить фильтры</Button></AlertDescription></Alert>}
{isLive && <Alert variant={loadError ? "warning" : "info"} role={loadError ? "alert" : "status"} aria-label="Чтение WireGuard"><AlertTitle>{loadError ? "Не удалось обновить WireGuard" : loading ? "Обновление WireGuard" : "Данные WireGuard"}</AlertTitle><AlertDescription>{loadError && readError && <p className="wrap-anywhere">{readError}</p>}{receivedAt !== null ? <p>Получены {new Date(receivedAt).toLocaleTimeString("ru-RU")}.{loadError ? " Показаны последние полученные данные. Повторите обновление перед изменением объектов." : loading ? " Выполняется обновление." : ""}</p> : <p>{loadError ? "Каталог пока неизвестен. Повторите обновление." : "Ожидание ответа сервера."}</p>}</AlertDescription></Alert>}
{selectedInterfaceId !== null && (
<Alert variant={selectedSourceError || ((!isLive || !loading) && !selectedInterface) ? "warning" : "info"} role="status" aria-label="WireGuard по ссылке">
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedInterface ? "Выбран интерфейс: " + selectedInterface.name : "WireGuard по ссылке"}</AlertTitle>
<AlertDescription>
<p className="wrap-anywhere">{isLive && loading ? "Загрузка интерфейса…" : selectedSourceError ? "Не удалось получить данные выбранного сервера. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedInterface ? selectedInterface.serverName + " · " + (selectedInterface.address ?? selectedInterface.id) + ". Список, пиры и сводка ограничены выбранным интерфейсом." : "Интерфейс не найден или недоступен на указанном сервере. Проверьте ссылку и доступ к устройству."}</p>
<div className="flex flex-wrap gap-4">
<Link href={allInterfacesHref} className="underline underline-offset-2">Все интерфейсы сервера</Link>
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
</div>
</AlertDescription>
</Alert>
)}
{togglePending && <p role="status" className="text-sm text-muted-foreground">Изменение интерфейса {togglePending}…</p>}
{toggleError && <p role="alert" className="text-sm text-destructive">{toggleError}</p>}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка WireGuard" aria-label="Сводка WireGuard"
items={[ items={[
{ {
id: "ifaces", id: "ifaces",
label: "Интерфейсов", label: "Интерфейсов",
value: scopedIfaces.length, value: completeScope ? scopedIfaces.length : "—",
icon: <ShieldCheckIcon className="size-4" />, icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
{ {
id: "up", id: "up",
label: "Активных (UP)", label: "Активных (UP)",
value: upIfaces, value: completeScope ? upIfaces : "—",
icon: <ActivityIcon className="size-4" />, icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success", iconClassName: "text-success",
}, },
{ {
id: "peers", id: "peers",
label: "Всего пиров", label: "Всего пиров",
value: totalPeers, value: completeScope ? totalPeers : "—",
icon: <UsersIcon className="size-4" />, icon: <UsersIcon className="size-4" />,
iconClassName: "text-info", iconClassName: "text-info",
}, },
{ {
id: "online", id: "handshake-data",
label: "Пиров онлайн", label: "Данные рукопожатия",
value: `${onlinePeers}/${totalPeers}`, value: completeScope ? `${peersWithHandshakeData}/${totalPeers}` : "—",
hint: <span className="block text-xs text-muted-foreground">Доступность пиров не проверена</span>,
icon: <KeyRoundIcon className="size-4" />, icon: <KeyRoundIcon className="size-4" />,
iconClassName: "text-primary", iconClassName: "text-primary",
}, },
@@ -648,7 +838,7 @@ export default function WireGuardPage() {
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, серверу, IP…" searchPlaceholder="Поиск по имени, серверу, IP…"
countLabel={`${filteredIfaces.length} интерфейсов`} countLabel={catalogKnown && !urlError ? `${filteredIfaces.length} интерфейсов` : "Количество неизвестно"}
segmented={{ segmented={{
value: statusFilter, value: statusFilter,
onChange: setStatusFilter, onChange: setStatusFilter,
@@ -659,17 +849,19 @@ export default function WireGuardPage() {
], ],
}} }}
/> />
<WireguardDataGrid {catalogKnown && !urlError && (completeScope || filteredIfaces.length > 0) ? <WireguardDataGrid
interfaces={filteredIfaces} interfaces={filteredIfaces}
canWrite={canWrite}
compactServer={compactServer} compactServer={compactServer}
emptyAction={emptyCreateAction} emptyAction={emptyCreateAction}
onExport={(iface) => openExport(iface, "rsc")} onExport={(iface) => openExport(iface, "rsc")}
onAddPeer={setPeerIface} onAddPeer={!canWrite || selectedSourceError ? undefined : iface => { setPeerError(null); setPeerIface(iface) }}
onToggle={handleToggle} onToggle={!canWrite || selectedSourceError ? undefined : handleToggle}
onDelete={(iface) => setPendingDelete({ kind: "iface", iface })} toggleBusy={togglePending !== null}
onDeletePeer={(iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })} onDelete={!canWrite || selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })}
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)} onDeletePeer={!canWrite || selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
/> onExportPeer={canWrite ? (iface, peerId) => openExport(iface, "peer", peerId) : undefined}
/> : <p role="status" className="px-5 py-8 text-sm text-muted-foreground">Каталог интерфейсов пока неизвестен</p>}
</DataPageCard> </DataPageCard>
</TabsContent> </TabsContent>
@@ -679,30 +871,30 @@ export default function WireGuardPage() {
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по ключу, IP, endpoint…" searchPlaceholder="Поиск по ключу, IP, endpoint…"
countLabel={`${peerRows.length} пиров`} countLabel={catalogKnown && !urlError ? `${peerRows.length} пиров` : "Количество неизвестно"}
/> />
<WireguardPeersGrid {catalogKnown && !urlError && (completeScope || peerRows.length > 0) ? <WireguardPeersGrid
peers={peerRows} peers={peerRows}
compactServer={compactServer} compactServer={compactServer}
emptyAction={ emptyAction={
scopedIfaces.length === 1 ? ( scopedIfaces.length === 1 ? (
<Button size="sm" onClick={() => setPeerIface(scopedIfaces[0])}> <Button size="sm" disabled={!canWrite || selectedSourceError} onClick={() => { setPeerError(null); setPeerIface(scopedIfaces[0]) }}>
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
Добавить пира Добавить пира
</Button> </Button>
) : emptyCreateAction ) : emptyCreateAction
} }
onDeletePeer={(row) => { onDeletePeer={!canWrite || selectedSourceError ? undefined : (row) => {
const iface = scopedIfaces.find((i) => i.id === row.ifaceId) const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId)
if (!iface) return if (!iface) return
setPendingDelete({ kind: "peer", iface, peerId: row.id }) setPendingDelete({ kind: "peer", iface, peerId: row.id })
}} }}
onExportPeer={(row) => { onExportPeer={canWrite ? (row) => {
const iface = scopedIfaces.find((i) => i.id === row.ifaceId) const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId)
if (!iface) return if (!iface) return
openExport(iface, "peer", row.id) openExport(iface, "peer", row.id)
}} } : undefined}
/> /> : <p role="status" className="px-5 py-8 text-sm text-muted-foreground">Каталог пиров пока неизвестен</p>}
</DataPageCard> </DataPageCard>
</TabsContent> </TabsContent>
@@ -760,7 +952,8 @@ export default function WireGuardPage() {
<WgCreateSheet <WgCreateSheet
open={createOpen} open={createOpen}
onOpenChange={setCreateOpen} onOpenChange={v => { if (!formLock.current) setCreateOpen(v) }}
error={createError}
servers={serverOptions} servers={serverOptions}
defaultServerId={sheetServerId} defaultServerId={sheetServerId}
busy={busy} busy={busy}
@@ -768,7 +961,8 @@ export default function WireGuardPage() {
/> />
<WgImportSheet <WgImportSheet
open={importOpen} open={importOpen}
onOpenChange={setImportOpen} onOpenChange={v => { if (!formLock.current) setImportOpen(v) }}
error={importError}
servers={serverOptions} servers={serverOptions}
defaultServerId={sheetServerId} defaultServerId={sheetServerId}
busy={busy} busy={busy}
@@ -778,15 +972,20 @@ export default function WireGuardPage() {
open={!!peerIface} open={!!peerIface}
iface={peerIface} iface={peerIface}
busy={busy} busy={busy}
onOpenChange={(v) => { if (!v) setPeerIface(null) }} onOpenChange={(v) => { if (!v && !formLock.current) setPeerIface(null) }}
error={peerError}
onSubmit={handleAddPeer} onSubmit={handleAddPeer}
/> />
<WgExportSheet <WgExportSheet
open={!!exportIface} open={!!exportIface}
iface={exportIface} iface={exportIface}
serverHost={displayServers.find(server => server.id === exportIface?.serverId)?.host}
initialTab={exportInitialTab} initialTab={exportInitialTab}
peerId={exportPeerId} peerId={exportPeerId}
onClose={() => { onClose={() => {
exportEpoch.current += 1
exportPending.current = null
setExportBusy(false)
setExportIface(null) setExportIface(null)
setExportPeerId(null) setExportPeerId(null)
setExportInitialTab("rsc") setExportInitialTab("rsc")
@@ -797,7 +996,7 @@ export default function WireGuardPage() {
onRequestLiveExport={isLive ? handleLiveExport : undefined} onRequestLiveExport={isLive ? handleLiveExport : undefined}
/> />
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}> <AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null) } }}>
<AlertDialogContent size="default"> <AlertDialogContent size="default">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive"> <AlertDialogMedia className="bg-destructive/10 text-destructive">
@@ -806,22 +1005,59 @@ export default function WireGuardPage() {
<AlertDialogTitle> <AlertDialogTitle>
{pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"} {pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription className="break-all">
{pendingDelete?.kind === "iface" {pendingDelete?.kind === "iface"
? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры на роутере.` ? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры и IP-адреса на роутере.`
: pendingDelete : pendingDelete
? `Пир на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).` ? `Пир «${pendingPeer?.name || pendingPeer?.publicKey || pendingDelete.peerId}» (ID ${pendingDelete.peerId}) на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).`
: null} : null}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
{deleteBusy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения удаления…</p>}
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel> <AlertDialogCancel disabled={deleteBusy}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => void confirmDelete()}> <AlertDialogAction type="button" variant="destructive" disabled={deleteBusy} onClick={() => void confirmDelete()}>
Удалить {deleteBusy ? "Удаление…" : "Удалить"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
loadError={historyError}
onRetry={() => void loadRevisions()}
title="История WireGuard"
itemLabel="интерфейсов"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
restoreDisabled={!can("mm:network:write") || !isLive || !historyServerId || !displayServers.some(s => s.id === historyServerId && s.enabled) || loading || busy || deleteBusy || !!togglePending}
onRestore={restoreRevision}
/>
</> </>
) )
} }
function WireGuardRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const router = useRouter()
const serverId = params.get("server") ?? ALL_SERVERS_ID
const interfaceId = params.get("interface")
const rest = new URLSearchParams(params.toString())
rest.delete("interface")
const allInterfacesHref = "/wireguard" + (rest.size ? "?" + rest.toString() : "")
return <WireGuardPageContent key={JSON.stringify([serverId, interfaceId, mode, backendUrl])} selectedServerId={serverId} selectedInterfaceId={interfaceId} allInterfacesHref={allInterfacesHref} onSelectServer={id => {
const next = new URLSearchParams(rest)
if (id === ALL_SERVERS_ID) next.delete("server")
else next.set("server", id)
router.push("/wireguard" + (next.size ? "?" + next.toString() : ""))
}} />
}
export default function WireGuardPage() {
return <Suspense fallback={<div role="status">Загрузка WireGuard…</div>}><WireGuardRoute /></Suspense>
}
+12 -3
View File
@@ -5,15 +5,18 @@ import { useRouter } from "next/navigation"
import { import {
clearPortalHandoffFlag, clearPortalHandoffFlag,
clearToken, clearToken,
can,
ensureAuthConfig, ensureAuthConfig,
firstAllowedPath, firstAllowedPath,
getClaims, getClaims,
getToken, getToken,
parseHashToken, parseHashToken,
permissionForPath,
redirectToPortalLogin, redirectToPortalLogin,
redirectToPortalLoginInteractive, redirectToPortalLoginInteractive,
setToken, setToken,
} from "@/lib/auth" } from "@/lib/auth"
import { permittedAuthReturnPath } from "@/lib/auth-return-path"
export default function AuthCallbackPage() { export default function AuthCallbackPage() {
const router = useRouter() const router = useRouter()
@@ -28,6 +31,10 @@ export default function AuthCallbackPage() {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const error = params.get("error") const error = params.get("error")
const returnPath = () => permittedAuthReturnPath(params.get("next"), pathname => {
const permission = permissionForPath(pathname)
return !permission || can(permission)
}, firstAllowedPath())
if (error === "sso_loop" || error === "jwt_rejected") { if (error === "sso_loop" || error === "jwt_rejected") {
redirectToPortalLoginInteractive() redirectToPortalLoginInteractive()
return return
@@ -62,7 +69,7 @@ export default function AuthCallbackPage() {
/* ignore network — proceed */ /* ignore network — proceed */
} }
const next = firstAllowedPath() const next = returnPath()
if (next === "/access-denied") { if (next === "/access-denied") {
router.replace("/access-denied") router.replace("/access-denied")
return return
@@ -77,11 +84,13 @@ export default function AuthCallbackPage() {
router.replace("/access-denied") router.replace("/access-denied")
return return
} }
router.replace(firstAllowedPath()) router.replace(returnPath())
return return
} }
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`) const callback = new URL(`${window.location.origin}/auth/callback`)
if (params.get("next")) callback.searchParams.set("next", params.get("next")!)
const ok = redirectToPortalLogin(callback.toString())
if (!ok) redirectToPortalLoginInteractive() if (!ok) redirectToPortalLoginInteractive()
})() })()
+12
View File
@@ -2,6 +2,18 @@
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
/* SVG focus follows the object shape, never the text/foreignObject bounding box. */
.network-map-focusable {
outline: none;
}
.network-map-focus-ring {
opacity: 0;
pointer-events: none;
}
.network-map-focusable:focus-visible > .network-map-focus-ring {
opacity: 1;
}
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
+2
View File
@@ -17,3 +17,5 @@ AUTH_REQUIRED=false
AUTH_JWT_SECRET=dev-secret-change-me AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175 AUTH_PORTAL_URL=http://localhost:5175
# Optional: enabled by the NetFlow agent Compose installer. Linux Unix socket only.
# FLOW_AGENT_SOCKET=/run/mm-netflow-host/agent.sock
+1
View File
@@ -5,3 +5,4 @@ dist/
*.db-wal *.db-wal
.env .env
storage/backups/ storage/backups/
storage/geoip/
+10 -8
View File
@@ -37,6 +37,15 @@ RUN npm run build -w @mmapp/contracts \
FROM node:22-bookworm-slim AS runner FROM node:22-bookworm-slim AS runner
WORKDIR /app WORKDIR /app
# Keep system packages independent of application changes and version tags.
RUN apt-get update \
&& apt-get install -y --no-install-recommends gnupg wget ca-certificates \
&& wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client-18 \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /app/data
ARG APP_VERSION=dev ARG APP_VERSION=dev
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=8000 ENV PORT=8000
@@ -48,15 +57,8 @@ COPY --from=build /app/packages/contracts ./packages/contracts
COPY --from=build /app/backend/dist ./backend/dist COPY --from=build /app/backend/dist ./backend/dist
COPY --from=build /app/backend/package.json ./backend/package.json COPY --from=build /app/backend/package.json ./backend/package.json
COPY --from=build /app/backend/drizzle ./backend/drizzle COPY --from=build /app/backend/drizzle ./backend/drizzle
COPY deploy/netflow-agent/install.sh ./deploy/netflow-agent/install.sh
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules # Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
COPY --from=build /app/backend/node_modules ./backend/node_modules COPY --from=build /app/backend/node_modules ./backend/node_modules
RUN apt-get update \
&& apt-get install -y --no-install-recommends gnupg wget ca-certificates \
&& wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client-18 \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /app/data
EXPOSE 8000 EXPOSE 8000
CMD ["node", "backend/dist/index.js"] CMD ["node", "backend/dist/index.js"]
+620
View File
@@ -0,0 +1,620 @@
-- Compact PostgreSQL 18 schema: wipe all app tables (not schema_migrations),
-- recreate with smaller time-series rows. SQLite is re-imported after this.
DO $$
DECLARE
r record;
BEGIN
FOR r IN
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename <> 'schema_migrations'
LOOP
EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.tablename);
END LOOP;
END $$;
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS servers (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 443,
username TEXT NOT NULL DEFAULT 'admin',
password TEXT NOT NULL DEFAULT '',
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
type TEXT NOT NULL DEFAULT 'home-router'
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
site TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
asn TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
lan_subnet TEXT NOT NULL DEFAULT '',
wan_uplinks JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS traffic_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 30,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
interval_sec INTEGER NOT NULL DEFAULT 120,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
public_endpoint TEXT NOT NULL DEFAULT '',
host_public_key TEXT NOT NULL DEFAULT '',
host_private_key TEXT NOT NULL DEFAULT '',
hub_server_id BIGINT,
retention_hours INTEGER NOT NULL DEFAULT 24,
top_n INTEGER NOT NULL DEFAULT 200,
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
last_datagram_at TIMESTAMPTZ,
last_exporter_ip TEXT,
last_error TEXT,
packets_received BIGINT NOT NULL DEFAULT 0,
peers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS uptime_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 15,
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS evobgp_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
base_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
bot_token TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '',
message_thread_id INTEGER,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS acme_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
cloudflare_api_token TEXT NOT NULL DEFAULT '',
default_zone_id TEXT NOT NULL DEFAULT '',
account_private_key TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 21600,
renew_before_days INTEGER NOT NULL DEFAULT 30,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
hour INTEGER NOT NULL DEFAULT 3,
minute INTEGER NOT NULL DEFAULT 0,
week_day INTEGER NOT NULL DEFAULT 0,
month_day INTEGER NOT NULL DEFAULT 1,
keep_count INTEGER NOT NULL DEFAULT 7,
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
server_ids_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
last_run_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS internet_path_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 300,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
id BIGINT PRIMARY KEY CHECK (id = 1),
last_source_finished_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS data_migration (
id INTEGER PRIMARY KEY CHECK (id = 1),
sqlite_imported_at TIMESTAMPTZ,
sqlite_path TEXT,
sqlite_sha256 TEXT,
report_json JSONB COMPRESSION lz4
);
CREATE TABLE IF NOT EXISTS filter_rules (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
community TEXT NOT NULL,
community_name TEXT,
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
gateway TEXT NOT NULL DEFAULT '',
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
CREATE TABLE IF NOT EXISTS recursive_routes (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
dst_address TEXT NOT NULL,
gateway TEXT NOT NULL,
distance INTEGER NOT NULL DEFAULT 1,
scope INTEGER,
target_scope INTEGER,
routing_table TEXT NOT NULL DEFAULT 'main',
check_gateway TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
disabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
CREATE TABLE IF NOT EXISTS server_snapshots (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
polled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
latency_ms DOUBLE PRECISION,
ros_version TEXT,
board_name TEXT,
uptime TEXT,
cpu_load INTEGER,
free_memory BIGINT,
total_memory BIGINT,
identity_name TEXT,
raw_interfaces JSONB COMPRESSION lz4,
raw_ip_addresses JSONB COMPRESSION lz4,
PRIMARY KEY (id, polled_at)
) PARTITION BY RANGE (polled_at);
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
CREATE TABLE IF NOT EXISTS traffic_samples (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
interface_name TEXT NOT NULL,
peer_public_key TEXT NOT NULL DEFAULT '',
sampled_at TIMESTAMPTZ NOT NULL,
rx_bytes BIGINT NOT NULL DEFAULT 0,
tx_bytes BIGINT NOT NULL DEFAULT 0,
rx_bps BIGINT NOT NULL DEFAULT 0,
tx_bps BIGINT NOT NULL DEFAULT 0,
flags SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, sampled_at, interface_name, peer_public_key)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sampled_at TIMESTAMPTZ NOT NULL,
ok BOOLEAN NOT NULL,
latency_ms INTEGER,
error TEXT,
PRIMARY KEY (server_id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE TABLE IF NOT EXISTS flow_buckets (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
src INET NOT NULL,
dst INET NOT NULL,
proto SMALLINT NOT NULL DEFAULT 0,
src_port INTEGER NOT NULL DEFAULT 0,
dst_port INTEGER NOT NULL DEFAULT 0,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
in_iface TEXT NOT NULL DEFAULT '',
out_iface TEXT NOT NULL DEFAULT '',
next_hop INET,
flow_start_ms BIGINT NOT NULL DEFAULT 0,
flow_end_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
) PARTITION BY RANGE (bucket_at);
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
CREATE TABLE IF NOT EXISTS flow_minute_stats (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
unique_src INTEGER NOT NULL DEFAULT 0,
unique_dst INTEGER NOT NULL DEFAULT 0,
conversations INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at)
) PARTITION BY RANGE (bucket_at);
CREATE TABLE IF NOT EXISTS flow_minute_dims (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
dim TEXT NOT NULL,
key TEXT NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at, dim, key)
) PARTITION BY RANGE (bucket_at);
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
CREATE TABLE IF NOT EXISTS flow_daily_dims (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
day DATE NOT NULL,
dim TEXT NOT NULL,
key TEXT NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, day, dim, key)
) PARTITION BY RANGE (day);
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
CREATE TABLE IF NOT EXISTS flow_ip_meta (
prefix TEXT PRIMARY KEY,
asn INTEGER NOT NULL DEFAULT 0,
country TEXT NOT NULL DEFAULT '',
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
holder TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 1,
fetched_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS flow_asn_meta (
asn INTEGER PRIMARY KEY,
holder TEXT NOT NULL DEFAULT '',
fetched_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS uptime_probes (
id TEXT PRIMARY KEY,
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
src_interface TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
target TEXT NOT NULL,
probe_filter TEXT NOT NULL DEFAULT '—',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 0,
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
sampled_at TIMESTAMPTZ NOT NULL,
rtt_ms INTEGER,
loss_pct INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
PRIMARY KEY (probe_id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sampled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
cpu_load INTEGER NOT NULL DEFAULT 0,
free_memory BIGINT NOT NULL DEFAULT 0,
total_memory BIGINT NOT NULL DEFAULT 0,
free_hdd_space BIGINT NOT NULL DEFAULT 0,
total_hdd_space BIGINT NOT NULL DEFAULT 0,
uptime_seconds BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
id TEXT PRIMARY KEY,
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
duration_sec INTEGER NOT NULL DEFAULT 10,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_run_at TIMESTAMPTZ,
last_tx_avg_mbps DOUBLE PRECISION,
last_rx_avg_mbps DOUBLE PRECISION,
last_status TEXT CHECK (last_status IN ('done', 'error')),
last_error TEXT,
last_ping_rtt_ms INTEGER,
last_ping_loss_pct INTEGER,
last_ping_at TIMESTAMPTZ,
last_ping_error TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
id TEXT PRIMARY KEY,
probe_id TEXT,
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
src_address TEXT,
dst_address TEXT,
src_interface_address TEXT,
dst_interface_address TEXT,
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
duration_sec INTEGER NOT NULL DEFAULT 10,
tx_avg_mbps DOUBLE PRECISION,
rx_avg_mbps DOUBLE PRECISION,
ping_rtt_ms INTEGER,
ping_loss_pct INTEGER,
ping_error TEXT,
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
step TEXT NOT NULL DEFAULT 'queued',
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
cert_name TEXT NOT NULL,
domain_names JSONB COMPRESSION lz4 NOT NULL,
key_type TEXT NOT NULL DEFAULT 'rsa2048',
trust_store TEXT NOT NULL DEFAULT 'www,api',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
error TEXT
);
CREATE TABLE IF NOT EXISTS backup_entries (
id TEXT PRIMARY KEY,
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
server_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
CREATE TABLE IF NOT EXISTS alert_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
cooldown_override TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
target TEXT NOT NULL,
condition TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
cooldown TEXT NOT NULL DEFAULT '5м',
rule_chat_id TEXT NOT NULL DEFAULT '',
confirm_stability_sec INTEGER,
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
recovery_stability_sec INTEGER,
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_rule_targets (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
target TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
condition_line TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
CREATE TABLE IF NOT EXISTS alert_engine_state (
scope_key TEXT PRIMARY KEY,
last_fired_at TEXT NOT NULL DEFAULT '',
last_payload_hash TEXT
);
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
payload_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
rule_id TEXT PRIMARY KEY,
payload_hash TEXT NOT NULL,
since_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS alert_history (
id TEXT PRIMARY KEY,
rule_id TEXT,
group_id TEXT,
rule_name TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
message TEXT NOT NULL,
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
fired_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
CREATE TABLE IF NOT EXISTS alert_outbox (
id TEXT PRIMARY KEY,
dedupe_key TEXT NOT NULL,
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
next_attempt_at TIMESTAMPTZ NOT NULL,
payload_json JSONB COMPRESSION lz4 NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ,
last_error TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
CREATE TABLE IF NOT EXISTS scheduler_runs (
id TEXT PRIMARY KEY,
job_key TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
error TEXT,
duration_ms INTEGER NOT NULL DEFAULT 0,
result_json JSONB COMPRESSION lz4
);
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
event_type TEXT NOT NULL,
source_module TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
entity_type TEXT,
entity_id TEXT,
payload_json JSONB COMPRESSION lz4
);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
CREATE TABLE IF NOT EXISTS app_users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
login TEXT NOT NULL UNIQUE,
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
active BOOLEAN NOT NULL DEFAULT TRUE,
avatar TEXT NOT NULL DEFAULT '',
last_seen TIMESTAMPTZ,
sections_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
servers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_interface_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
peer_public_key TEXT NOT NULL DEFAULT '',
peer_name TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (server_id, interface_name, peer_public_key)
);
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
sampled_at TIMESTAMPTZ NOT NULL,
payload_json JSONB COMPRESSION lz4 NOT NULL,
PRIMARY KEY (id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
+44
View File
@@ -0,0 +1,44 @@
-- S3-compatible storage for RouterOS backups
CREATE TABLE IF NOT EXISTS backup_storage_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
provider TEXT NOT NULL DEFAULT 'local' CHECK (provider IN ('local', 's3')),
s3_endpoint TEXT NOT NULL DEFAULT '',
s3_region TEXT NOT NULL DEFAULT 'us-east-1',
s3_bucket TEXT NOT NULL DEFAULT '',
s3_prefix TEXT NOT NULL DEFAULT 'mikrotik',
s3_access_key_id TEXT NOT NULL DEFAULT '',
s3_secret_access_key TEXT NOT NULL DEFAULT '',
s3_force_path_style BOOLEAN NOT NULL DEFAULT TRUE,
keep_local_copy BOOLEAN NOT NULL DEFAULT TRUE,
last_test_at TIMESTAMPTZ,
last_test_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO backup_storage_settings (id)
VALUES (1)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS storage TEXT NOT NULL DEFAULT 'local';
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS s3_key TEXT;
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS s3_etag TEXT;
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS upload_error TEXT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'backup_entries_storage_check'
) THEN
ALTER TABLE backup_entries
ADD CONSTRAINT backup_entries_storage_check
CHECK (storage IN ('local', 's3', 'both'));
END IF;
END $$;
+19
View File
@@ -0,0 +1,19 @@
-- GeoLite2 mmdb (страна/ASN для netflow): настройки автообновления зеркала P3TERX
CREATE TABLE IF NOT EXISTS geoip_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
update_interval_sec INTEGER NOT NULL DEFAULT 604800,
last_check_at TIMESTAMPTZ,
last_success_at TIMESTAMPTZ,
last_error TEXT,
country_build_at TIMESTAMPTZ,
asn_build_at TIMESTAMPTZ,
etags_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO geoip_settings (id)
VALUES (1)
ON CONFLICT (id) DO NOTHING;
+28
View File
@@ -0,0 +1,28 @@
-- Statistics cube: hour + daily facts (server × iface × country × service × ASN).
-- Compact types. FILLFACTOR/autovacuum нельзя на partitioned parent (PG 42809) —
-- задаются на листовых партициях в ensurePartitionFor.
-- Retention: DROP partitions only (see PARTITION_SPECS).
CREATE TABLE IF NOT EXISTS flow_hour_facts (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
iface TEXT NOT NULL,
country CHAR(2) NOT NULL,
service TEXT NOT NULL,
asn INTEGER NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at, iface, country, service, asn)
) PARTITION BY RANGE (bucket_at);
CREATE TABLE IF NOT EXISTS flow_daily_facts (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
day DATE NOT NULL,
iface TEXT NOT NULL,
country CHAR(2) NOT NULL,
service TEXT NOT NULL,
asn INTEGER NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, day, iface, country, service, asn)
) PARTITION BY RANGE (day);
+16
View File
@@ -0,0 +1,16 @@
-- История desired/actual снапшотов managed-секций (фильтры, рекурсивные маршруты).
-- Retention — prune в сервисе (последние 50 на пару server+section).
CREATE TABLE IF NOT EXISTS config_revisions (
id TEXT PRIMARY KEY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
section TEXT NOT NULL,
source TEXT NOT NULL,
fingerprint TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '[]'::jsonb,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_config_revisions_server_section_created
ON config_revisions (server_id, section, created_at DESC);
@@ -0,0 +1,7 @@
-- IPFIX postNAT (IANA 225/226) + postNAPT ports (IANA 227/228) from MikroTik Traffic Flow.
-- Needed to rebuild facts with the same internet dest as the network map.
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src INET;
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst INET;
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src_port INTEGER NOT NULL DEFAULT 0;
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst_port INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,6 @@
-- Захват перед POST и удержание неизвестных исходов. Исторические строки не меняются.
BEGIN;
ALTER TABLE alert_outbox DROP CONSTRAINT IF EXISTS alert_outbox_status_check;
ALTER TABLE alert_outbox ADD CONSTRAINT alert_outbox_status_check
CHECK (status IN ('pending', 'processing', 'unknown', 'sent', 'failed'));
COMMIT;
@@ -0,0 +1,4 @@
BEGIN;
ALTER TABLE alert_outbox ADD COLUMN IF NOT EXISTS resolution_version integer NOT NULL DEFAULT 0;
ALTER TABLE alert_outbox ADD CONSTRAINT alert_outbox_resolution_version_check CHECK (resolution_version >= 0);
COMMIT;
@@ -0,0 +1,3 @@
-- A failed command is an attempt, not a measured packet-loss percentage.
ALTER TABLE uptime_probe_samples ADD COLUMN IF NOT EXISTS measurement_known BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE uptime_probe_samples ADD COLUMN IF NOT EXISTS observation_error TEXT;
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS traffic_collection_state (
server_id BIGINT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE,
server_updated_at TIMESTAMPTZ NOT NULL,
last_attempt_at TIMESTAMPTZ NOT NULL,
last_succeeded_at TIMESTAMPTZ,
status TEXT NOT NULL CHECK(status IN ('pending', 'ok', 'error')),
error TEXT
);
@@ -0,0 +1 @@
ALTER TABLE traffic_samples ADD COLUMN IF NOT EXISTS native_counter_identity TEXT;
@@ -0,0 +1,4 @@
-- Retention runs after each scheduler receipt; avoid scanning the complete history.
-- On a busy existing installation, pre-create this same index CONCURRENTLY
-- outside a transaction before deploying (deploy/PRODUCTION-RETENTION-INDEX.md).
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_finished_at ON scheduler_runs (finished_at);
+26
View File
@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS router_log_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), enabled BOOLEAN NOT NULL DEFAULT false,
interval_sec INTEGER NOT NULL DEFAULT 10 CHECK (interval_sec BETWEEN 5 AND 3600),
retention_days INTEGER NOT NULL DEFAULT 14 CHECK (retention_days BETWEEN 1 AND 365)
);
INSERT INTO router_log_settings(id) VALUES (1) ON CONFLICT DO NOTHING;
-- No foreign key: collected history survives device deletion.
CREATE TABLE IF NOT EXISTS router_log_collection_state (
server_id INTEGER PRIMARY KEY, server_name TEXT NOT NULL, host TEXT NOT NULL,
generation INTEGER NOT NULL DEFAULT 0, snapshot JSONB NOT NULL DEFAULT '[]',
last_collected_at TIMESTAMPTZ, last_error TEXT, gap_at TIMESTAMPTZ,
next_poll_at TIMESTAMPTZ, failures INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS router_log_entries (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, server_id INTEGER NOT NULL,
server_name TEXT NOT NULL, host TEXT NOT NULL, router_id TEXT NOT NULL, raw_time TEXT NOT NULL,
event_at TIMESTAMPTZ, received_at TIMESTAMPTZ NOT NULL,
effective_at TIMESTAMPTZ NOT NULL, topics TEXT[] NOT NULL, level TEXT NOT NULL,
message TEXT NOT NULL, generation INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS router_logs_server_time ON router_log_entries(server_id, effective_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS router_logs_time ON router_log_entries(effective_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS router_logs_level_time ON router_log_entries(level, effective_at DESC);
CREATE INDEX IF NOT EXISTS router_logs_topics ON router_log_entries USING gin(topics);
CREATE INDEX IF NOT EXISTS router_logs_received ON router_log_entries(received_at);
CREATE INDEX IF NOT EXISTS router_logs_server_cursor ON router_log_entries(server_id, id);
+34 -8
View File
@@ -4,23 +4,48 @@
"description": "MikroTik Manager backend — Fastify + Drizzle + PostgreSQL", "description": "MikroTik Manager backend — Fastify + Drizzle + PostgreSQL",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test:logs": "tsx src/services/router-logs-model.test.ts && tsx src/plugins/auth.logs.test.ts && tsx src/services/mikrotik-logs.test.ts",
"test:logs:pg": "tsx src/services/router-logs-sql.test.ts",
"test:sidebar-counts": "tsx src/services/complete-count.test.ts && tsx src/routes/sidebar-counts.test.ts",
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "npm run build -w @mmapp/contracts && tsc", "build": "npm run build -w @mmapp/contracts && tsc",
"test:alert-engine": "tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts", "test:alerts-telegram": "tsx src/services/alerts-outbox-retry.test.ts && tsx src/services/alerts-outbox-overview.test.ts && tsx src/services/alert-engine/outbox-execution.test.ts && tsx src/services/alerts-telegram-delivery.test.ts && tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && tsx ../packages/contracts/src/alerts-normalization.test.ts && tsx src/services/alerts-config-execution.test.ts && tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts && tsx src/routes/alerts.test.ts && tsx src/plugins/auth.alerts.test.ts",
"test:alert-engine": "tsx src/services/alert-engine/server-signal-identity.test.ts && tsx src/services/alert-engine/collector-source-signals.test.ts && tsx src/services/alert-engine/monitoring-history.test.ts && tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/gre-tunnels-live.test.ts && tsx src/services/bgp-peers-live.test.ts && tsx src/services/gre-bgp-snapshot-collector.test.ts && tsx src/services/gre-bgp-snapshot-history.test.ts && tsx src/services/gre-bgp-snapshot-sql.test.ts && tsx src/services/alert-engine/collector-source-sql.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts",
"start": "node dist/index.js", "start": "node dist/index.js",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts", "db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts", "facts:rebuild": "tsx src/scripts/rebuild-flow-facts.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts", "test:probes": "tsx src/routes/probes.test.ts && tsx src/routes/probes.cancel.test.ts && tsx src/services/ping-measurement.test.ts && tsx src/services/uptime-probe-observations.test.ts && tsx src/services/ping-measurement-sql.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts", "test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts", "test:auth": "tsx src/plugins/auth-path.test.ts && tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts", "test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts",
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/pg-schema.test.ts", "test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.test.ts && tsx src/services/traffic-membership.test.ts && tsx src/services/traffic-aggregate-peak.test.ts && tsx src/services/traffic-users-cache.test.ts && tsx src/services/traffic-collection-lock.test.ts && tsx src/services/traffic-concurrency-sql.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg" "test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/db/scheduler-retention-index.test.ts && tsx src/services/config-revisions.test.ts",
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-effective-properties.test.ts && tsx src/services/recursive-apply.test.ts",
"test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts",
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
"test:backups": "tsx src/services/backup-local-file-state.test.ts && tsx src/services/router-config-export.test.ts && tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
"test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-operation.test.ts && tsx src/routes/ipsec-mutation.test.ts && tsx src/routes/ipsec-nat.test.ts && tsx src/routes/ipsec-group.test.ts",
"test:live-maps": "tsx src/services/gre-keepalive.test.ts && tsx src/services/gre-read.test.ts && tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts && tsx src/routes/ospf-legacy.test.ts",
"test": "node scripts/test.mjs",
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts && tsx src/services/traffic-flow-agent-install.test.ts && tsx src/services/traffic-flow-agent-client.test.ts && tsx src/services/traffic-flow-operation-queue.test.ts && tsx src/routes/traffic-flow-host.test.ts",
"test:geoip": "tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts",
"test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
"test:certificates": "tsx src/services/certificate-installation.test.ts && tsx src/services/certificate-renew-collector.test.ts && tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts",
"test:system-database": "tsx src/services/system-database-partition-restore.test.ts && tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts",
"test:all": "npm run test:logs && npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database && npm run test:server-privacy",
"test:server-privacy": "tsx src/routes/server-secrets-sql.test.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2", "@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0", "@fastify/type-provider-zod": "^1.0.0",
@@ -31,6 +56,7 @@
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"fastify": "^5.8.5", "fastify": "^5.8.5",
"fastify-plugin": "^5.1.0", "fastify-plugin": "^5.1.0",
"maxmind": "^5.0.7",
"pg": "^8.23.0", "pg": "^8.23.0",
"undici": "^8.1.0", "undici": "^8.1.0",
"zod": "^4.4.1" "zod": "^4.4.1"
@@ -0,0 +1,16 @@
import fs from 'node:fs';import assert from 'node:assert/strict';import {chromium,expect} from '@playwright/test';
const privatePath=process.env.MMAPP_ACME_FIXTURE||'tmp/acme250-private.json',p=JSON.parse(fs.readFileSync(privatePath)),login=JSON.parse(fs.readFileSync('tmp/portal195-private.json'));
assert(/^mmt250-[a-f0-9]{8}\.pngs\.top$/.test(p.fqdn));assert(!p.jobId,'Never submit an unknown or existing issue job again');
const report={date:'2026-10-14',block:'12.5.250',fqdn:p.fqdn,certName:p.certName,serverId:p.serverId,realPortalLogin:true,noMocks:true,issuePosts:0,pageErrors:[]};
const browser=await chromium.launch({channel:'msedge',headless:true});
try{
const page=await browser.newPage({viewport:{width:1440,height:1000}});page.on('pageerror',e=>report.pageErrors.push(e.name));page.on('request',r=>{if(new URL(r.url()).pathname==='/api/certificates/issue'&&r.method()==='POST'){const b=r.postDataJSON();assert.equal(String(b.serverId),p.serverId);assert.equal(b.certName,p.certName);assert.deepEqual(b.domainNames,[p.fqdn]);p.jobId=b.requestId;assert(p.jobId);report.issuePosts++;report.jobId=p.jobId;fs.writeFileSync(privatePath,JSON.stringify(p))}});
await page.goto('http://localhost:3000/certificates?server=2');await page.getByLabel('Email',{exact:true}).fill(login.email);await page.getByLabel('Пароль',{exact:true}).fill(login.password);await page.getByRole('button',{name:'Войти',exact:true}).click();await expect(page.getByRole('navigation',{name:'Путь страницы'})).toContainText('Сертификаты',{timeout:30000});
const settings=await page.evaluate(async({token,zoneId})=>{const headers={Authorization:'Bearer '+localStorage.getItem('mmapp_token'),'Content-Type':'application/json'};const before=await fetch('/api/certificates/acme-settings',{headers});if(!before.ok)throw Error('Settings read failed');const old=await before.json();if(old.tokenConfigured)throw Error('Existing CF token must not be overwritten');const test=await fetch('/api/certificates/acme-settings/test',{method:'POST',headers,body:JSON.stringify({cloudflareApiToken:token})});const tested=await test.json();if(!test.ok||tested.ok!==true)return{testStatus:test.status,testOk:false,message:tested.message};const put=await fetch('/api/certificates/acme-settings',{method:'PUT',headers,body:JSON.stringify({directoryUrl:'https://acme-v02.api.letsencrypt.org/directory',defaultZoneId:zoneId,cloudflareApiToken:token})});const configured=await put.json();if(!put.ok||!configured.tokenConfigured)throw Error('Settings save failed');return{testStatus:test.status,testOk:true,saveStatus:put.status,tokenConfigured:configured.tokenConfigured,directoryUrl:configured.directoryUrl}}, {token:p.token,zoneId:p.zoneId});
report.settings=settings;assert.equal(settings.testOk,true);
await page.getByRole('button',{name:'Выпустить сертификат',exact:true}).click();const dialog=page.getByRole('dialog',{name:'Выпуск сертификата'});await dialog.getByRole('combobox',{name:'Сервер выпуска'}).selectOption(p.serverId);await dialog.getByRole('textbox',{name:'Имя сертификата',exact:true}).fill(p.certName);await dialog.getByRole('button',{name:'Далее',exact:true}).click();await dialog.getByRole('textbox',{name:'Основной домен',exact:true}).fill(p.fqdn);await dialog.getByRole('button',{name:'Далее',exact:true}).click();await dialog.getByRole('button',{name:'Далее',exact:true}).click();await expect(dialog).toContainText(p.fqdn);
const response=page.waitForResponse(r=>new URL(r.url()).pathname==='/api/certificates/issue'&&r.request().method()==='POST');await dialog.getByRole('button',{name:'Выпустить',exact:true}).click();const accepted=await response;assert.equal(accepted.status(),200);assert.equal((await accepted.json()).jobId,p.jobId);report.accepted=true;fs.writeFileSync('docs/ux-ui/acceptance/acme250-live-ui.json',JSON.stringify(report,null,2)+'\n');console.log('One ACME issue accepted for '+p.fqdn);
await expect.poll(()=>page.evaluate(async(jobId)=>{const r=await fetch('/api/certificates/issue/'+jobId,{headers:{Authorization:'Bearer '+localStorage.getItem('mmapp_token')}});if(!r.ok)return'unknown';return(await r.json()).status},p.jobId),{timeout:180000,intervals:[2000]}).toMatch(/^(done|failed)$/);
report.job=await page.evaluate(async(jobId)=>{const r=await fetch('/api/certificates/issue/'+jobId,{headers:{Authorization:'Bearer '+localStorage.getItem('mmapp_token')}});const b=await r.json();return{id:b.id,status:b.status,step:b.step,error:b.error}},p.jobId);assert.equal(report.issuePosts,1);assert.equal(report.job.status,'done');await expect(dialog.getByRole('status')).toContainText('Завершена',{timeout:15000});assert.deepEqual(report.pageErrors,[]);report.uiTerminalConfirmed=true;
fs.writeFileSync('docs/ux-ui/acceptance/acme250-live-ui.json',JSON.stringify(report,null,2)+'\n');console.log('ACME UI job done; one issue POST, JS errors0');
}catch(e){report.failure={name:e.name};fs.writeFileSync('docs/ux-ui/acceptance/acme250-live-ui.json',JSON.stringify(report,null,2)+'\n');console.error('ACME test incomplete: '+e.name);process.exitCode=1}finally{await browser.close()}
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import tls from 'node:tls';
import { X509Certificate } from 'node:crypto';
import { pathToFileURL } from 'node:url';
const fingerprint = value => {
assert.equal(typeof value, 'string', 'Fingerprint is required');
const normalized = value.replaceAll(':', '').toLowerCase();
assert(/^[a-f0-9]{64}$/.test(normalized), 'Expected SHA-256 fingerprint');
return normalized;
};
export function compareRenewal(before, after) {
for (const sample of [before, after]) {
assert(/^mmt253-[a-f0-9]{8}\.pngs\.top$/.test(sample.fqdn), 'Only the prepared exact test domain is permitted');
assert.equal(sample.remoteAddress, '104.165.244.7');
assert.equal(sample.authorized, true);
assert.equal(sample.hostnameMatched, true);
assert.equal(sample.nativePrivateKey, true);
assert.equal(sample.nativeName, sample.certName);
assert.equal(sample.certName, sample.fqdn.replace('.pngs.top', ''));
assert.equal(fingerprint(sample.nativeFingerprint256), fingerprint(sample.fingerprint256), 'Native leaf must match served TLS');
assert(Number.isFinite(Date.parse(sample.validUntil)), 'Expiry is required');
assert(Number.isFinite(Date.parse(sample.sampledAt)), 'Sample time is required');
assert(Date.parse(sample.validUntil) > Date.parse(sample.sampledAt), 'Certificate is expired');
}
assert.equal(after.fqdn, before.fqdn);
assert.equal(after.certName, before.certName);
assert.notEqual(fingerprint(after.fingerprint256), fingerprint(before.fingerprint256), 'Old certificate is still being served');
assert(Date.parse(after.sampledAt) >= Date.parse(before.sampledAt));
assert(Date.parse(after.validUntil) > Date.parse(after.sampledAt), 'New certificate is expired');
// A second issuance on the same day need not extend expiry by a full day.
return { fingerprintChanged: true, nativeAndTlsMatch: true, trustedHostnameMatch: true };
}
async function sample(fixture) {
assert(/^mmt253-[a-f0-9]{8}\.pngs\.top$/.test(fixture.fqdn));
assert.equal(fixture.certName, fixture.fqdn.replace('.pngs.top', ''));
assert.equal(fixture.nativePrivateKey, true);
fingerprint(fixture.nativeFingerprint256);
return await new Promise((resolve, reject) => {
const socket = tls.connect({ host: '104.165.244.7', port: 443, servername: fixture.fqdn, rejectUnauthorized: true });
socket.setTimeout(15000, () => socket.destroy(new Error('TLS acceptance timeout')));
socket.once('error', reject);
socket.once('secureConnect', () => {
try {
assert.equal(socket.authorized, true);
const cert = new X509Certificate(socket.getPeerCertificate().raw);
assert(cert.checkHost(fixture.fqdn), 'Hostname mismatch');
assert(Date.parse(cert.validTo) > Date.now(), 'Certificate is expired');
assert.equal(fingerprint(cert.fingerprint256), fingerprint(fixture.nativeFingerprint256), 'Native leaf does not match TLS');
resolve({ sampledAt: new Date().toISOString(), fqdn: fixture.fqdn, certName: fixture.certName,
remoteAddress: socket.remoteAddress, authorized: socket.authorized, hostnameMatched: true,
protocol: socket.getProtocol(), fingerprint256: cert.fingerprint256,
validFrom: cert.validFrom, validUntil: cert.validTo,
nativeName: fixture.certName, nativePrivateKey: true, nativeFingerprint256: fixture.nativeFingerprint256 });
} catch (error) { reject(error); } finally { socket.end(); }
});
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [mode, input, output] = process.argv.slice(2);
assert(input && output && ['sample', 'compare'].includes(mode), 'Usage: sample native-summary.json output.json | compare samples.json output.json');
assert(!fs.existsSync(output), 'Never overwrite acceptance evidence');
const data = JSON.parse(fs.readFileSync(input, 'utf8'));
const result = mode === 'sample' ? await sample(data) : compareRenewal(data.before, data.after);
fs.writeFileSync(output, JSON.stringify(result, null, 2) + '\n', { flag: 'wx' });
console.log(mode === 'sample' ? 'Trusted TLS/native fingerprint sample saved' : 'Renewal fingerprint comparison passed');
}
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { compareRenewal } from './acme-renew-tls.mjs';
const before = { fqdn: 'mmt253-a1b2c3d4.pngs.top', certName: 'mmt253-a1b2c3d4', nativeName: 'mmt253-a1b2c3d4',
remoteAddress: '104.165.244.7', authorized: true, hostnameMatched: true, nativePrivateKey: true,
fingerprint256: 'a'.repeat(64), nativeFingerprint256: 'a'.repeat(64), sampledAt: '2026-10-14T12:00:00Z', validUntil: '2027-01-12T12:00:00Z' };
const after = { ...before, fingerprint256: 'b'.repeat(64), nativeFingerprint256: 'b'.repeat(64), sampledAt: '2026-10-14T12:01:00Z' };
assert.equal(compareRenewal(before, after).fingerprintChanged, true);
for (const patch of [{ fingerprint256: before.fingerprint256, nativeFingerprint256: before.nativeFingerprint256 },
{ nativeFingerprint256: before.nativeFingerprint256 }, { authorized: false }, { hostnameMatched: false },
{ nativePrivateKey: false }, { nativeName: 'another' }, { fqdn: 'other.pngs.top' },
{ remoteAddress: '194.226.121.150' }, { validUntil: 'bad' }, { validUntil: '2026-10-13T12:00:00Z' },
{ sampledAt: '2026-10-14T11:00:00Z' }]) assert.throws(() => compareRenewal(before, { ...after, ...patch }));
console.log('Renewal acceptance comparison: changed leaf passes; 11 false-success cases rejected. No network or CA calls.');
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import {createServer} from 'node:http'
import {randomBytes} from 'node:crypto'
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own211.invalid';process.env.AUTH_PORTAL_URL='https://own211.invalid'
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
const psk='own211-'+randomBytes(24).toString('base64url'),baseline=false,cases=[];let app,serverId,nativeWrites=0,nativeCalls=0
const native=createServer((req,res)=>{nativeCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');res.end(JSON.stringify(req.url.endsWith('/interface/gre')?[{'.id':'*1',name:'own211-encrypted','local-address':'192.0.2.1','remote-address':'192.0.2.2','ipsec-secret':psk,mtu:'1476',keepalive:'10s,3',disabled:'false',running:'true','allow-fast-path':'false'},{'.id':'*2',name:'own211-plain','remote-address':'192.0.2.3','ipsec-secret':'',disabled:'false',running:'true'}]:[]))});await new Promise(r=>native.listen(0,'127.0.0.1',r))
const report={date:'2026-10-13',block:'12.5.211',database,baseline,cases,scope:'Production compiled canonical auth/RBAC using own restricted PostgreSQL, local native HTTP fixture and synthetic JWT/PSK',passed:false}
try{await initDatabase();serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own211-native','127.0.0.1',$1,false,true,'fixture','fixture') RETURNING id",[native.address().port])).rows[0].id);app=await buildApp({logger:false,startScheduler:false});await app.ready()
for(const path of ['/api/servers','/api/%73ervers','/%61pi/servers','/%61%70%69/servers','/api/gre/tunnels','/%61pi/gre/tunnels'])for(const method of ['GET','HEAD']){const before=nativeCalls;const r=await app.inject({method,url:path});assert.equal(r.statusCode,401);assert.equal(nativeCalls,before);cases.push({path,method,role:'anonymous',status:401})}
for(const path of ['/api/gre/tunnels','/api/%67re/tunnels','/%61pi/gre/tunnels'])for(const role of ['dashboard','reader','writer']){const permissions=role==='dashboard'?['mm:dashboard:read']:role==='reader'?['mm:network:read']:['mm:network:write'],token=app.jwt.sign({sub:'own211-'+role,apps:['mm'],permissions,iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});const r=await app.inject({method:'GET',url:path+'?serverId='+serverId,headers:{authorization:'Bearer '+token}});assert.equal(r.statusCode,role==='dashboard'?403:200);if(role!=='dashboard')assert.equal(r.json().tunnels.find(t=>t.name==='own211-encrypted').ipsec.secret,role==='writer'?psk:'');cases.push({path,method:'GET',role,status:r.statusCode,privateMaterialStored:false});if(role!=='writer'){const patch=await app.inject({method:'PATCH',url:path,headers:{authorization:'Bearer '+token},payload:{serverId:String(serverId),rosId:'*1',comment:'not applied'}});assert.equal(patch.statusCode,403);cases.push({path,method:'PATCH',role,status:403})}}
for(const path of ['/api/auth/config','/%61pi/auth/config']){assert.equal((await app.inject({url:path})).statusCode,200);cases.push({path,role:'anonymous',status:200,publicConfig:true})}
assert.equal(nativeWrites,0);report.nativeWrites=0;report.passed=true
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre211-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/auth211-production-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}
if(!report.passed)process.exitCode=1
@@ -0,0 +1,3 @@
import assert from'node:assert/strict';import fs from'node:fs';import{createRequire}from'node:module';import{createServer}from'node:http';
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool,db}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{servers}=await import('/app/backend/dist/db/schema.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');let nativeRequests=0;const native=createServer((_req,res)=>{nativeRequests++;res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));const[server]=await db.insert(servers).values({name:'owned-availability237',host:'127.0.0.1',port:native.address().port,useSsl:false,username:'fixture',password:'fixture',enabled:true,type:'exit-node'}).returning();fs.mkdirSync('storage/backups',{recursive:true});fs.writeFileSync('storage/backups/available237.rsc','# harmless own fixture\n');fs.mkdirSync('storage/backups/directory237.rsc');const fixtures=[['available237','available237.rsc','local',null],['missing237','missing237.rsc','local',null],['both237','both237.rsc','both','own-test/both237.rsc'],['remote237','remote237.rsc','s3','own-test/remote237.rsc'],['directory237','directory237.rsc','local',null]];for(const[id,filename,storage,key]of fixtures)await pool.query('INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES($1,$2,$3,$4,1,$5,$6,$7,now())',[id,server.id,'fixture',filename,'manual',storage,key]);const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const report={date:'2026-10-14',block:'12.5.237',passed:false,checks:[],externalS3Contacts:0,chrContacts:0};try{let r=await app.inject({method:'GET',url:'/api/backups'});assert.equal(r.statusCode,200);const statuses=Object.fromEntries(r.json().map(r=>[r.id,r.localFileStatus]));assert.deepEqual(statuses,{available237:'available',missing237:'missing',both237:'missing',remote237:'not-stored',directory237:'unknown'});report.checks.push({listStates:statuses});r=await app.inject({method:'GET',url:'/api/backups/available237/download'});assert.equal(r.statusCode,200);assert.equal(r.body,'# harmless own fixture\n');report.checks.push({availableDownload:200});r=await app.inject({method:'GET',url:'/api/backups/missing237/download'});assert.equal(r.statusCode,404);report.checks.push({missingDownload:404});r=await app.inject({method:'POST',url:'/api/backups/missing237/restore'});assert.equal(r.statusCode,400);assert.equal(r.json().outcome,'not-applied');assert.match(r.json().error,/Локальный файл/);assert.equal(nativeRequests,0);report.checks.push({missingRestore:400,outcome:'not-applied',nativeRequests:0});r=await app.inject({method:'GET',url:'/api/backups'});assert.equal(r.json().length,5);report.checks.push({failurePreservesCatalog:true});fs.writeFileSync('storage/backups/missing237.rsc','# recreated fixture\n');r=await app.inject({method:'DELETE',url:'/api/backups/missing237?recordOnly=true'});assert.equal(r.statusCode,409);assert(fs.existsSync('storage/backups/missing237.rsc'));assert.equal((await pool.query("SELECT count(*)::int AS n FROM backup_entries WHERE id='missing237'")).rows[0].n,1);report.checks.push({recreatedFilePreserved:409});fs.unlinkSync('storage/backups/missing237.rsc');r=await app.inject({method:'DELETE',url:'/api/backups/missing237?recordOnly=true'});assert.equal(r.statusCode,204);assert.equal(r.headers['x-backup-delete-mode'],'record-only');report.checks.push({missingRecordDeletion:204,mode:'record-only'});report.passed=true}catch(e){report.failure={name:e.name}}finally{await databaseBackgroundWork.drain();await app.close();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/checks/backup-availability237.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
@@ -0,0 +1,3 @@
import assert from 'node:assert/strict';import fs from 'node:fs';import{randomUUID}from'node:crypto';import{createServer}from'node:http';import{createRequire}from'node:module';
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool,db}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{servers}=await import('/app/backend/dist/db/schema.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');let nativeRequests=0;const native=createServer((_req,res)=>{nativeRequests++;res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));const[server]=await db.insert(servers).values({name:'owned-legacy234',host:'127.0.0.1',port:native.address().port,useSsl:false,username:'fixture',password:'fixture',enabled:true,type:'exit-node'}).returning();const filename='legacy234-'+randomUUID()+'.rsc',file='storage/backups/'+filename;fs.mkdirSync('storage/backups',{recursive:true});await pool.query("INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,created_at) VALUES('owned-legacy234',$1,'fixture',$2,1,'manual','local',now())",[server.id,filename]);const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const report={date:'2026-10-14',block:'12.5.234',checks:[],passed:false,chrContacts:0,externalS3Contacts:0};try{for(const prefix of['','\uFEFF\r\n ']){const content=prefix+'# synthetic export generated by MikrotikManager\n/system identity\nset name=legacy-fixture\n';fs.writeFileSync(file,content);const download=await app.inject({method:'GET',url:'/api/backups/owned-legacy234/download'});assert.equal(download.statusCode,200);assert.equal(download.body,content);const restore=await app.inject({method:'POST',url:'/api/backups/owned-legacy234/restore'});assert.equal(restore.statusCode,400);assert.equal(restore.json().outcome,'not-applied');assert.match(restore.json().error,/частичным экспортом/);assert.equal(fs.readFileSync(file,'utf8'),content);assert.equal(nativeRequests,0);assert.equal((await pool.query("SELECT count(*)::int AS n FROM backup_entries WHERE id='owned-legacy234'")).rows[0].n,1);report.checks.push({bomAndWhitespace:Boolean(prefix),downloadStatus:200,restoreStatus:400,outcome:'not-applied',fileAndCatalogPreserved:true,nativeRequests:0})}report.passed=true}catch(e){report.failure={name:e.name}}finally{await databaseBackgroundWork.drain();const deleted=await app.inject({method:'DELETE',url:'/api/backups/owned-legacy234'});assert.equal(deleted.statusCode,204);assert(!fs.existsSync(file));assert.equal((await pool.query('SELECT count(*)::int AS n FROM backup_entries')).rows[0].n,0);report.cleanup={catalogRows:0,localFiles:0};await app.close();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/checks/legacy-restore234.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
@@ -0,0 +1,5 @@
import assert from 'node:assert/strict';import fs from 'node:fs';import{createRequire}from'node:module';
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');const baseline=process.env.MMAPP_ORIGIN_BASELINE==='true',report={date:'2026-10-14',block:'12.5.233',baseline,checks:[],externalS3Contacts:0,chrContacts:0,passed:false};const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const config={provider:'s3',s3Endpoint:'https://s3.invalid',s3Region:'test-region',s3Bucket:'origin-bucket',s3Prefix:'owned-test',s3AccessKeyId:'fixture',s3SecretAccessKey:'fixture',s3ForcePathStyle:false,keepLocalCopy:true};async function api(method,path,payload,status=200){const r=await app.inject({method,url:'/api'+path,...(payload?{payload}:{})});assert.equal(r.statusCode,status,method+' '+path);return r}
async function insert(){await pool.query("INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES('owned-origin',NULL,'fixture','fixture.rsc',1,'manual','s3','owned-test/fixture.rsc',now())")}
try{await api('PUT','/backups/storage',config);await insert();for(const patch of[{s3Bucket:'other-bucket'},{s3Endpoint:'https://other.invalid'},{s3Region:'other'},{s3ForcePathStyle:true},{provider:'local'}]){const before=(await api('GET','/backups/storage')).json();await api('PUT','/backups/storage',patch,baseline?200:409);const after=(await api('GET','/backups/storage')).json();if(!baseline)assert.deepEqual(after,before);else await api('PUT','/backups/storage',config);report.checks.push({change:Object.keys(patch)[0],status:baseline?200:409,settingsUnchanged:!baseline})}for(const patch of[{s3Prefix:'new-prefix'},{s3SecretAccessKey:'rotated-fixture'},{keepLocalCopy:false}]){await api('PUT','/backups/storage',patch);report.checks.push({allowed:Object.keys(patch)[0],status:200})}await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'");await api('PUT','/backups/storage',{s3Bucket:'empty-catalog-bucket'});report.checks.push({emptyCatalogChangeAllowed:true});if(!baseline){const{withBackupStorageOperation}=await import('/app/backend/dist/services/backup-storage-operation.js');let release,started;const active=new Promise(r=>started=r),wait=new Promise(r=>release=r);const creator=withBackupStorageOperation(async()=>{started();await wait;await insert()});await active;let settled=false;const update=api('PUT','/backups/storage',{s3Bucket:'racing-bucket'},409).then(()=>settled=true);await new Promise(r=>setTimeout(r,20));await update;assert.equal(settled,true);release();await creator;assert.equal((await api('GET','/backups/storage')).json().s3Bucket,'empty-catalog-bucket');report.checks.push({inFlightUpdateRejectedBeforeCatalogPublication:true});const{readBackupContent,getBackupById}=await import('/app/backend/dist/services/backup-service.js');const stale=await getBackupById('owned-origin');let releaseDelete;const deletion=withBackupStorageOperation(async()=>{await new Promise(r=>releaseDelete=r);await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'")});const download=readBackupContent(stale);const rejected=assert.rejects(download,/Бэкап не найден/);await Promise.resolve();releaseDelete();await deletion;await rejected;report.checks.push({staleDownloadRejectsBeforeS3AfterDeletion:true})}report.passed=true}catch(e){report.failure={name:e.name}}finally{await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'");await databaseBackgroundWork.drain();await app.close();await closePool();fs.writeFileSync('/checks/storage-origin233.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import {createServer} from 'node:http'
import {randomBytes} from 'node:crypto'
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own216.invalid';process.env.AUTH_PORTAL_URL='https://own216.invalid'
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js'),{fetchBgpSessionsForAlerts}=await import('/app/backend/dist/services/bgp-peers-live.js'),{collectGreBgpSnapshotOnce}=await import('/app/backend/dist/services/gre-bgp-snapshot-collector.js')
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
let phase='populated',app,nativeCalls=0,nativeWrites=0,disabledCalls=0;const cases=[]
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');if(phase==='failed'||(phase==='partial'&&label==='second')){res.statusCode=401;res.end(JSON.stringify({error:401,message:'own216 fixture denied'}));return}const rows=phase==='populated'?[{'.id':'*1',name:'own216-peer',state:'established'}]:[];res.end(JSON.stringify(rows))})
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
const baseline=process.env.MMAPP_BASELINE==='true';const report={date:'2026-10-14',block:'12.5.216',baseline,database,cases,scope:'Compiled production routes/auth, isolated PostgreSQL and RouterOS HTTP fixture; real CHR unchanged',passed:false}
try{
await initDatabase();const ids=[];for(let i=0;i<natives.length;i++)ids.push(Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4) RETURNING id",['own216-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])).rows[0].id))
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own216-reader',apps:['mm'],permissions:['mm:bgp:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token}
const wrongToken=app.jwt.sign({sub:'own216-other-reader',apps:['mm'],permissions:['mm:network:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});for(const [name,h,status] of [['anonymous',{},401],['wrong permission',{authorization:'Bearer '+wrongToken},403]]){const before=nativeCalls,r=await app.inject({method:'GET',url:'/api/bgp/sessions',headers:h});assert.equal(r.statusCode,status);assert.equal(nativeCalls,before);cases.push({phase:name,endpoint:'bgp/sessions',status,nativeCalls:0})}
for(phase of ['populated','empty','partial','failed','disabled']){
if(phase==='disabled')await pool.query('UPDATE servers SET enabled=false')
const hasFailure=['partial','failed'].includes(phase),expected=phase==='partial'?[ids[1]]:phase==='failed'?ids.slice(0,2):[]
for(const endpoint of ['bgp/sessions','bgp/catalog']){
const before=nativeCalls,r=await app.inject({method:'GET',url:'/api/'+endpoint,headers}),body=r.json(),isCatalog=endpoint==='bgp/catalog'
assert.equal(r.statusCode,!baseline&&hasFailure&&!isCatalog?503:200)
if(isCatalog||(!baseline&&hasFailure)){assert.deepEqual(body.failures.map(f=>f.serverId),expected);if(isCatalog)assert.equal(body.sessions.length,phase==='populated'?2:0)}else{assert(Array.isArray(body));assert.equal(body.length,phase==='populated'?2:0)}
if(phase==='disabled')assert.equal(nativeCalls,before)
cases.push({phase,endpoint,status:r.statusCode,failures:body.failures?.length??0,nativeCalls:nativeCalls-before})
}
const snapshot=await collectGreBgpSnapshotOnce({fetchGre:async()=>[{targetLabel:'own216-GRE-control',status:'up'}],fetchBgp:fetchBgpSessionsForAlerts})
assert.equal(snapshot.greWritten,1);assert.equal(snapshot.bgpWritten,phase==='populated'?2:0);assert.equal(snapshot.errors.length,!baseline&&hasFailure?1:0)
if(snapshot.errors.length)assert.match(snapshot.errors[0],/BGP: Не опрошены устройства BGP/)
cases.push({phase,endpoint:'compiled snapshot collector',bgpWritten:snapshot.bgpWritten,greWritten:1,sourceErrors:snapshot.errors.length})
}
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/bgp216-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/bgp216-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
@@ -0,0 +1,9 @@
import assert from 'node:assert/strict';import fs from 'node:fs';import path from 'node:path';import{pathToFileURL}from'node:url';import{createHash}from'node:crypto';
const vantage=process.env.MMAPP_TEST_VANTAGE;assert(['workstation','debian'].includes(vantage));const output=process.env.MMAPP_TEST_REPORT;assert(output);const hosts=['194.226.121.150','104.165.244.7'],root=path.resolve('backend/dist'),{MikrotikClient}=await import(pathToFileURL(root+'/services/mikrotik.js'));let rows;
if(vantage==='debian'){const{pool}=await import(pathToFileURL(root+'/db/index.js'));try{rows=(await pool.query("SELECT host,port,use_ssl,enabled,username,password FROM servers WHERE host IN ('194.226.121.150','104.165.244.7') ORDER BY host")).rows}finally{await pool.end()}}else{assert(process.env.MMAPP_TEST_CHR_USER&&process.env.MMAPP_TEST_CHR_PASSWORD);rows=hosts.map(host=>({host,port:80,use_ssl:false,enabled:true,username:process.env.MMAPP_TEST_CHR_USER,password:process.env.MMAPP_TEST_CHR_PASSWORD}))}assert.equal(rows.length,2);assert.deepEqual(rows.map(r=>r.host).sort(),[...hosts].sort());assert(rows.every(r=>r.enabled&&r.port===80&&!r.use_ssl));
const cycles=Number(process.env.MMAPP_REST_CYCLES??8),periodMs=Number(process.env.MMAPP_REST_PERIOD_MS??12000),maxMs=Number(process.env.MMAPP_REST_MAX_MS??960000);assert(Number.isInteger(cycles)&&cycles>=1&&cycles<=32);assert(Number.isInteger(periodMs)&&periodMs>=1000&&periodMs<=60000);assert(Number.isInteger(maxMs)&&maxMs>=10000&&maxMs<=960000);const deadline=Date.now()+maxMs;const report={date:'2026-10-14',block:process.env.MMAPP_REST_BLOCK??'12.5.236',vantage,cycles,periodMs,maxMs,credentialSource:vantage==='debian'?'stored-enabled-server-rows':'explicit-owned-test-env',startedAt:new Date().toISOString(),requests:[],diagnostics:[],configurationWrites:0,passwordChanges:0,rootCauseKnown:false};const sleep=ms=>new Promise(r=>setTimeout(r,ms));const clients=new Map(rows.map(r=>[r.host,new MikrotikClient({host:r.host,port:80,useSsl:false,verifySsl:false,username:r.username,password:r.password})]));
function errorMetadata(e,body){let obj;try{obj=JSON.parse(body)}catch{}const detail=String(obj?.detail??'');return{errorName:e?.name??null,errorBodySha256:body?createHash('sha256').update(body).digest('hex'):null,errorKeys:obj&&typeof obj==='object'?Object.keys(obj).sort():[],detailCategory:/not enough permissions/i.test(detail)?'insufficient-policy':/not allowed/i.test(detail)?'not-allowed':/session closed/i.test(detail)?'session-closed':detail?'unclassified':'absent'}}
async function read(row,endpoint,mode,cycle,kind='sample'){const begin=Date.now(),record={host:row.host,path:endpoint,mode,cycle,kind,startedAt:new Date().toISOString()};let data;try{if(mode==='compiled-client'){data=await clients.get(row.host).get(endpoint,6000);record.status=200}else{const authorization='Basic '+Buffer.from(row.username+':'+row.password).toString('base64');const r=await fetch('http://'+row.host+'/rest'+endpoint,{headers:{authorization,Connection:'close'},signal:AbortSignal.timeout(6000)}),body=await r.text();record.status=r.status;record.basicChallenge=/Basic/i.test(r.headers.get('www-authenticate')??'');if(r.ok)data=JSON.parse(body);else Object.assign(record,errorMetadata(null,body))}}catch(e){record.status=e.statusCode??null;Object.assign(record,errorMetadata(e,e.body??''))}record.durationMs=Date.now()-begin;report.requests.push(record);await sleep(125);return data}
async function diagnostic(row,phase){const userRows=await read(row,'/user','compiled-client',0,'diagnostic'),users=Array.isArray(userRows)?userRows.filter(u=>u.name===row.username):[];const groups=await read(row,'/user/group','compiled-client',0,'diagnostic'),group=Array.isArray(groups)?groups.find(g=>g.name===users[0]?.group):null;const active=await read(row,'/user/active','compiled-client',0,'diagnostic');const apiSessions=Array.isArray(active)?active.filter(s=>s.name===row.username&&s.via==='api').map(s=>createHash('sha256').update(String(s['.id'])+'|'+String(s.when)).digest('hex')).sort():null;const services=await read(row,'/ip/service','compiled-client',0,'diagnostic'),resource=await read(row,'/system/resource','compiled-client',0,'diagnostic'),logs=await read(row,'/log','compiled-client',0,'diagnostic');const auth=Array.isArray(logs)?logs.filter(l=>/login failure/i.test(String(l.message))):[];const events=auth.map(l=>{const message=String(l.message);return{time:l.time??null,via:message.match(/via (\S+)/)?.[1]??null,fromTestDebian:/from 161\.104\.46\.222\b/.test(message),forConfiguredUser:message.includes('for user '+row.username+' ')}});const www=Array.isArray(services)?services.filter(s=>s.name==='www'):[];report.diagnostics.push({host:row.host,phase,apiSessionFingerprints:apiSessions,retainedRestSessions:Array.isArray(active)?active.filter(s=>s.via==='rest-api').length:null,userPresent:users.length===1,userDisabled:users[0]?.disabled??null,userAddressRestriction:users[0]?.address??null,hasApiPolicy:group?String(group.policy).split(',').includes('api'):null,hasRestApiPolicy:group?String(group.policy).split(',').includes('rest-api'):null,wwwEnabled:www.some(s=>s.disabled==='false'),wwwMaxSessions:www.find(s=>s['max-sessions'])?.['max-sessions']??null,wwwDynamicRows:www.filter(s=>s.dynamic==='true').length,resource:resource?{version:resource.version,uptime:resource.uptime,cpuLoad:resource['cpu-load'],freeMemory:resource['free-memory']}:null,retainedAuthFailureCount:auth.length,recentFailures:events.slice(-15),retainedLogIsNotCurrentRate:true})}
function save(){const samples=report.requests.filter(r=>r.kind==='sample');report.completedAt=new Date().toISOString();report.summary={sampleRequests:samples.length,failedSamples:samples.filter(r=>r.status!==200).length,allRequests:report.requests.length,failures:report.requests.filter(r=>r.status!==200).map(r=>({host:r.host,path:r.path,mode:r.mode,status:r.status,kind:r.kind,startedAt:r.startedAt}))};fs.writeFileSync(output,JSON.stringify(report,null,2)+'\n')}
try{for(const row of rows)await diagnostic(row,'before');const started=Date.now(),paths=['/system/resource','/interface/gre','/routing/bgp/session','/routing/ospf/neighbor','/interface/wireguard/peers','/interface/vxlan'];for(let cycle=1;cycle<=cycles&&Date.now()<deadline;cycle++){for(const row of rows)for(const endpoint of paths){const modes=cycle%2?['compiled-client','fetch-close']:['fetch-close','compiled-client'];for(const mode of modes){const previous=report.requests.length;await read(row,endpoint,mode,cycle);if(report.requests[previous].status!==200){await read(row,'/system/resource','fetch-close',cycle,'failure-control');await diagnostic(row,'failure-'+cycle)}}}save();console.log(JSON.stringify({vantage,cycle,samples:report.summary.sampleRequests,failed:report.summary.failedSamples}));if(cycle<cycles)await sleep(Math.max(0,Math.min(deadline,started+cycle*periodMs)-Date.now()))}for(const row of rows)await diagnostic(row,'after')}finally{save();console.log(JSON.stringify({vantage,summary:report.summary,rootCauseKnown:false}))}
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? '').split(',')
assert.deepEqual(hosts, ['194.226.121.150', '104.165.244.7'], 'Explicit owned lab hosts required')
const { MMAPP_TEST_CHR_USER: username, MMAPP_TEST_CHR_PASSWORD: password } = process.env
assert(username && password, 'Explicit test credentials required')
const vantage = process.env.MMAPP_TEST_VANTAGE
assert(['workstation', 'debian'].includes(vantage), 'Explicit vantage required')
const output = process.env.MMAPP_TEST_REPORT
assert(output, 'Explicit report path required')
const authorization = 'Basic ' + Buffer.from(username + ':' + password).toString('base64')
const report = { block: '12.5.225', date: '2026-10-14', vantage, startedAt: new Date().toISOString(), requests: [], diagnostics: [], configurationWrites: 0, passwordChanges: 0, rootCauseKnown: false }
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
async function read(host, path) {
const startedAt = new Date().toISOString(), start = Date.now()
try {
const response = await fetch(`http://${host}/rest${path}`, { headers: { authorization, Connection: 'close' }, signal: AbortSignal.timeout(8000) })
const body = await response.text()
report.requests.push({ host, path, startedAt, durationMs: Date.now() - start, status: response.status, headers: Object.fromEntries(['date', 'server', 'www-authenticate', 'connection'].map(key => [key, response.headers.get(key)])) })
if (!response.ok) return null
try { return JSON.parse(body) } catch { return null }
} catch (error) {
report.requests.push({ host, path, startedAt, durationMs: Date.now() - start, status: null, errorName: error.name })
return null
} finally { await sleep(300) }
}
for (let cycle = 1; cycle <= 3; cycle++) {
for (const host of hosts) for (const path of ['/system/resource', '/interface/gre']) await read(host, path)
if (cycle < 3) await sleep(5000)
}
for (const host of hosts) {
const clock = await read(host, '/system/clock')
const services = await read(host, '/ip/service')
const logs = await read(host, '/log')
const failures = Array.isArray(logs) ? logs.filter(row => /login failure/i.test(String(row.message))) : null
const sources = {}
const events = failures?.map(row => {
const message = String(row.message), source = message.match(/from ([\d.]+)/)?.[1] ?? 'unparsed'
const origin = source === '161.104.46.222' ? 'test-debian' : 'other-or-unidentified'
sources[origin] = (sources[origin] ?? 0) + 1
return { time: row.time ?? null, origin, via: message.match(/via (\w+)/)?.[1] ?? null }
})
report.diagnostics.push({ host, clock: clock ? { date: clock.date, time: clock.time, gmtOffset: clock['gmt-offset'] } : null, wwwServices: Array.isArray(services) ? services.filter(row => row.name === 'www').map(row => ({ disabled: row.disabled, maxSessions: row['max-sessions'] ?? null, dynamic: row.dynamic ?? null })) : null, retainedAuthFailures: failures?.length ?? null, sources, recentFailures: events?.slice(-20) ?? null, retainedLogIsNotCurrentRate: true })
}
report.completedAt = new Date().toISOString()
report.summary = hosts.map(host => { const rows = report.requests.filter(row => row.host === host); return { host, requests: rows.length, ok: rows.filter(row => row.status === 200).length, unauthorized: rows.filter(row => row.status === 401).length, serverErrors: rows.filter(row => row.status >= 500).length, transportErrors: rows.filter(row => row.status === null).length } })
fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n')
console.log(JSON.stringify({ vantage, summary: report.summary, configurationWrites: 0 }))
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
const hosts=(process.env.MMAPP_TEST_CHR_HOSTS??'').split(',');assert.deepEqual(hosts,['194.226.121.150','104.165.244.7'],'Explicit owned lab hosts required')
const username=process.env.MMAPP_TEST_CHR_USER,password=process.env.MMAPP_TEST_CHR_PASSWORD;assert(username&&password,'Explicit test credentials required')
const authorization='Basic '+Buffer.from(username+':'+password).toString('base64'),paths=['/system/resource','/interface/gre','/routing/bgp/session','/routing/ospf/neighbor'],cases=[],start=Date.now(),deadline=start+360000,samples=20,periodMs=10000
const sleep=ms=>new Promise(r=>setTimeout(r,ms)),output='docs/ux-ui/acceptance/chr224-rest-observation-2026-10-14.json'
function save(completed){const failed=cases.filter(c=>c.status!==200);fs.writeFileSync(output,JSON.stringify({date:'2026-10-14',block:'12.5.224',startedAt:new Date(start).toISOString(),completedAt:new Date().toISOString(),plannedSamples:samples,completedSamples:completed,periodMs,requests:cases.length,failedRequests:failed.length,cases,configurationWrites:0,passwordChanges:0,scope:'Low-rate direct native REST with Connection close; short window, no burst/load/physical WAN/SLA/root-cause proof'},null,2)+'\n')}
for(let sample=1;sample<=samples;sample++){
if(Date.now()>deadline)break
for(const host of hosts)for(const path of paths){const began=Date.now();try{const r=await fetch('http://'+host+'/rest'+path,{headers:{authorization,Connection:'close'},signal:AbortSignal.timeout(8000)});await r.arrayBuffer();cases.push({sample,host,path,status:r.status,durationMs:Date.now()-began})}catch(e){cases.push({sample,host,path,status:null,errorName:e.name,durationMs:Date.now()-began})}await sleep(150)}
save(sample);console.log(JSON.stringify({completedSamples:sample,requests:cases.length,failedRequests:cases.filter(c=>c.status!==200).length}));if(sample<samples)await sleep(Math.max(0,start+sample*periodMs-Date.now()))
}
@@ -0,0 +1,71 @@
// Real Portal login; credentials stay in the caller's ignored private fixture.
import fs from 'node:fs';
import assert from 'node:assert/strict';
import { chromium, expect } from '@playwright/test';
(async () => {
const phase = process.argv[2];
assert(['guard', 'remove', 'repeat'].includes(phase));
const login = JSON.parse(fs.readFileSync(process.env.MMAPP_PORTAL_FIXTURE || 'tmp/portal195-private.json'));
const own = JSON.parse(fs.readFileSync(process.env.MMAPP_CONTAINER_FIXTURE || 'tmp/state248-public.json'));
const reportPath = 'docs/ux-ui/acceptance/containers248-live-ui.json';
const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath)) : { date: '2026-10-14', block: '12.5.248', realPortalLogin: true, noMocks: true, steps: [], pageErrors: [] };
const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.on('pageerror', error => report.pageErrors.push(error.message));
await page.goto((process.env.MMAPP_TEST_URL || 'http://localhost:3000') + '/containers');
await page.getByLabel('Email', { exact: true }).fill(login.email);
await page.getByLabel('Пароль', { exact: true }).fill(login.password);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('navigation', { name: 'Путь страницы' })).toContainText('Контейнеры', { timeout: 30000 });
const endpoint = `/api/servers/${own.serverId}/containers/remove`;
const direct = () => page.evaluate(async ({ endpoint, rosId }) => {
const r = await fetch(endpoint, { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('mmapp_token'), 'Content-Type': 'application/json' }, body: JSON.stringify({ rosId }) });
const body = await r.json();
return { status: r.status, ok: body.ok === true, error: body.error };
}, { endpoint, rosId: own.rosId });
if (phase === 'repeat') {
const result = await direct(); assert.equal(result.status, 409); assert.equal(result.ok, false); assert.match(result.error, /уже отсутствует/);
report.steps.push({ phase, status: result.status, absentConflict: true });
} else {
await page.getByRole('button', { name: 'Действия контейнера ' + own.name, exact: true }).click();
await page.getByRole('menuitem', { name: 'Удалить', exact: true }).click();
const dialog = page.getByRole('alertdialog');
await expect(dialog).toContainText(own.name);
if (phase === 'guard') {
report.layout = [];
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 1000 });
await expect(dialog).toContainText('после подтверждённой остановки');
await expect(dialog.getByRole('button', { name: 'Удалить', exact: true })).toBeDisabled();
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth > innerWidth), { timeout: 5000 }).toBe(false);
const layout = await page.evaluate(() => ({ viewport: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
(report.layout ??= []).push(layout);
if (layout.scrollWidth > layout.viewport) {
report.overflow = await page.evaluate(() => Array.from(document.querySelectorAll('*')).map(el => ({tag:el.tagName,slot:el.getAttribute('data-slot'),left:el.getBoundingClientRect().left,right:el.getBoundingClientRect().right,width:el.getBoundingClientRect().width,position:getComputedStyle(el).position})).filter(el=>el.right>innerWidth+1).slice(0,20));
await page.screenshot({ path: `tmp/containers248-guard-${width}.png`, fullPage: true });
}
assert.equal(layout.scrollWidth > layout.viewport, false);
}
const result = await direct(); assert.equal(result.status, 409); assert.equal(result.ok, false);
report.steps.push({ phase, viewports: [1440, 390], uiDeleteDisabled: true, backendConflict: result.status });
} else {
await expect(dialog).toContainText('остановленный контейнер');
const response = page.waitForResponse(r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST');
await dialog.getByRole('button', { name: 'Удалить', exact: true }).click();
const r = await response; assert.equal(r.request().postDataJSON().rosId, own.rosId); assert.equal(r.status(), 200); assert.equal((await r.json()).ok, true);
await expect(dialog).toBeHidden();
await expect(page.getByRole('button', { name: 'Действия контейнера ' + own.name, exact: true })).toHaveCount(0);
report.steps.push({ phase, status: 200, receiptOk: true, targetMatched: true, rowAbsent: true });
}
}
assert.deepEqual(report.pageErrors, []);
delete report.overflow;
if (report.failure?.phase === phase) { (report.harnessFailures ??= []).push(report.failure); delete report.failure; }
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n');
console.log('Live container removal: ' + phase + ' passed');
} catch (error) {
report.failure = { phase, name: error.name, ...(error.name === 'AssertionError' ? { actual: error.actual, expected: error.expected } : {}) }; fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); throw error;
} finally { await browser.close(); }
})().catch(error => { console.error('Live container removal failed: ' + error.name); process.exitCode = 1; });
Loaded 100 of 2463 files, more files were not shown because too many files have changed in this diff. Show more