Compare commits

...
830 Commits
Author SHA1 Message Date
Denozordec 5efd0339fb feat(logs): добавить раздел логов с историей и живым потоком
Docker images / frontend-image (push) Successful in 3m29s
Docker images / netflow-agent-image (push) Successful in 34s
Docker images / publish-release (push) Successful in 10s
Docker images / updater-image (push) Successful in 41s
Docker images / backend-image (push) Successful in 1m44s
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 / netflow-agent-image (push) Successful in 31s
Docker images / publish-release (push) Successful in 13s
Docker images / updater-image (push) Successful in 39s
Docker images / backend-image (push) Successful in 1m37s
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 / netflow-agent-image (push) Successful in 1m6s
Docker images / publish-release (push) Successful in 10s
Docker images / updater-image (push) Successful in 39s
Docker images / backend-image (push) Successful in 1m40s
Docker images / notify-webhook (push) Successful in 2s
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m27s
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 / publish-release (push) Successful in 15s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 1m54s
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 / backend-image (push) Successful in 1m41s
Docker images / publish-release (push) Successful in 10s
Docker images / updater-image (push) Successful in 41s
Docker images / notify-webhook (push) Successful in 2s
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 5m11s
2026-10-15 22:14:14 +07:00
Denozordec b2cf71fcc7 fix(db): индексировать историю для очистки планировщика
Docker images / frontend-image (push) Successful in 2m39s
Docker images / publish-release (push) Skipped
Docker images / backend-image (push) Skipped
Docker images / updater-image (push) Successful in 42s
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 / frontend-image (push) Successful in 3m7s
Docker images / publish-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 3m2s
Docker images / updater-image (push) Successful in 1m16s
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 / publish-release (push) Successful in 11s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 2m46s
Docker images / backend-test (push) Successful in 1m56s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 5s
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 / publish-release (push) Successful in 9s
Docker images / updater-image (push) Successful in 51s
Docker images / backend-image (push) Successful in 2m25s
Docker images / backend-test (push) Successful in 2m28s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 8s
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 / publish-release (push) Successful in 11s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 3m19s
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 / publish-release (push) Successful in 7s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m44s
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 / publish-release (push) Successful in 14s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m43s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m18s
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 / publish-release (push) Successful in 11s
Docker images / updater-image (push) Successful in 39s
Docker images / backend-image (push) Successful in 2m40s
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 / publish-release (push) Successful in 9s
Docker images / updater-image (push) Successful in 38s
Docker images / backend-image (push) Successful in 2m20s
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 / backend-image (push) Successful in 2m11s
Docker images / publish-release (push) Successful in 10s
Docker images / updater-image (push) Successful in 40s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
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 / publish-release (push) Successful in 13s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m48s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m41s
- 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 / publish-release (push) Successful in 15s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m35s
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 / publish-release (push) Successful in 8s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
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 / publish-release (push) Successful in 10s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 3m5s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m40s
- Окно периода клампится к текущему моменту: «Сегодня»/«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 / backend-image (push) Successful in 2m44s
Docker images / publish-release (push) Successful in 10s
Docker images / updater-image (push) Successful in 43s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m11s
- Контракт: 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 / publish-release (push) Successful in 10s
Docker images / updater-image (push) Successful in 47s
Docker images / backend-image (push) Successful in 2m23s
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 / publish-release (push) Successful in 13s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m25s
Docker images / backend-test (push) Successful in 2m34s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 9s
- 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 / publish-release (push) Successful in 13s
Docker images / updater-image (push) Successful in 51s
Docker images / backend-image (push) Successful in 2m40s
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 / publish-release (push) Successful in 13s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m30s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m16s
- 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 / publish-release (push) Successful in 12s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 2m55s
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 / publish-release (push) Successful in 9s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m51s
Docker images / backend-test (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 10s
- 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 / publish-release (push) Successful in 10s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m31s
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 / backend-image (push) Successful in 2m49s
Docker images / publish-release (push) Successful in 12s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m20s
- 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 / publish-release (push) Successful in 10s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m40s
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 / publish-release (push) Successful in 9s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m50s
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 / publish-release (push) Successful in 8s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m18s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m17s
- 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 / publish-release (push) Successful in 10s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 2m52s
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 / backend-image (push) Successful in 3m9s
Docker images / publish-release (push) Successful in 14s
Docker images / updater-image (push) Successful in 48s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m18s
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 / publish-release (push) Successful in 12s
Docker images / updater-image (push) Successful in 52s
Docker images / backend-image (push) Successful in 2m41s
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 / publish-release (push) Successful in 15s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m39s
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 / publish-release (push) Successful in 11s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 2m20s
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 / publish-release (push) Successful in 14s
Docker images / updater-image (push) Successful in 52s
Docker images / backend-image (push) Successful in 3m8s
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 / publish-release (push) Successful in 13s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m32s
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 / publish-release (push) Successful in 13s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m19s
Куб 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 / publish-release (push) Successful in 15s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 3m1s
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 / publish-release (push) Successful in 15s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m44s
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 / publish-release (push) Successful in 11s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 4m2s
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 / publish-release (push) Successful in 15s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 2m45s
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 / publish-release (push) Successful in 14s
Docker images / updater-image (push) Successful in 47s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m13s
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 / publish-release (push) Successful in 10s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m29s
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 / publish-release (push) Successful in 16s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m6s
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 / publish-release (push) Successful in 10s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m9s
Коллектор снова отдаёт назначения в живом потоке. Ошибки записи видны в статусе, лишние перезаписи минутных агрегатов убраны.

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 / publish-release (push) Successful in 11s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m47s
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 / publish-release (push) Successful in 13s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m0s
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 / frontend-image (push) Successful in 3m14s
Docker images / publish-release (push) Skipped
Docker images / backend-image (push) Skipped
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Failing after 12s
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
DenozordecandCursor ec43591a99 feat(db): перевести хранилище с SQLite на PostgreSQL
Docker images / frontend-image (push) Successful in 4m28s
Docker images / publish-release (push) Successful in 11s
Docker images / updater-image (push) Successful in 58s
Docker images / backend-image (push) Successful in 2m49s
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m9s
Docker images / notify-webhook (push) Skipped
При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump.

Co-authored-by: Cursor <[email protected]>
2026-09-08 01:36:48 +07:00
DenozordecandCursor 0e5fb065e2 fix(backend): уменьшить лишние записи SQLite
Docker images / backend-image (push) Successful in 2m8s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / publish-release (push) Successful in 12s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / prepare-release (push) Successful in 10s
Убрать WAL TRUNCATE с hot path NetFlow, не писать неизменённые UPDATE
и служебные INSERT, кэшировать карты topology/analytics.

Co-authored-by: Cursor <[email protected]>
2026-09-07 20:22:20 +07:00
DenozordecandCursor 5188b2aff2 fix(network-map): обрезать пунктир до сервиса и показать пути клиентов
Docker images / frontend-image (push) Successful in 3m17s
Docker images / publish-release (push) Successful in 13s
Docker images / backend-image (push) Successful in 1m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / prepare-release (push) Successful in 8s
Линия от обода EN до рамки сервиса; таблица клиент, узел и сервис с подсветкой на карте.

Co-authored-by: Cursor <[email protected]>
2026-09-07 17:39:18 +07:00
DenozordecandCursor cd1fd2c9d3 fix(network-map): рисовать пунктир и скорость до сервиса
Docker images / frontend-image (push) Successful in 2m55s
Docker images / publish-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 43s
Docker images / prepare-release (push) Successful in 8s
Docker images / notify-webhook (push) Skipped
Якорь hop на EN даже без nextHop; линия ARN\to Google с NetflowRateBadge.

Co-authored-by: Cursor <[email protected]>
2026-09-07 17:02:28 +07:00
DenozordecandCursor 77425cca32 fix(network-map): якорить сервисы на выходную ноду
Docker images / frontend-image (push) Successful in 2m56s
Docker images / publish-release (push) Successful in 16s
Docker images / backend-image (push) Successful in 1m46s
Docker images / updater-image (push) Successful in 45s
Docker images / prepare-release (push) Successful in 8s
Docker images / notify-webhook (push) Skipped
Пунктир от EN, а не от JH; перетаскивание узлов сервисов; иконка Google без foreignObject.

Co-authored-by: Cursor <[email protected]>
2026-09-07 16:10:39 +07:00
DenozordecandCursor 29d245cde3 feat(traffic): enhance flow analytics and brand classification
Docker images / frontend-image (push) Successful in 4m3s
Docker images / publish-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 2m12s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / prepare-release (push) Successful in 11s
- Introduced `pickInternetPeer` function to improve IP classification by selecting the appropriate public IP from source and destination.
- Updated `buildFlowAnalytics` and related functions to utilize the new peer selection logic, enhancing accuracy in flow analytics.
- Added tests for new classifications and ensured existing tests cover new scenarios for Google and Cloudflare.
- Refactored traffic flow brand mappings to include additional CIDR ranges for Google and Cloudflare.

Co-authored-by: Cursor <[email protected]>
2026-09-07 15:35:52 +07:00
DenozordecandCursor 7a491a325d fix(network-map): убрать тяжёлую классификацию из poll карты
Docker images / frontend-image (push) Successful in 3m11s
Docker images / publish-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 1m53s
Docker images / updater-image (push) Successful in 49s
Docker images / prepare-release (push) Successful in 9s
Docker images / notify-webhook (push) Skipped
Не сканировать RIPE и каталог по каждой строке окна; сервисы по уникальным dst и кэшу ASN. Порог доли на карте настраиваемый и отключаемый.

Co-authored-by: Cursor <[email protected]>
2026-09-07 14:17:04 +07:00
DenozordecandCursor db64621122 feat(network-map): показать конечные сервисы на карте сети
Docker images / frontend-image (push) Successful in 3m15s
Docker images / publish-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 2m0s
Docker images / updater-image (push) Successful in 45s
Docker images / prepare-release (push) Successful in 9s
Docker images / notify-webhook (push) Skipped
NetFlow ≥ 5% окна, узлы с логотипом бренда справа от EN, скорость потока на рёбрах к сервисам.

Co-authored-by: Cursor <[email protected]>
2026-09-07 13:30:45 +07:00
DenozordecandCursor 6332d83a12 feat(traffic): показать поток NetFlow на карте сети
Docker images / frontend-image (push) Successful in 3m14s
Docker images / publish-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m14s
Docker images / updater-image (push) Successful in 46s
Docker images / prepare-release (push) Successful in 10s
Docker images / notify-webhook (push) Skipped
Скорость между узлами считается как в Трафике (5 мин, без overlay/mesh), отдельно от ёмкости WAN и BT.

Co-authored-by: Cursor <[email protected]>
2026-09-07 12:37:47 +07:00
DenozordecandCursor 3834c40aa8 fix(traffic): починить сборку tsc для частичных IPFIX-фикстур
Docker images / frontend-image (push) Successful in 3m15s
Docker images / publish-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 1m50s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / prepare-release (push) Successful in 6s
Тесты не должны попадать в Docker tsc; ingest принимает ParsedFlowInput и нормализует поля.

Co-authored-by: Cursor <[email protected]>
2026-09-07 11:45:22 +07:00
DenozordecandCursor 90c8c393e5 feat(traffic): разделить плоскости IPFIX и показать путь JH→EN
Docker images / frontend-image (push) Successful in 2m36s
Docker images / publish-release (push) Skipped
Docker images / backend-image (push) Failing after 1m31s
Docker images / updater-image (push) Successful in 42s
Docker images / prepare-release (push) Successful in 8s
Docker images / notify-webhook (push) Skipped
Считать payload отдельно от overlay GRE/ESP и mesh; вкладка Пути и KPI Wire из счётчиков iface.

Co-authored-by: Cursor <[email protected]>
2026-09-07 11:39:14 +07:00
DenozordecandCursor cb799da13a fix(netflow): изолировать коллектор IPFIX и срезать раздувание базы
Docker images / frontend-image (push) Successful in 3m16s
Docker images / publish-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 2m23s
Docker images / updater-image (push) Successful in 57s
Docker images / prepare-release (push) Successful in 8s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-07 09:50:24 +07:00
DenozordecandCursor e0ddb17539 feat(traffic): классифицировать потоки по брендам и разгрузить ingest
Docker images / frontend-image (push) Successful in 2m53s
Docker images / publish-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m48s
Docker images / updater-image (push) Successful in 41s
Docker images / prepare-release (push) Successful in 12s
Docker images / notify-webhook (push) Skipped
Починить ISO-страну Cloudflare, не держать holder как сервис, клик по срезу открывает Сессии. Не вызывать REST /interface с UDP, flush SQLite пачкой — uptime не должен ловить timeout 10s.

Co-authored-by: Cursor <[email protected]>
2026-09-07 03:01:14 +07:00
DenozordecandCursor 2820683cba feat(traffic): добавить карту и срезы IPFIX по странам и сессиям
Docker images / frontend-image (push) Successful in 3m23s
Docker images / publish-release (push) Successful in 14s
Docker images / backend-image (push) Successful in 2m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / prepare-release (push) Successful in 11s
Имена ifIndex пишутся по обоим ключам REST, дедуп 5-tuple по max байт, RIPEstat только из prefix-кэша, карта откуда-куда в Frame.

Co-authored-by: Cursor <[email protected]>
2026-09-07 02:13:21 +07:00
DenozordecandCursor 37167f78e3 feat(traffic): показать аналитику IPFIX по серверам и интерфейсам
Docker images / frontend-image (push) Successful in 2m53s
Docker images / publish-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m10s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / prepare-release (push) Successful in 12s
Резолвить ifIndex в имена RouterOS, дать вкладке Потоки ту же оболочку сервер/клиент/iface, что у обычного трафика, и обновлять срезы live без перезагрузки.

Co-authored-by: Cursor <[email protected]>
2026-09-07 01:21:12 +07:00
DenozordecandCursor cf68b59b3f fix(traffic): сбрасывать src-address Traffic Flow в авто
Docker images / frontend-image (push) Successful in 3m11s
Docker images / publish-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 2m2s
Docker images / updater-image (push) Successful in 47s
Docker images / prepare-release (push) Successful in 11s
Docker images / notify-webhook (push) Skipped
Target с адресом wg-flow помечался invalid, IPFIX не уходил на коллектор.

Co-authored-by: Cursor <[email protected]>
2026-09-07 00:37:12 +07:00
DenozordecandCursor 5e512407e5 fix(traffic): записывать потоки при подмене UDP-источника в Docker
Docker images / frontend-image (push) Successful in 2m57s
Docker images / publish-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m11s
Docker images / updater-image (push) Successful in 46s
Docker images / prepare-release (push) Successful in 11s
Docker images / notify-webhook (push) Skipped
Пакеты IPFIX доходили, но разговоры отбрасывались, если Docker подменял адрес jump-host на 172.x.

Co-authored-by: Cursor <[email protected]>
2026-09-07 00:02:50 +07:00
DenozordecandCursor 13889005f8 refactor(traffic): update traffic flow host file generation and configuration scripts
Docker images / backend-image (push) Successful in 1m39s
Docker images / frontend-image (push) Successful in 3m2s
Docker images / publish-release (push) Successful in 9s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / prepare-release (push) Successful in 6s
- Renamed and refactored functions for better clarity, including `buildHostComposeSnippet` to `buildHostComposeOverride` and `buildHostNftSnippet` to `buildHostLinuxInstallSh`.
- Introduced a new script for Linux installation of WireGuard, enhancing the setup process for Docker hosts.
- Updated the `generateNativeConf` function to conditionally include the listen port in the configuration.
- Adjusted the `FlowOverlaySheet` component to default to the new Linux tab and improved the user interface for selecting jump-hosts.

Co-authored-by: Cursor <[email protected]>
2026-09-06 23:20:12 +07:00
DenozordecandCursor f0dc5acfd3 fix(traffic): сделать JH сервером WG и патчить traffic-flow по id
Docker images / frontend-image (push) Successful in 2m52s
Docker images / publish-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / updater-image (push) Successful in 45s
Docker images / prepare-release (push) Successful in 11s
Docker images / notify-webhook (push) Skipped
RouterOS не принимает PATCH без .id у singleton. Пир на jump-host без endpoint: клиент — хост MM с Endpoint JH:13232.

Co-authored-by: Cursor <[email protected]>
2026-09-06 22:58:31 +07:00
DenozordecandCursor 63bed28251 fix(ci): импортировать TrafficFlowHostFile в клиенте overlay
Docker images / frontend-image (push) Successful in 3m6s
Docker images / publish-release (push) Successful in 16s
Docker images / backend-image (push) Successful in 56s
Docker images / updater-image (push) Successful in 49s
Docker images / prepare-release (push) Successful in 11s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-06 22:24:44 +07:00
DenozordecandCursor 95dcd3df58 fix(traffic): спрашивать endpoint MM в sheet подключения JH
Docker images / frontend-image (push) Failing after 2m38s
Docker images / publish-release (push) Skipped
Docker images / backend-image (push) Successful in 1m31s
Docker images / updater-image (push) Successful in 46s
Docker images / prepare-release (push) Successful in 6s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-06 22:16:41 +07:00
DenozordecandCursor 1e9312acbd feat(traffic): добавить приём Traffic Flow с jump-host
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 3m56s
Docker images / publish-release (push) Successful in 12s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / prepare-release (push) Successful in 8s
Чтобы видеть «кто с кем», а не только объём порта: IPFIX внутри WG на хосте Docker MM, REST-счётчики не трогаем.

Co-authored-by: Cursor <[email protected]>
2026-09-06 21:54:30 +07:00
Denozordec 5884bd8873 feat(traffic, users): enhance interface and peer management features
Docker images / frontend-image (push) Successful in 2m44s
Docker images / publish-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 1m37s
Docker images / updater-image (push) Successful in 40s
Docker images / prepare-release (push) Successful in 7s
Docker images / notify-webhook (push) Skipped
Added support for managing peer information in interface bindings, including new fields for peerPublicKey and peerName in the BoundIfaceTraffic interface. Updated the database schema to include these fields in user_interface_bindings and traffic_samples tables. Enhanced the UI components to display peer details alongside interface names, improving user experience and clarity in the traffic management system. Updated relevant functions and services to handle peer-specific logic, ensuring robust integration across the application.
2026-09-06 20:45:37 +07:00
Denozordec fc161506e7 feat(users): enhance interface type mapping and parsing logic
Docker images / frontend-image (push) Successful in 2m11s
Docker images / publish-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m30s
Docker images / updater-image (push) Successful in 40s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 6s
Updated the mapRosInterfaceType function to support additional interface types, including "gre-tunnel" and improved handling for various input formats. Enhanced the parseRawInterfaces function to accommodate new interface types in the test cases, ensuring accurate parsing and type assignment. Added corresponding test cases to validate the new functionality and ensure robustness in interface type handling.
2026-09-06 19:35:01 +07:00
Denozordec b3e50a1f5f feat(users): implement user management features and database schema
Docker images / frontend-image (push) Successful in 4m8s
Docker images / publish-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 1m57s
Docker images / updater-image (push) Successful in 44s
Docker images / prepare-release (push) Successful in 10s
Docker images / notify-webhook (push) Skipped
Added user management functionality, including the creation of app_users and user_interface_bindings tables in the database. Implemented API routes for user data retrieval and permissions handling. Enhanced the traffic monitoring system to include user traffic statistics and interface bindings. Updated relevant components and services to support the new user features, improving overall application functionality and user experience.
2026-09-06 19:20:05 +07:00
Denozordec fe32c9313a feat(ui): integrate KpiStatGrid for enhanced statistics display
Docker images / frontend-image (push) Successful in 2m58s
Docker images / publish-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m43s
Docker images / updater-image (push) Successful in 39s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 9s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
2026-09-06 17:58:05 +07:00
Denozordec 6123660346 feat(terminal, wireguard): enhance UI components and functionality
Docker images / frontend-image (push) Successful in 2m45s
Docker images / publish-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 1m36s
Docker images / updater-image (push) Successful in 43s
Docker images / prepare-release (push) Successful in 7s
Docker images / notify-webhook (push) Skipped
Updated the TerminalPage to include a new ServerTileRail for server selection and added QuickCmds for quick command execution. Enhanced the WireGuardPage with new tabs for interface and peer management, improved server selection handling, and added support for displaying peer details. Refactored data grids to support compact server views and improved empty state handling. Introduced new hooks for managing server states and improved user experience across components.
2026-09-06 01:23:40 +07:00
Denozordec b680f882cc feat(ui): enhance CodeExportSheet and WgExportSheet components
Docker images / frontend-image (push) Successful in 2m47s
Docker images / publish-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m40s
Docker images / updater-image (push) Successful in 38s
Docker images / prepare-release (push) Successful in 9s
Docker images / notify-webhook (push) Skipped
Updated CodeExportSheet to improve UI elements, including the addition of a Badge component for live status indication and refined layout adjustments. Modified WgExportSheet to simplify format labels and incorporate live content fetching capabilities. Adjusted styles for better visual consistency and user experience.
2026-09-06 00:23:51 +07:00
DenozordecandCursor 5e0c16e808 fix(ui): выровнять confirms и токены под ReUI PRO
Docker images / frontend-image (push) Successful in 2m11s
Docker images / publish-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 1m31s
Docker images / updater-image (push) Successful in 40s
Docker images / prepare-release (push) Successful in 8s
Docker images / notify-webhook (push) Skipped
Добавить ui-design-contract, AlertDialog вместо hand-roll, CodeExportSheet для preview фильтров, ReUI Badge и gap вместо space-y.

Co-authored-by: Cursor <[email protected]>
2026-09-06 00:12:57 +07:00
DenozordecandCursor 66509b26bd fix(ui): выровнять экспорт WireGuard под ReUI Frame
Убрать дубль копирования, вынести CodeExportSheet и семантические токены.
Открывать вкладку Peer при экспорте пира. Мигрировать VXLAN/Firewall/Containers/GRE.

Co-authored-by: Cursor <[email protected]>
2026-09-06 00:12:50 +07:00
Denozordec 15ad53af1f feat(wireguard): implement WireGuard interface management and permissions
Docker images / backend-image (push) Successful in 1m39s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / publish-release (push) Successful in 10s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 53s
Docker images / prepare-release (push) Successful in 8s
Added comprehensive support for managing WireGuard interfaces, including CRUD operations and peer management. Updated permissions to include access control for WireGuard routes. Enhanced the UI components to display and interact with WireGuard configurations, improving user experience and functionality. Introduced new tests for WireGuard-related functionalities to ensure reliability.
2026-09-05 02:10:55 +07:00
Denozordec 883842636b refactor(api): streamline API requests with requestJson and requestBlob functions
Docker images / frontend-image (push) Successful in 2m6s
Docker images / publish-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m24s
Docker images / updater-image (push) Successful in 43s
Docker images / prepare-release (push) Successful in 6s
Docker images / notify-webhook (push) Skipped
Replaced direct fetch calls with requestJson and requestBlob utility functions across multiple components for improved consistency and error handling. This change enhances the maintainability of the codebase by centralizing API request logic and ensuring uniform handling of authentication and response parsing.
2026-09-05 01:42:44 +07:00
Denozordec b9f430de16 refactor(acme-cloudflare): enhance upsertARecord and syncCertificateDomainRecords functions
Docker images / frontend-image (push) Successful in 2m21s
Docker images / publish-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m32s
Docker images / updater-image (push) Successful in 41s
Docker images / prepare-release (push) Successful in 8s
Docker images / notify-webhook (push) Skipped
Updated the upsertARecord function to return status messages ("updated", "created", "skipped_cname") instead of void, improving clarity on record handling. Modified syncCertificateDomainRecords to collect and return skipped CNAME records, enhancing error handling and feedback during DNS operations.
2026-09-05 01:29:58 +07:00
Denozordec 25e040a5dd fix(settings): improve error handling and success notifications in EvoBGP settings
Docker images / backend-image (push) Successful in 1m26s
Docker images / frontend-image (push) Successful in 2m26s
Docker images / publish-release (push) Successful in 8s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / prepare-release (push) Successful in 7s
Enhanced the error handling in the settings page by introducing a dedicated error message function. Added success and error toast notifications for better user feedback during settings save operations. Updated API key normalization to ensure consistent handling across the application.
2026-09-05 00:07:58 +07:00
Denozordec f2df990746 chore(docker): enhance Dockerfile for backend to manage nested dependencies
Docker images / frontend-image (push) Successful in 2m12s
Docker images / publish-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m23s
Docker images / updater-image (push) Successful in 42s
Docker images / notify-webhook (push) Skipped
Docker images / prepare-release (push) Successful in 5s
Updated the Dockerfile to create a directory for nested workspace dependencies and adjusted the copy commands to ensure proper handling of node_modules during the build process.
2026-09-04 23:48:03 +07:00
Denozordec 77bc174e43 chore(deps): update package-lock.json and backend dependencies, adjust Dockerfile for leaner backend image
Docker images / frontend-image (push) Successful in 2m35s
Docker images / publish-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m46s
Docker images / updater-image (push) Successful in 38s
Docker images / prepare-release (push) Successful in 7s
Docker images / notify-webhook (push) Skipped
Removed unnecessary frontend dependencies from backend Docker image and updated package-lock.json to include new dev dependencies. Adjusted backend Dockerfile to streamline the build process and ensure proper workspace configuration.
2026-09-04 23:37:23 +07:00
DenozordecandCursor 0e9349e508 feat(auth): интегрировать SSO auth-portal
Docker images / backend-image (push) Successful in 2m52s
Docker images / frontend-image (push) Successful in 2m23s
Docker images / publish-release (push) Successful in 8s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / prepare-release (push) Successful in 5s
JWT на backend, handoff/callback на UI, RBAC mm:*, AUTH_* в compose.

Co-authored-by: Cursor <[email protected]>
2026-09-04 20:38:28 +07:00
DenozordecandCursor 0208aa4d7c chore(deploy): добавить Traefik-стеки и общий compose с CDNManager
Docker images / backend-image (push) Successful in 3m23s
Docker images / frontend-image (push) Successful in 2m1s
Docker images / publish-release (push) Successful in 6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / prepare-release (push) Successful in 5s
Единый стек CDN+MM, вариант рядом с CDN Traefik и CLI-скрипт запуска.

Co-authored-by: Cursor <[email protected]>
2026-09-04 19:07:49 +07:00
Denozordec 42a2e18047 docs: обновить раздел о прод-развёртывании Docker и изменить ссылки на репозиторий
Docker images / frontend-image (push) Successful in 3m45s
Docker images / publish-release (push) Skipped
Docker images / backend-image (push) Failing after 1m30s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 50s
Docker images / prepare-release (push) Successful in 23s
2026-09-04 18:54:26 +07:00
DenozordecandCursor 9df3971f6c chore(reui): обновить ReUI agent skill до актуальной версии
Docker images / frontend-image (push) Successful in 2m55s
Docker images / publish-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 5m58s
Docker images / updater-image (push) Successful in 53s
Docker images / prepare-release (push) Successful in 4s
Docker images / notify-webhook (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-08-19 20:03:55 +07:00
DenozordecandCursor 7ed269cf2c ci: переключить registry на git.shx.one
Docker images / frontend-image (push) Successful in 2m10s
Docker images / publish-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 4m25s
Docker images / updater-image (push) Successful in 45s
Docker images / prepare-release (push) Successful in 6s
Docker images / notify-webhook (push) Skipped
Обновить Gitea Actions, compose, updater и ссылки на образы.

Co-authored-by: Cursor <[email protected]>
2026-08-17 16:43:40 +07:00
Denozordec 7f81c304ae fix(config): добавить ключ лицензии REUI в настройки локальной конфигурации
Docker images / frontend-image (push) Successful in 1m49s
Docker images / publish-release (push) Skipped
Docker images / backend-image (push) Failing after 2m40s
Docker images / updater-image (push) Successful in 42s
Docker images / prepare-release (push) Successful in 5s
Docker images / notify-webhook (push) Skipped
2026-08-17 15:42:10 +07:00
DenozordecandCursor 5f29cfaf0f fix(ui): выровнять chrome Frame и убрать Card-оболочки
Docker images / frontend-image (push) Successful in 2m22s
Docker images / publish-release (push) Skipped
Docker images / backend-image (push) Failing after 2m36s
Docker images / updater-image (push) Successful in 55s
Docker images / prepare-release (push) Successful in 4s
Docker images / notify-webhook (push) Skipped
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell.

Co-authored-by: Cursor <[email protected]>
2026-08-17 15:36:56 +07:00
2558 changed files with 337721 additions and 22036 deletions

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
---
name: reui
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
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **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 is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **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
- **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
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [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/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/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/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
+43
View File
@@ -0,0 +1,43 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
+60
View File
@@ -0,0 +1,60 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_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 MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- 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.
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).
+428
View File
@@ -0,0 +1,428 @@
# ReUI components
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.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**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
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
+45
View File
@@ -0,0 +1,45 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
+39
View File
@@ -0,0 +1,39 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
+22
View File
@@ -0,0 +1,22 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
+35
View File
@@ -0,0 +1,35 @@
# ReUI registry structure
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 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)`.
- **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).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **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)).
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+26
View File
@@ -0,0 +1,26 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
+54
View File
@@ -0,0 +1,54 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
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 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its 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.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+58
View File
@@ -0,0 +1,58 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
## 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?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?ref=skill (upgrade). Free accounts still get all components + examples.
- **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.
## Fallbacks
- 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.
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+15 -5
View File
@@ -11,7 +11,7 @@ const outputDir = process.env.RELEASE_OUTPUT_DIR
? join(repoRoot, process.env.RELEASE_OUTPUT_DIR) ? join(repoRoot, process.env.RELEASE_OUTPUT_DIR)
: join(repoRoot, ".ci", "release") : join(repoRoot, ".ci", "release")
const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? "" const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shts.su").replace(/\/$/, "") const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shx.one").replace(/\/$/, "")
const MANIFEST_COMMIT_LIMIT = Number(process.env.RELEASE_MANIFEST_COMMIT_LIMIT ?? 6) const MANIFEST_COMMIT_LIMIT = Number(process.env.RELEASE_MANIFEST_COMMIT_LIMIT ?? 6)
const CONVENTIONAL_RE = const CONVENTIONAL_RE =
@@ -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 })
}
})
+6 -3
View File
@@ -12,9 +12,9 @@
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)", "Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)", "Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)", "Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
"WebFetch(domain:git.shts.su)", "WebFetch(domain:git.shx.one)",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")", "Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")", "Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"Bash(node -e ' *)", "Bash(node -e ' *)",
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")", "Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")", "Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
@@ -63,5 +63,8 @@
"Bash(xargs grep *)", "Bash(xargs grep *)",
"Bash(npx shadcn@latest add popover --yes)" "Bash(npx shadcn@latest add popover --yes)"
] ]
},
"env": {
"REUI_LICENSE_KEY": "REUI-EAF4-EA40-6C5C-5D86"
} }
} }
+67
View File
@@ -0,0 +1,67 @@
---
name: reui
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
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **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 is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **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
- **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
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [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/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/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/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
+43
View File
@@ -0,0 +1,43 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
+60
View File
@@ -0,0 +1,60 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_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 MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- 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.
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).
+428
View File
@@ -0,0 +1,428 @@
# ReUI components
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.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**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
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
+45
View File
@@ -0,0 +1,45 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
+39
View File
@@ -0,0 +1,39 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
+22
View File
@@ -0,0 +1,22 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
+35
View File
@@ -0,0 +1,35 @@
# ReUI registry structure
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 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)`.
- **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).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **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)).
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+26
View File
@@ -0,0 +1,26 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
+54
View File
@@ -0,0 +1,54 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
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 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its 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.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+58
View File
@@ -0,0 +1,58 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
## 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?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?ref=skill (upgrade). Free accounts still get all components + examples.
- **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.
## Fallbacks
- 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.
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"
+1 -1
View File
@@ -18,7 +18,7 @@ alwaysApply: false
## Эталон UI ## Эталон UI
- **`app/(main)/servers`** — главный эталон layout, сетки, типографики, композиции и взаимодействий. Не вводить новый UI-паттерн, если на `/servers` уже есть эквивалент. - **`app/(main)/servers`** — эталон **поведения** CRUD. Оболочка ops: **Frame + DataGrid**, не Card-shell. Не копировать `DataPageCard` на новые экраны.
## Качество и совместимость ## Качество и совместимость
+15 -15
View File
@@ -1,11 +1,11 @@
--- ---
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI только с эталоном /servers" description: "[Multi-agent] Роль UI GUARDIAN — сверка UI с CRUD /servers и оболочкой Frame+DataGrid"
alwaysApply: false alwaysApply: false
--- ---
# Агент: UI GUARDIAN (хранитель UI) # Агент: UI GUARDIAN (хранитель UI)
Ты отвечаешь **только** за **визуальную и UX-согласованность** с эталоном **`/servers`**. Ты отвечаешь **только** за **визуальную и UX-согласованность**.
## Язык ## Язык
@@ -13,31 +13,31 @@ alwaysApply: false
## Эталон ## Эталон
- Каталог **`app/(main)/servers`** и связанные компоненты страницы — **единственный** эталон для: Два слоя (не смешивать):
- структуры layout (области страницы, карточки, секции);
- сетки и отступов; 1. **Поведение CRUD** — каталог **`app/(main)/servers`**: фильтры, таблица, диалоги, формы, плотность, паттерны кнопок.
- типографики (заголовки, подписи, плотность текста); 2. **Оболочка ops** — **ReUI Frame + DataGrid**, не shadcn `Card` как page shell. KPI — `IconTile` `variant="elevated"` `className="size-10.5"`. Preview: [frame](https://reui.io/docs/components/base/frame) · [data-grid](https://reui.io/docs/components/base/data-grid) · [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile).
- композиции shadcn-компонентов;
- паттернов взаимодействия (кнопки, таблицы, диалоги, формы — как на `/servers`). `DataPageCard` — ops list shell на ReUI Frame; новые экраны не копируют Card-shell.
## Обязанности ## Обязанности
- Сравнить **каждое** спорное UI-решение в изменениях с тем, как сделано на `/servers`. - Сравнить **каждое** спорное UI-решение: CRUD — с `/servers`; оболочка — с Frame+DataGrid.
- Требовать **переиспользование** существующих компонентов/обёрток из этой зоны, если они покрывают задачу. - Требовать **переиспользование** существующих компонентов/обёрток, если они покрывают задачу.
## Правила ## Правила
- **Нельзя** принимать UI, который **системно расходится** с `/servers` без явной технической необходимости (которую нужно назвать). - **Нельзя** принимать новый Card page-shell без явной технической необходимости (назвать её).
- **Нельзя** предлагать «новый стиль» ради вариации — только выравнивание с эталоном или расширение существующих примитивов. - **Нельзя** предлагать «новый стиль» ради вариации — только выравнивание с эталоном или расширение существующих примитивов.
## Формат вывода (строго) ## Формат вывода (строго)
1. **Несоответствия** эталону (конкретно: что именно отличается). 1. **Несоответствия** эталону (конкретно: CRUD vs Frame-shell).
2. **Обязательные правки** (что поменять). 2. **Обязательные правки** (что поменять).
3. **Что переиспользовать** с `/servers`: файлы/компоненты и паттерн. 3. **Что переиспользовать**: файлы/компоненты и паттерн.
Если расхождений нет — явно: **«Расхождений с эталоном /servers не выявлено»** и кратко перечисли проверенные аспекты. Если расхождений нет — явно: **«Расхождений с эталоном CRUD /servers и Frame+DataGrid не выявлено»** и кратко перечисли проверенные аспекты.
## Граница ответственности ## Граница ответственности
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности с /servers** и **reuse** UI. - Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности** и **reuse** UI.
+8 -7
View File
@@ -16,11 +16,12 @@ alwaysApply: true
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта). - Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
- Не выдумывать API и «недокументированные» паттерны; предпочитать стабильные, описанные в доках решения. - Не выдумывать API и «недокументированные» паттерны; предпочитать стабильные, описанные в доках решения.
## 2. Эталон UI/UX: страница `/servers` (критично) ## 2. Эталон UI/UX: `/servers` (CRUD) + Frame+DataGrid (оболочка)
- **`app/(main)/servers`** (и связанные компоненты) — **главный эталон** дизайна и поведения. - **`app/(main)/servers`** — эталон **поведения** CRUD: фильтры, сортировка, пагинация, диалоги, формы, плотность.
- Выравнивать: layout, отступы, сетку, типографику, структуру компонентов, паттерны взаимодействия. - **Оболочка ops-списков / dashboard / KPI:** **ReUI Frame + DataGrid**, не shadcn `Card` как page shell. Preview: [frame](https://reui.io/docs/components/base/frame) · [data-grid](https://reui.io/docs/components/base/data-grid) · [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile).
- Переиспользовать оттуда же компоненты и паттерны; **не вводить новый UI-паттерн**, если эквивалент уже есть на `/servers`. - `DataPageCard` — ops list shell на ReUI Frame (`components/data-page-card.tsx`). Внутренние панели — `OpsPanel` / Frame, не shadcn Card как page shell.
- Переиспользовать паттерны `/servers` для CRUD; **не** вводить Card-grid как новый SoT.
## 3. Большой репозиторий (критично) ## 3. Большой репозиторий (критично)
@@ -50,7 +51,7 @@ alwaysApply: true
В ответе явно указать: В ответе явно указать:
- **Почему** это согласуется с Next.js, shadcn/ui и эталоном **`/servers`**. - **Почему** это согласуется с Next.js, shadcn/ui, CRUD `/servers` и оболочкой Frame+DataGrid.
- **Server vs Client Component** и обоснование. - **Server vs Client Component** и обоснование.
- Что **переиспользовано** из проекта (паттерны/компоненты). - Что **переиспользовано** из проекта (паттерны/компоненты).
- Какие **файлы проанализированы**. - Какие **файлы проанализированы**.
@@ -68,7 +69,7 @@ alwaysApply: true
1. **Шаг 0:** список проанализированных файлов. 1. **Шаг 0:** список проанализированных файлов.
2. **Шаг 1:** что изменено. 2. **Шаг 1:** что изменено.
3. **Шаг 2:** почему (Next.js + shadcn/ui + `/servers` + паттерны кодовой базы). 3. **Шаг 2:** почему (Next.js + shadcn/ui + CRUD `/servers` + Frame+DataGrid + паттерны кодовой базы).
4. **Шаг 3:** анализ влияния (что ещё затронуто). 4. **Шаг 3:** анализ влияния (что ещё затронуто).
5. **Шаг 4:** код (диффы или несколько файлов). 5. **Шаг 4:** код (диффы или несколько файлов).
6. **Шаг 5:** проверка dev/консоли (ошибки, предупреждения, исправления). 6. **Шаг 5:** проверка dev/консоли (ошибки, предупреждения, исправления).
@@ -80,4 +81,4 @@ alwaysApply: true
## Цель ## Цель
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, выравнивание с `/servers`, безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности. Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, CRUD как на `/servers`, оболочка Frame+DataGrid (не Card-shell), безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
+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)!:` с русским описанием.
## Примеры ## Примеры
+73
View File
@@ -0,0 +1,73 @@
---
description: Use the ReUI registry (blocks, primitives, icons) correctly
globs: ["**/*.tsx","**/*.ts"]
alwaysApply: false
---
---
name: reui
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
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **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 is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **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
- **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
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [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/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/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/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
+67
View File
@@ -0,0 +1,67 @@
---
name: reui
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
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **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 is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **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
- **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
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [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/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/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/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
+43
View File
@@ -0,0 +1,43 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
+60
View File
@@ -0,0 +1,60 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_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 MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- 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.
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).
+428
View File
@@ -0,0 +1,428 @@
# ReUI components
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.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**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
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
+45
View File
@@ -0,0 +1,45 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
+39
View File
@@ -0,0 +1,39 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
+22
View File
@@ -0,0 +1,22 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
+35
View File
@@ -0,0 +1,35 @@
# ReUI registry structure
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 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)`.
- **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).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **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)).
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+26
View File
@@ -0,0 +1,26 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
+54
View File
@@ -0,0 +1,54 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
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 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its 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.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+58
View File
@@ -0,0 +1,58 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
## 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?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?ref=skill (upgrade). Free accounts still get all components + examples.
- **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.
## Fallbacks
- 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.
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
+161 -10
View File
@@ -7,8 +7,8 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
REGISTRY: git.shts.su REGISTRY: git.shx.one
GITEA_SERVER_URL: https://git.shts.su GITEA_SERVER_URL: https://git.shx.one
jobs: jobs:
prepare-release: prepare-release:
@@ -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
@@ -38,8 +41,85 @@ jobs:
node .ci/scripts/compute-release.mjs node .ci/scripts/compute-release.mjs
test -f .ci/release/release-manifest.json test -f .ci/release/release-manifest.json
backend-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Start PostgreSQL 18
shell: bash
run: |
set -euo pipefail
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_PASSWORD=mmapp \
-e POSTGRES_DB=mmapp \
"${NETWORK_ARGS[@]}" \
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
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
exit 0
fi
sleep 1
done
echo "PostgreSQL не поднялся"
docker logs "$NAME" || true
exit 1
- name: Install and test backend
shell: bash
env:
REQUIRE_TEST_POSTGRES: "1"
run: |
set -euo pipefail
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
- name: Stop PostgreSQL
if: always()
shell: bash
run: docker rm -f "${PG_CONTAINER:-}" 2>/dev/null || true
backend-image: backend-image:
needs: prepare-release needs:
- prepare-release
- backend-test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -61,22 +141,34 @@ jobs:
STAGING=".ci/docker/backend" STAGING=".ci/docker/backend"
rm -rf "$STAGING" rm -rf "$STAGING"
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend" mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
cp package.json package-lock.json "$STAGING/" cp package-lock.json "$STAGING/"
node <<'NODE'
const fs = require("node:fs")
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
pkg.workspaces = ["packages/*", "backend"]
pkg.dependencies = {}
pkg.devDependencies = {}
delete pkg.scripts
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
NODE
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/" cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
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
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Registry - name: Log in to Gitea Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.shts.su registry: git.shx.one
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }} password: ${{ secrets.ACTIONS_PAT }}
@@ -161,7 +253,7 @@ jobs:
- name: Log in to Gitea Registry - name: Log in to Gitea Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.shts.su registry: git.shx.one
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }} password: ${{ secrets.ACTIONS_PAT }}
@@ -177,6 +269,7 @@ jobs:
NEXT_PUBLIC_BACKEND_URL=same-origin NEXT_PUBLIC_BACKEND_URL=same-origin
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
NEXT_PUBLIC_ALLOW_MOCK_DATA=false NEXT_PUBLIC_ALLOW_MOCK_DATA=false
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }} NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }} NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
tags: | tags: |
@@ -214,7 +307,7 @@ jobs:
- name: Log in to Gitea Registry - name: Log in to Gitea Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.shts.su registry: git.shx.one
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }} password: ${{ secrets.ACTIONS_PAT }}
@@ -238,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:
@@ -325,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 \
+21
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
@@ -49,3 +53,20 @@ Thumbs.db
tmp/ tmp/
temp/ temp/
.ci/docker/ .ci/docker/
.claude/settings.local.json
# Local MCP configs (may contain REUI license Bearer)
.cursor/mcp.json
.mcp.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/
+67
View File
@@ -0,0 +1,67 @@
---
name: reui
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
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **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 is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **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
- **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
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [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/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/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/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
+43
View File
@@ -0,0 +1,43 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
+60
View File
@@ -0,0 +1,60 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_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 MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- 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.
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).
+428
View File
@@ -0,0 +1,428 @@
# ReUI components
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.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**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
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
+45
View File
@@ -0,0 +1,45 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
+39
View File
@@ -0,0 +1,39 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
+22
View File
@@ -0,0 +1,22 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
+35
View File
@@ -0,0 +1,35 @@
# ReUI registry structure
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 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)`.
- **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).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **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)).
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+26
View File
@@ -0,0 +1,26 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
+54
View File
@@ -0,0 +1,54 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
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 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its 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.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+58
View File
@@ -0,0 +1,58 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
## 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?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?ref=skill (upgrade). Free accounts still get all components + examples.
- **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.
## Fallbacks
- 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.
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): …`.
+7
View File
@@ -14,3 +14,10 @@
## Локальный запуск ## Локальный запуск
Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`. Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
## ReUI
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).
Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
+2
View File
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
ARG NEXT_PUBLIC_APP_VERSION=dev ARG NEXT_PUBLIC_APP_VERSION=dev
ARG NEXT_PUBLIC_RELEASE_URL= ARG NEXT_PUBLIC_RELEASE_URL=
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL
COPY packages/contracts packages/contracts COPY packages/contracts packages/contracts
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./ COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
COPY app app COPY app app
+125 -48
View File
@@ -19,17 +19,17 @@
| Компонент | Путь | Стек | Порт (runtime) | Docker-образ | | Компонент | Путь | Стек | Порт (runtime) | Docker-образ |
|-----------|------|------|----------------|--------------| |-----------|------|------|----------------|--------------|
| Frontend | корень (`app/`, `components/`, `lib/`, …) | Next.js 16.2.4 (App Router), React 19 | 3000 | `…-frontend` | | Frontend | корень (`app/`, `components/`, `lib/`, …) | Next.js 16.2.4 (App Router), React 19 | 3000 | `…-frontend` |
| Backend | `backend/` | Fastify 5, TypeScript ESM, SQLite, Drizzle | 8000 | `…-backend` | | Backend | `backend/` | Fastify 5, TypeScript ESM, PostgreSQL 18, Drizzle | 8000 | `…-backend` |
| Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend | | Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend |
| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` | | Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — | | Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
| CI | `.gitea/workflows/docker.yml` | Gitea Actions, Buildx | — | push в `git.shts.su` | | CI | `.gitea/workflows/docker.yml` | Gitea Actions, Buildx | — | push в `git.shx.one` |
Имена образов в registry для репозитория `denozord/MikrotikManager-3`: Имена образов в registry для репозитория `denozord/MikrotikManager-3`:
- `git.shts.su/denozord/mikrotikmanager-backend` - `git.shx.one/denozord/mikrotikmanager-backend`
- `git.shts.su/denozord/mikrotikmanager-frontend` - `git.shx.one/denozord/mikrotikmanager-frontend`
- `git.shts.su/denozord/mikrotikmanager-updater` - `git.shx.one/denozord/mikrotikmanager-updater`
Для другого owner/repo подставьте имя по правилу CI (см. [CI/CD](#cicd-gitea-actions)). Для другого owner/repo подставьте имя по правилу CI (см. [CI/CD](#cicd-gitea-actions)).
@@ -43,24 +43,25 @@ flowchart TB
Browser["Браузер"] Browser["Браузер"]
NextDev["Next.js :3000"] NextDev["Next.js :3000"]
FastifyDev["Fastify :8000"] FastifyDev["Fastify :8000"]
SqliteDev["SQLite файл"] PostgresDev["PostgreSQL 18"]
Contracts["@mmapp/contracts"] Contracts["@mmapp/contracts"]
Browser --> NextDev Browser --> NextDev
Browser -->|"fetch /api, CORS"| FastifyDev Browser -->|"fetch /api, CORS"| FastifyDev
FastifyDev --> SqliteDev FastifyDev --> PostgresDev
Contracts --> NextDev Contracts --> NextDev
Contracts --> FastifyDev Contracts --> FastifyDev
end end
subgraph ci [Gitea Actions] subgraph ci [Gitea Actions]
WF[".gitea/workflows/docker.yml"] WF[".gitea/workflows/docker.yml"]
WF --> Reg["git.shts.su registry"] WF --> Reg["git.shx.one registry"]
end end
subgraph prod [Прод-сервер] subgraph prod [Прод-сервер]
FE["mmapp-frontend :3000"] FE["mmapp-frontend :3000"]
BE["mmapp-backend :8000"] BE["mmapp-backend :8000"]
DBVol["volume backend-data /app/data"] PG["mmapp-postgres"]
DBVol["volume sqlite ETL /app/data"]
UPD["mmapp-updater"] UPD["mmapp-updater"]
Sock["/var/run/docker.sock"] Sock["/var/run/docker.sock"]
Reg --> FE Reg --> FE
@@ -69,6 +70,7 @@ flowchart TB
UPD --> Sock UPD --> Sock
UPD --> FE UPD --> FE
UPD --> BE UPD --> BE
BE --> PG
BE --> DBVol BE --> DBVol
BrowserProd["Клиент"] --> FE BrowserProd["Клиент"] --> FE
BrowserProd -->|"live + backendUrl"| BE BrowserProd -->|"live + backendUrl"| BE
@@ -117,7 +119,7 @@ sequenceDiagram
``` ```
- Триггер workflow: `push` в ветку `main`, ручной `workflow_dispatch`. - Триггер workflow: `push` в ветку `main`, ручной `workflow_dispatch`.
- Параллельные jobs: `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`. - Параллельные jobs: `backend-test` (PostgreSQL 18), `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
- Теги на каждый успешный push: **`:latest`** и **`:<commit-sha>`**; платформа **linux/amd64**. - Теги на каждый успешный push: **`:latest`** и **`:<commit-sha>`**; платформа **linux/amd64**.
- На сервере образы подтягиваются вручную (`docker compose pull`) и/или через **updater** (сравнение digest у тега из `targets.json`). Webhook CI **не заменяет** updater. - На сервере образы подтягиваются вручную (`docker compose pull`) и/или через **updater** (сравнение digest у тега из `targets.json`). Webhook CI **не заменяет** updater.
@@ -125,7 +127,8 @@ sequenceDiagram
| Зависимость | Реализация | | Зависимость | Реализация |
|-------------|------------| |-------------|------------|
| SQLite | Не отдельный контейнер. Файл `mikrotik.db` в томе `backend-data` → `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db` в образе backend). | | 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`). При старте, пока нет маркера `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). |
## Локальная разработка ## Локальная разработка
@@ -134,7 +137,9 @@ sequenceDiagram
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`). - **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`. - **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся. - Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся (нужен только для одноразового boot-ETL из `mikrotik.db`).
- Локально нужен PostgreSQL 18 (`deploy/docker-compose.postgres.yml` или `DATABASE_URL`).
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
### Запуск ### Запуск
@@ -160,7 +165,8 @@ Backend — скопировать `backend/.env.example` в `backend/.env`:
| Переменная | По умолчанию | Назначение | | Переменная | По умолчанию | Назначение |
|------------|--------------|------------| |------------|--------------|------------|
| `DATABASE_PATH` | `./mikrotik.db` | путь к файлу SQLite | | `DATABASE_URL` | `postgres://mmapp:[email protected]:5432/mmapp` | подключение к PostgreSQL |
| `DATABASE_PATH` | `./mikrotik.db` | SQLite только для boot-ETL, если PG пустой |
| `PORT` | `8000` | порт Fastify | | `PORT` | `8000` | порт Fastify |
| `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS | | `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS |
@@ -178,23 +184,29 @@ Frontend — в репозитории нет корневого `.env.example`.
npm run build -w @mmapp/contracts npm run build -w @mmapp/contracts
``` ```
После изменения схем Drizzle: Схема PostgreSQL накатывается при старте backend из [`backend/drizzle/0000_postgresql.sql`](backend/drizzle/0000_postgresql.sql). Опциональный ETL:
```bash ```bash
npm --prefix backend run db:generate npm --prefix backend run db:migrate-from-sqlite
npm --prefix backend run db:migrate
npm --prefix backend run db:studio
``` ```
### 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**).
| Job | Build context | Dockerfile | Имя образа | | Job | Build context | Dockerfile | Имя образа |
|-----|---------------|------------|------------| |-----|---------------|------------|------------|
| `backend-image` | `.ci/docker/backend` (staging в CI) | `backend/Dockerfile` | `git.shts.su/<owner>/<stem>-backend` | | `backend-image` | `.ci/docker/backend` (staging в CI) | `backend/Dockerfile` | `git.shx.one/<owner>/<stem>-backend` |
| `frontend-image` | `.ci/docker/frontend` | `Dockerfile.frontend` | `git.shts.su/<owner>/<stem>-frontend` | | `frontend-image` | `.ci/docker/frontend` | `Dockerfile.frontend` | `git.shx.one/<owner>/<stem>-frontend` |
| `updater-image` | `deploy/updater` | `deploy/updater/Dockerfile` | `git.shts.su/<owner>/<stem>-updater` | | `updater-image` | `deploy/updater` | `deploy/updater/Dockerfile` | `git.shx.one/<owner>/<stem>-updater` |
- `<owner>` — первая часть `gitea.repository`, lower case. - `<owner>` — первая часть `gitea.repository`, lower case.
- `<stem>` — имя репозитория lower case без суффикса `-<цифры>` в конце (например `MikrotikManager-3` → `mikrotikmanager`). - `<stem>` — имя репозитория lower case без суффикса `-<цифры>` в конце (например `MikrotikManager-3` → `mikrotikmanager`).
@@ -207,27 +219,83 @@ npm --prefix backend run db:studio
### Версионирование и релизы ### Версионирование и релизы
- Базовая версия: **`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.shts.su` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`. - Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта). - UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта).
- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump. - Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump.
- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc); subject и body — **на русском**, префикс Conventional Commits — на английском. - Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc); subject и body — **на русском**, префикс Conventional Commits — на английском.
## Прод-развёртывание Docker ## Прод-развёртывание Docker
Эталон: `deploy/docker-compose.yml`. Рабочий каталог для команд compose — `deploy/` (или укажите `-f deploy/docker-compose.yml` из корня репозитория). Эталон без reverse-proxy: `deploy/docker-compose.yml` (порты `3000` / `8000` на хост).
Стек с Traefik + HTTPS (Let's Encrypt DNS-01 / Cloudflare), по аналогии с CDNManager: [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) + [`deploy/env.traefik.example`](deploy/env.traefik.example). На сервере публикуются только `:80`/`:443`; frontend получает HTTPS, `/api` и `/health` проксируются на backend внутри сети `mmapp`. Домен по умолчанию: `mm.shnt.top`.
### CDN Manager + MikrotikManager (один Traefik)
Полный стек: Traefik + `cdn.shnt.top` + `mm.shnt.top` в одном Compose.
| Файл | Назначение |
|------|------------|
| [`deploy/docker-compose.cdn-mm.yml`](deploy/docker-compose.cdn-mm.yml) | Traefik + CDN Manager + MM backend/frontend/updater |
| [`deploy/env.cdn-mm.example`](deploy/env.cdn-mm.example) | общий `.env` |
```bash
mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
cp deploy/env.cdn-mm.example /opt/cdn-mm/.env
cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
# заполнить CF_DNS_API_TOKEN, CLOUDFLARE_API_TOKEN, AUTH_JWT_SECRET, CORS_ORIGIN, …
docker login git.shx.one
cd /opt/cdn-mm && docker compose pull && docker compose up -d
curl -fsS https://cdn.shnt.top/health
curl -fsS https://mm.shnt.top/health
```
Данные: `./data/cdn` (CDN), `./data/mm` (MM). Сеть Traefik: `edge`. Не запускайте параллельно standalone `docker-compose.traefik.yml` CDNManager или MM на тех же 80/443.
**Если на сервере уже крутится CDNManager Traefik** (`cdnmanager-traefik`, сеть `cdnmanager`) — **не** поднимайте второй Traefik. Варианты:
| Способ | Файл |
|--------|------|
| Compose без своего Traefik | [`deploy/docker-compose.traefik-cdn.yml`](deploy/docker-compose.traefik-cdn.yml) |
| Plain `docker` CLI (скрипт) | [`deploy/run-beside-cdn-traefik.sh`](deploy/run-beside-cdn-traefik.sh) |
Frontend вешается в сеть `cdnmanager` с Traefik-labels; backend/updater остаются в `mmapp`. Сертификат для `MM_DOMAIN` выпускает уже работающий Traefik CDNManager (тот же `letsencrypt` / Cloudflare DNS-01).
```bash
# Compose (рекомендуется)
mkdir -p /opt/mmapp/{data,state,updater}
cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
cp deploy/env.traefik.example /opt/mmapp/.env # MM_DOMAIN + CORS_ORIGIN
cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
cd /opt/mmapp && docker compose pull && docker compose up -d
# Или одной CLI-командой (скрипт сам сделает network/pull/run/connect):
curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
chmod +x /tmp/run-beside-cdn-traefik.sh
sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-traefik.sh
```
DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`.
SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`).
Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня).
### Прод-контейнеры ### Прод-контейнеры
| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` | | Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
|--------|------------------|----------------|----------------------|------|-----------| |--------|------------------|----------------|----------------------|------|-----------|
| backend | `mmapp-backend` | `git.shts.su/denozord/mikrotikmanager-backend:latest` | `8000:8000` | `backend-data` → `/app/data` | `unless-stopped` | | postgres | `mmapp-postgres` | `postgres:18-alpine` | нет (внутренняя сеть) | `mmapp-pgdata` | `unless-stopped` |
| frontend | `mmapp-frontend` | `git.shts.su/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` | | backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | sqlite ETL → `/app/data` | `unless-stopped` |
| updater | `mmapp-updater` | `git.shts.su/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state` → `/state`, `targets.json` → `/etc/updater/targets.json:ro` | `unless-stopped` | | frontend | `mmapp-frontend` | `git.shx.one/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
| updater | `mmapp-updater` | `git.shx.one/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state` → `/state`, `targets.json` → `/etc/updater/targets.json:ro` | `unless-stopped` |
Метки для updater на backend и frontend: Метки для updater на backend и frontend:
@@ -241,11 +309,14 @@ npm --prefix backend run db:studio
### Переменные окружения (прод) ### Переменные окружения (прод)
**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`, `DATABASE_PATH=/app/data/mikrotik.db`; в compose обычно переопределяют только CORS): **Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`; в compose задают `DATABASE_URL`, `DATABASE_PATH` для ETL и CORS):
| Переменная | Источник в compose | Назначение | | Переменная | Источник в compose | Назначение |
|------------|-------------------|------------| |------------|-------------------|------------|
| `CORS_ORIGIN` | `${CORS_ORIGIN:-http://localhost:3000}` | origin UI, с которого браузер вызывает API | | `CORS_ORIGIN` | `${CORS_ORIGIN:-http://localhost:3000}` | origin UI, с которого браузер вызывает API |
| `DATABASE_URL` | `postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp` | PostgreSQL |
| `DATABASE_PATH` | `/app/data/mikrotik.db` | SQLite на томе для одноразового ETL |
| `POSTGRES_PASSWORD` | обязателен | пароль пользователя `mmapp` |
**Frontend** (в образе: `PORT=3000`, `HOSTNAME=0.0.0.0`). URL API в образ **не** зашит — задаётся в браузере (режим live + URL бэкенда) вместе с `CORS_ORIGIN` на backend. **Frontend** (в образе: `PORT=3000`, `HOSTNAME=0.0.0.0`). URL API в образ **не** зашит — задаётся в браузере (режим live + URL бэкенда) вместе с `CORS_ORIGIN` на backend.
@@ -256,16 +327,16 @@ npm --prefix backend run db:studio
| `TARGETS_FILE` | `/etc/updater/targets.json` | список целей | | `TARGETS_FILE` | `/etc/updater/targets.json` | список целей |
| `STATE_FILE` | `/state/updater-state.json` | digest и ошибки | | `STATE_FILE` | `/state/updater-state.json` | digest и ошибки |
| `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса | | `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса |
| `HEALTH_TIMEOUT_SECONDS` | `120` | ожидание HTTP health | | `HEALTH_TIMEOUT_SECONDS` | `600` | ожидание HTTP health (первый boot с ETL может занять минуты) |
| `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` | | `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` |
| `REGISTRY` | `git.shts.su` | registry для `docker login` | | `REGISTRY` | `git.shx.one` | registry для `docker login` |
| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) | | `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
| `REGISTRY_PASSWORD` | пусто | пароль registry | | `REGISTRY_PASSWORD` | пусто | пароль registry |
### Docker Compose ### Docker Compose
```bash ```bash
docker login git.shts.su docker login git.shx.one
export CORS_ORIGIN=http://<хост>:3000 export CORS_ORIGIN=http://<хост>:3000
export REGISTRY_USERNAME=<user> export REGISTRY_USERNAME=<user>
export REGISTRY_PASSWORD=<token> export REGISTRY_PASSWORD=<token>
@@ -307,8 +378,8 @@ docker run -d \
-v backend-data:/app/data \ -v backend-data:/app/data \
--label mmapp.updater.managed=true \ --label mmapp.updater.managed=true \
--label mmapp.updater.target=backend \ --label mmapp.updater.target=backend \
--label mmapp.updater.image=git.shts.su/denozord/mikrotikmanager-backend:latest \ --label mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-backend:latest \
git.shts.su/denozord/mikrotikmanager-backend:latest git.shx.one/denozord/mikrotikmanager-backend:latest
``` ```
**Frontend:** **Frontend:**
@@ -320,8 +391,8 @@ docker run -d \
-p 3000:3000 \ -p 3000:3000 \
--label mmapp.updater.managed=true \ --label mmapp.updater.managed=true \
--label mmapp.updater.target=frontend \ --label mmapp.updater.target=frontend \
--label mmapp.updater.image=git.shts.su/denozord/mikrotikmanager-frontend:latest \ --label mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:latest \
git.shts.su/denozord/mikrotikmanager-frontend:latest git.shx.one/denozord/mikrotikmanager-frontend:latest
``` ```
**Updater:** **Updater:**
@@ -331,7 +402,7 @@ docker volume create updater-state
docker run -d \ docker run -d \
--name mmapp-updater \ --name mmapp-updater \
--restart unless-stopped \ --restart unless-stopped \
-e REGISTRY=git.shts.su \ -e REGISTRY=git.shx.one \
-e REGISTRY_USERNAME=<user> \ -e REGISTRY_USERNAME=<user> \
-e REGISTRY_PASSWORD=<token> \ -e REGISTRY_PASSWORD=<token> \
-e POLL_INTERVAL_SECONDS=300 \ -e POLL_INTERVAL_SECONDS=300 \
@@ -340,7 +411,7 @@ docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-v updater-state:/state \ -v updater-state:/state \
-v /path/to/targets.json:/etc/updater/targets.json:ro \ -v /path/to/targets.json:/etc/updater/targets.json:ro \
git.shts.su/denozord/mikrotikmanager-updater:latest git.shx.one/denozord/mikrotikmanager-updater:latest
``` ```
### Проверка после деплоя ### Проверка после деплоя
@@ -399,7 +470,7 @@ bash deploy/updater/validate.sh
## Первичная настройка сервера ## Первичная настройка сервера
Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shts.su`. Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shx.one`.
1. Установить Docker Engine и плагин Compose (официальная документация Docker для вашего дистрибутива). 1. Установить Docker Engine и плагин Compose (официальная документация Docker для вашего дистрибутива).
2. Проверить: 2. Проверить:
@@ -413,7 +484,7 @@ docker compose version
4. Войти в registry: 4. Войти в registry:
```bash ```bash
docker login git.shts.su docker login git.shx.one
``` ```
5. Получить файлы деплоя: клонировать репозиторий или скопировать каталог `deploy/` и подготовить `deploy/updater/targets.json`. 5. Получить файлы деплоя: клонировать репозиторий или скопировать каталог `deploy/` и подготовить `deploy/updater/targets.json`.
@@ -443,8 +514,8 @@ Updater опрашивает registry с интервалом `POLL_INTERVAL_SEC
### Ручное обновление ### Ручное обновление
```bash ```bash
docker pull git.shts.su/denozord/mikrotikmanager-backend:latest docker pull git.shx.one/denozord/mikrotikmanager-backend:latest
docker pull git.shts.su/denozord/mikrotikmanager-frontend:latest docker pull git.shx.one/denozord/mikrotikmanager-frontend:latest
``` ```
Через compose (пересоздание при смене образа): Через compose (пересоздание при смене образа):
@@ -460,18 +531,20 @@ docker compose up -d --force-recreate
### Откат ### Откат
- **Автоматически:** updater при failed health после обновления — контейнер на `previous_digest`. - **Автоматически:** updater при failed health после обновления — контейнер на `previous_digest`.
- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы SQLite** updater не выполняет — нужен отдельный backup тома `backend-data` / файла `mikrotik.db`. - **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы PostgreSQL** updater не выполняет — нужен `pg_dump` / restore или том `mmapp-pgdata`.
### Резервное копирование SQLite ### Резервное копирование PostgreSQL
Том `backend-data` (или `mmapp-backend-data` при явном `docker volume create`). Пример остановки backend для консистентной копии: Том `mmapp-pgdata` и (до cleanup) sqlite `mikrotik.db` на `/app/data`. Из UI: Настройки → «Скачать бэкап» (`pg_dump -Fc`). С хоста:
```bash ```bash
docker stop mmapp-backend docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
docker run --rm -v backend-data:/data -v $(pwd):/backup alpine tar czf /backup/mikrotik-db-backup.tar.gz -C /data .
docker start mmapp-backend
``` ```
Восстановление: UI «Восстановить из файла» или `pg_restore --clean --if-exists -d …`.
Том sqlite после успешного ETL и smoke **не удаляйте сразу** — оставьте как fallback, затем уберите `mikrotik.db`, когда маркер `data_migration.sqlite_imported_at` подтверждён.
### Отладка ### Отладка
| Действие | Команда | | Действие | Команда |
@@ -485,7 +558,7 @@ docker start mmapp-backend
1. Установить Docker Engine и Compose plugin; проверить `docker version` и `docker compose version`. 1. Установить Docker Engine и Compose plugin; проверить `docker version` и `docker compose version`.
2. Открыть порты 3000 и 8000 (или обеспечить доступ клиентов к UI и API). 2. Открыть порты 3000 и 8000 (или обеспечить доступ клиентов к UI и API).
3. Выполнить `docker login git.shts.su`. 3. Выполнить `docker login git.shx.one`.
4. Склонировать репозиторий или скопировать `deploy/`. 4. Склонировать репозиторий или скопировать `deploy/`.
5. Задать `CORS_ORIGIN` (origin фронтенда для браузера). 5. Задать `CORS_ORIGIN` (origin фронтенда для браузера).
6. Задать `REGISTRY_USERNAME` и `REGISTRY_PASSWORD` для updater при приватном registry. 6. Задать `REGISTRY_USERNAME` и `REGISTRY_PASSWORD` для updater при приватном registry.
@@ -495,3 +568,7 @@ docker start mmapp-backend
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).
+368 -712
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
+299 -202
View File
@@ -1,34 +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 { Card, CardContent } from "@/components/ui/card" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
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, 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 { 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 ────────────────────────────────────────────────────────────────
@@ -178,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" },
@@ -186,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 {
@@ -241,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,
@@ -265,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
@@ -280,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
@@ -327,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>
) )
@@ -355,17 +349,18 @@ 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 (
<Card key={router.id} className="overflow-hidden gap-0 py-0"> <Frame key={router.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* header */} {/* header */}
<div className="flex items-center gap-3 px-4 py-3 border-b"> <div className="flex items-center gap-3 px-4 py-3 border-b">
<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 && (
@@ -396,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>
) )
@@ -416,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">
@@ -424,7 +419,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
</span> </span>
</div> </div>
)} )}
</Card> </FramePanel>
</Frame>
) )
})} })}
</div> </div>
@@ -436,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]
) )
@@ -452,38 +448,54 @@ 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
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* summary row */} <KpiStatGrid
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> aria-label="Сводка префиксов BGP"
{[ items={[
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" }, {
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" }, id: "rx",
{ label: "eBGP сессий", value: ebgpSessions, color: "" }, label: "Префиксы",
{ label: "iBGP сессий", value: ibgpSessions, color: "" }, value: fmtNum(totalRx),
].map(s => ( icon: <DownloadIcon className="size-4" />,
<Card key={s.label}> iconClassName: "text-success",
<CardContent className="pt-4 pb-3 px-4"> },
<p className="text-xs text-muted-foreground">{s.label}</p> {
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p> id: "active",
</CardContent> label: "Маршруты",
</Card> hint: totalActive === null ? "нет данных API" : undefined,
))} value: fmtNum(totalActive),
</div> icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "ebgp",
label: "eBGP",
value: sessions.some(s => s.type === "Unknown") ? "—" : ebgpSessions,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ibgp",
label: "iBGP",
value: sessions.some(s => s.type === "Unknown") ? "—" : ibgpSessions,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5"> <div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
{/* prefixes by peer — horizontal bar chart */} {/* prefixes by peer — horizontal bar chart */}
<Card> <OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-4">Топ-8 пиров по полученным префиксам</p>
<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">
@@ -494,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")}>
@@ -513,18 +525,15 @@ 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>
</CardContent> </OpsPanel>
</Card>
{/* right column */} {/* right column */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* state distribution */} {/* state distribution */}
<Card> <OpsPanel title="Распределение состояний" contentClassName="pt-4 pb-4 px-5">
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-3">Распределение состояний</p>
{stateCounts.length === 0 ? ( {stateCounts.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p> <p className="text-xs text-muted-foreground">Нет данных</p>
) : ( ) : (
@@ -535,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>
@@ -545,14 +554,11 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
})} })}
</div> </div>
)} )}
</CardContent> </OpsPanel>
</Card>
{/* eBGP vs iBGP */} {/* eBGP vs iBGP */}
<Card> <OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
<CardContent className="pt-4 pb-4 px-5"> {sessions.length === 0 || sessions.some(s => s.type === "Unknown") ? (
<p className="text-sm font-semibold mb-3">eBGP vs iBGP</p>
{sessions.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p> <p className="text-xs text-muted-foreground">Нет данных</p>
) : ( ) : (
<> <>
@@ -579,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]">
@@ -592,8 +598,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
</div> </div>
</> </>
)} )}
</CardContent> </OpsPanel>
</Card>
</div> </div>
</div> </div>
</div> </div>
@@ -609,33 +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([
fetch(`${backendUrl}/api/bgp/sessions`) requestJson<unknown>(backendUrl, "/api/bgp/catalog"),
.then(r => { requestJson<unknown>(backendUrl, "/api/servers"),
if (!r.ok) throw new Error(`HTTP ${r.status}`) ])
return r.json() as Promise<BackendBgpSession[]> .then(([data, servers]) => {
})
.then(data => {
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) => {
@@ -647,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">
@@ -700,74 +787,84 @@ 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">
Моковые данные Моковые данные
</span> </span>
)} )}
{/* KPI strip */} <KpiStatGrid
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> aria-label="Сводка BGP"
{[ items={[
{ label: "Сессий всего", value: sessions.length, color: "" }, {
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" }, id: "sessions",
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" }, label: "Сессий",
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" }, value: incomplete ? "—" : sessions.length,
].map(s => ( hint: incomplete ? "неизвестно" : undefined,
<Card key={s.label}> icon: <GitMergeIcon className="size-4" />,
<CardContent className="pt-4 pb-3 px-4"> iconClassName: "text-muted-foreground",
<p className="text-xs text-muted-foreground">{s.label}</p> },
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p> {
</CardContent> id: "established",
</Card> label: "Established",
))} value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : established,
</div> icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "not-estab",
label: "Не поднято",
value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : notEstab,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
variant: notEstab > 0 ? "warning" : "default",
},
{
id: "prefixes",
label: "Префиксы",
value: incomplete ? "—" : fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />,
iconClassName: "text-info",
},
]}
/>
{/* 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>
) )
} }
+250 -392
View File
@@ -1,16 +1,22 @@
"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 { Card, CardContent } from "@/components/ui/card" 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 { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
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 {
@@ -24,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,
@@ -55,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 {
@@ -75,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">
@@ -91,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">
@@ -111,140 +111,61 @@ 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 (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <KpiStatGrid
{[ aria-label="Сводка сертификатов"
items={[
{ {
id: "all",
label: "Всего", label: "Всего",
value: displayCerts.length, value: totalKnown ? displayCerts.length : "—",
icon: <ShieldCheckIcon className="size-4 text-muted-foreground" />, icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
}, },
{ {
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 text-emerald-500" />, icon: <BadgeCheckIcon className="size-4" />,
iconClassName: "text-success",
}, },
{ {
id: "expiring",
label: "Истекают", label: "Истекают",
value: expiring.length, value: complete ? expiring.length : "—",
icon: <AlertTriangleIcon className="size-4 text-amber-500" />, icon: <AlertTriangleIcon className="size-4" />,
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
variant: expiring.length > 0 ? "warning" : "default",
}, },
{ {
id: "expired",
label: "Истёкших", label: "Истёкших",
value: expired.length, value: complete ? expired.length : "—",
icon: <AlertCircleIcon className="size-4 text-red-500" />, icon: <AlertCircleIcon className="size-4" />,
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
variant: expired.length > 0 ? "destructive" : "default",
}, },
].map((s) => ( ]}
<Card key={s.label}> />
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
)
}
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 (
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-3">
<p className="text-sm font-medium">ACME · Cloudflare DNS-01</p>
<p className="text-xs text-muted-foreground">
Публичные Let&apos;s Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+.
</p>
<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>
</CardContent>
</Card>
) )
} }
function CertPartReference() { function CertPartReference() {
return ( return (
<Card> <OpsPanel
<CardContent className="px-5 py-4"> title="RouterOS 7 · /certificate — справка CLI"
<p className="text-xs font-medium text-muted-foreground mb-1"> description="RouterOS 7 умеет обновлять Let's Encrypt сам. Этот CLI — справка; автообновление MM включается панелью выше."
RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве. contentClassName="px-5 py-4"
</p> >
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7 · /certificate — справка CLI</p>
<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">
{[ {[
{ {
@@ -278,14 +199,13 @@ 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>
))} ))}
</div> </div>
</CardContent> </OpsPanel>
</Card>
) )
} }
@@ -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,19 +385,34 @@ 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 [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 scopedCerts = useMemo(() => {
if (urlError) return []
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
return displayCerts.filter((c) => c.serverId === selectedServerId)
}, [displayCerts, selectedServerId, urlError])
const certRailItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: isLive ? catalog.servers.find(server => String(server.id) === s.id)?.status ?? undefined : s.status,
type: s.type,
enabled: s.enabled,
meta: isLive && !snapshots[s.id]?.confirmed ? "—" : String(displayCerts.filter((c) => c.serverId === s.id).length),
}))
), [displayServers, displayCerts, isLive, catalog.servers, snapshots])
const serverById = useMemo(() => { const serverById = useMemo(() => {
const map = new Map<string, Server>() const map = new Map<string, Server>()
@@ -457,71 +420,23 @@ 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(
() => displayCerts.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),
[displayCerts], [scopedCerts],
) )
const expired = useMemo(() => displayCerts.filter((c) => c.status === "expired"), [displayCerts]) 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 displayCerts.filter((c) => { return scopedCerts.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()
@@ -532,140 +447,96 @@ export default function CertificatesPage() {
c.sans.some((s) => s.includes(q)) c.sans.some((s) => s.includes(q))
) )
}) })
}, [displayCerts, 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 (
<div className="flex flex-col h-full"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]} items={certRailItems}
actions={ selectedId={selectedServerId}
<> onSelect={setSelectedServerId}
<Button showAll
variant="outline" allCount={displayServers.length}
size="sm" preserveSelection
disabled={!liveReady || loadState === "loading"} loading={isLive && loading && displayServers.length === 0}
onClick={() => { header={
void handleRefresh() <PageHeader
}} crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
> actions={
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} /> <>
Обновить <ServerRailMobileButton />
</Button> <Button
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}> variant="outline"
<UploadIcon className="size-4" /> size="sm"
Импорт disabled={!isLive || loading || (!canRead && !canReadServers)}
</Button> onClick={() => {
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}> void handleRefresh()
<PlusIcon className="size-4" /> }}
Выпустить сертификат >
</Button> <RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
</> Обновить
} </Button>
/> <Button variant="outline" size="sm" disabled title="Импорт сертификатов пока недоступен">
<UploadIcon className="size-4" />
<div className="flex-1 overflow-y-auto p-6"> Импорт
<div className="flex flex-col gap-5"> </Button>
<Button
size="sm"
disabled={!issue.hydrated || !liveReady || !canRead || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
onClick={() => {
setIssueStep(issue.checkpoint ? 4 : 1)
if (!issue.checkpoint && selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
setIssueOpen(true)
}}
>
<PlusIcon className="size-4" />
{issue.checkpoint ? "Задача выпуска" : "Выпустить сертификат"}
</Button>
</>
}
/>
}
>
<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} />}
@@ -673,57 +544,46 @@ export default function CertificatesPage() {
</div> </div>
)} )}
<CertPartKpi displayCerts={displayCerts} 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 />
</div> </div>
</div> </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>
@@ -731,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">
{[ {[
@@ -751,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={serverList} 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)}>
@@ -784,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)}
> >
Далее Далее
@@ -792,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} готов к импорту на роутер`)
}}
/>
</div>
) )
} }
+110 -85
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 { import { Frame, FramePanel } from "@/components/reui/frame"
Card, CardContent, CardHeader, CardTitle, CardDescription, import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
} from "@/components/ui/card" 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,38 +166,46 @@ 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>
)}
{/* ── summary ── */} <KpiStatGrid
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> aria-label="Сводка communities"
{[ items={[
{ label: "Всего communities", value: String(listData.length) }, {
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) }, id: "all",
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) }, label: "Всего communities",
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) }, value: known ? String(listData.length) : "—",
].map(({ label, value }) => ( icon: <TagIcon className="size-4" />,
<Card key={label}> iconClassName: "text-muted-foreground",
<CardContent className="pt-4 pb-3 px-4"> },
<p className="text-xs text-muted-foreground">{label}</p> {
<p className="text-2xl font-semibold tabular-nums">{value}</p> id: "enabled",
</CardContent> label: "Активных",
</Card> value: known ? String(listData.filter((c) => c.enabled).length) : "—",
))} icon: <CheckIcon className="size-4" />,
</div> iconClassName: "text-success",
},
{
id: "standard",
label: "Стандартных",
value: known ? String(listData.filter((c) => c.type === "standard").length) : "—",
icon: <TagIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "filters",
label: "Использует фильтры",
value: mode === "mock" ? String(new Set(listData.flatMap((c) => c.filterIds)).size) : "—",
icon: <FilterIcon className="size-4" />,
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 */}
@@ -187,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)}>
@@ -199,49 +226,48 @@ 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>
{/* ── detail panel ── */} {/* ── detail panel ── */}
{selected ? ( {selected ? (
<Card className="h-fit sticky top-0"> <OpsPanel
<CardHeader className="pb-3"> className="h-fit sticky top-0"
<div className="flex items-start justify-between gap-2"> title={<span className="font-mono">{selected.value}</span>}
<div> description={selected.name}
<div className="flex items-center gap-2 mb-1"> headerRight={
<span className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
selected.enabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-muted text-muted-foreground"
)}>
{selected.enabled ? "Активен" : "Выключен"}
</span>
</div>
<CardTitle className="text-sm font-mono">{selected.value}</CardTitle>
<CardDescription className="text-xs mt-0.5">{selected.name}</CardDescription>
</div>
<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"
>
<div className="flex items-center gap-2">
<span className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
selected.enabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-muted text-muted-foreground"
)}>
{selected.enabled ? "Активен" : "Выключен"}
</span>
</div> </div>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-xs text-muted-foreground">{selected.description}</p> <p className="text-xs text-muted-foreground">{selected.description}</p>
<div className="space-y-2"> <div className="space-y-2">
{[ {[
["Тип", 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">
@@ -267,20 +293,19 @@ 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>
</div> </div>
</CardContent> </OpsPanel>
</Card>
) : ( ) : (
<Card className="h-fit"> <Frame dense className="w-full h-fit">
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center"> <FramePanel className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<TagIcon className="size-8 text-muted-foreground/30" /> <TagIcon className="size-8 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p> <p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
</CardContent> </FramePanel>
</Card> </Frame>
)} )}
</div> </div>
</div> </div>
+406 -238
View File
@@ -1,13 +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 { Card, CardContent } from "@/components/ui/card" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
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,
@@ -15,161 +22,115 @@ import {
import { import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon, BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon, MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon, CodeXmlIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon, TerminalIcon, AlertCircleIcon, RefreshCwIcon,
} from "lucide-react" } from "lucide-react"
import { import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
Sheet, SheetContent, SheetHeader, SheetTitle, import { useDataSource } from "@/lib/data-source"
SheetDescription, SheetFooter, SheetClose, import { ApiClientError, requestJson } from "@/shared/api/http-client"
} from "@/components/ui/sheet" 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 [copied, setCopied] = useState(false)
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт Container</SheetTitle>
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── 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 (
<Card className="overflow-hidden"> <Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10"> <div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} /> <span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
@@ -180,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>
<CardContent 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>
)} )}
@@ -232,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>
@@ -240,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) => (
@@ -257,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) => (
@@ -271,137 +235,335 @@ 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>
)} )}
</CardContent> </div>
</Card> </FramePanel>
</Frame>
) )
} }
// ════════════════════════════════════════════════════════════════════════════ 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>
)}
{/* KPI */} <KpiStatGrid
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> aria-label="Сводка контейнеров"
{[ items={[
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> }, {
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> }, id: "all",
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> }, label: "Всего",
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> }, value: known ? scoped.length : "—",
].map((s) => ( icon: <BoxIcon aria-hidden="true" className="size-4" />,
<Card key={s.label}> iconClassName: "text-muted-foreground",
<CardContent className="px-5 py-4 flex items-start justify-between"> },
<div> {
<p className="text-sm text-muted-foreground">{s.label}</p> id: "running",
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p> label: "Работает",
</div> value: statusKnown ? running : "—",
<div className="mt-0.5">{s.icon}</div> icon: <PlayIcon aria-hidden="true" className="size-4" />,
</CardContent> iconClassName: "text-success",
</Card> },
))} {
</div> id: "stopped",
label: "Остановлен",
value: statusKnown ? stopped : "—",
icon: <StopCircleIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "errors",
label: "Ошибок",
value: statusKnown ? errors : "—",
icon: <AlertCircleIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-destructive",
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">
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7.4+ · /container — быстрые команды
</p>
<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">
{[ {[
{ {
@@ -451,23 +613,29 @@ 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>
))} ))}
</div> </div>
</CardContent> </OpsPanel>
</Card>
</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
+1261 -298
View File
File diff suppressed because it is too large. Load diff
+668 -517
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
+25 -11
View File
@@ -1,21 +1,35 @@
import type { CSSProperties, ReactNode } from "react"
import { AppSidebar } from "@/components/app-sidebar" import { AppSidebar } from "@/components/app-sidebar"
import { AuthGuard } from "@/components/auth-guard"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { CommandPalette } from "@/components/command-palette" import { CommandPalette } from "@/components/command-palette"
import { ReleaseNotesModal } from "@/components/release-notes-modal" import { ReleaseNotesModal } from "@/components/release-notes-modal"
import { DataSourceProvider } from "@/lib/data-source" import { DataSourceProvider } from "@/lib/data-source"
import { EvoBGPProvider } from "@/lib/evobgp-context" import { EvoBGPProvider } from "@/lib/evobgp-context"
export default function MainLayout({ children }: { children: React.ReactNode }) { const SKIP_TO_CONTENT_CLASS =
"bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2"
export default function MainLayout({ children }: { children: ReactNode }) {
return ( return (
<DataSourceProvider> <AuthGuard>
<EvoBGPProvider> <DataSourceProvider>
<SidebarProvider> <EvoBGPProvider>
<AppSidebar /> <SidebarProvider
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset> style={{ "--sidebar-width": "240px" } as CSSProperties}
<CommandPalette /> >
<ReleaseNotesModal /> <a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
</SidebarProvider> К содержимому
</EvoBGPProvider> </a>
</DataSourceProvider> <AppSidebar />
<SidebarInset tabIndex={-1} id="main-content" className="h-svh overflow-hidden">
{children}
</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
</EvoBGPProvider>
</DataSourceProvider>
</AuthGuard>
) )
} }
+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
+546 -494
View File
File diff suppressed because it is too large. Load diff
+320 -354
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+34 -37
View File
@@ -1,12 +1,11 @@
import Link from "next/link" import Link from "next/link"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { OpsPanel } from "@/components/ops-panel"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version" import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
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,62 +39,60 @@ 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">
<Card> <OpsPanel
<CardHeader> title="Текущая версия"
<CardTitle>Текущая версия</CardTitle> description={isProdBuild ? "Версия запущенной сборки MikrotikManager." : "Локальная разработка. Публикация релиза не подтверждена."}
<CardDescription>Сборка MikrotikManager, опубликованная через CI/CD.</CardDescription> contentClassName="flex flex-col gap-3 px-5 pb-5"
</CardHeader> >
<CardContent className="space-y-3">
<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>
</CardContent> </OpsPanel>
</Card>
<Card> {!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}
<CardHeader>
<CardTitle>Сводка коммитов</CardTitle> <OpsPanel
<CardDescription> title="Сводка коммитов"
{commits.length > 0 description={
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.` commits.length > 0
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."} ? `Коммиты манифеста ${formatAppVersionLabel(manifest!.version)}: ${commits.length}.`
</CardDescription> : manifest ? "В манифесте нет коммитов. При публикации список формируется CI." : "Список коммитов не подтверждён."
</CardHeader> }
<CardContent> 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="space-y-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>
)} )}
</CardContent> </OpsPanel>
</Card>
</div> </div>
</div> </div>
</div> </div>
File diff suppressed because it is too large. Load diff
+296 -96
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"
@@ -27,7 +35,7 @@ import {
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
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 { Input } from "@/components/ui/input"
@@ -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,27 +622,69 @@ 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">
{/* Stats */} {!canWrite && <Alert variant="info" role="status"><AlertTitle>Только просмотр</AlertTitle><AlertDescription>Изменение, удаление, опрос серверов и тест соединения требуют разрешения на запись. Просмотр деталей и фильтры доступны.</AlertDescription></Alert>}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> {operationError && <Alert variant="destructive" role="alert"><AlertTitle>Действие не завершено</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
{[ {operationMessage && <Alert variant="success" role="status"><AlertDescription>{operationMessage}</AlertDescription></Alert>}
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4 text-muted-foreground" /> }, {pollResults.length > 0 && (
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4 text-emerald-500" /> }, <Alert variant={pollResults.some(r => r.error) ? "warning" : "success"} role="status">
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4 text-violet-400" /> }, <AlertTitle>Опрос: успешно {pollResults.filter(r => !r.error).length} из {pollResults.length}</AlertTitle>
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4 text-emerald-400" /> }, <AlertDescription>
].map(s => ( <ul>{pollResults.map(r => <li key={r.id}>{serverList.find(s => s.id === r.id)?.name ?? r.id}: {r.error ?? "опрос выполнен"}</li>)}</ul>
<Card key={s.label}> {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>}
<CardContent className="px-5 py-4 flex items-start justify-between"> </AlertDescription>
<div> </Alert>
<p className="text-sm text-muted-foreground">{s.label}</p> )}
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div> {selectedServerId !== null && (
<div className="mt-0.5">{s.icon}</div> <Alert variant={listState === "error" || (listState === "ready" && !selectedServer) ? "warning" : "info"} role="status" aria-label="Сервер по ссылке">
</CardContent> <AlertTitle className="line-clamp-none wrap-anywhere">{selectedServer ? "Выбран сервер: " + selectedServer.name : "Сервер по ссылке"}</AlertTitle>
</Card> <AlertDescription>
))} <p>{listState === "loading" ? "Загрузка сервера…" : listState === "error" ? "Не удалось загрузить сервер. Проверьте сообщение об ошибке выше." : selectedServer ? selectedServer.host + " · Список ограничен выбранным сервером. Сводка относится ко всем серверам." : "Сервер не найден или недоступен. Возможно, он удалён или у вас нет доступа."}</p>
</div> <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
aria-label="Сводка серверов"
items={[
{
id: "all",
label: "Всего серверов",
value: counts.all,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "online",
label: "Онлайн",
value: counts.online,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "jh-en",
label: "JH + Exit Node",
value: counts["jump-host"] + counts["exit-node"],
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "home",
label: "Home Router",
value: counts["home-router"],
icon: <HomeIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
{/* 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={{
@@ -522,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="Поиск по имени, хосту…"
@@ -532,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>
@@ -544,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}>
@@ -591,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={[
@@ -711,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" />Проверить подключение</>}
@@ -762,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>
}
File diff suppressed because it is too large. Load diff
+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>
)
}
+120 -428
View File
@@ -1,13 +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 { Flag } from "@/components/flag" 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 { import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import {
TrashIcon, RefreshCwIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -157,229 +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 res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: cmd }),
})
const data = await res.json() as { output?: string; error?: string }
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 ───────────────────────────────────────────────────────────
@@ -407,215 +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 } = 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) 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)
fetch(`${backendUrl}/api/servers`) }).catch(err=>{if(!cancelled)setError(err instanceof Error?(err.name==="ZodError"?"Некорректный каталог устройств.":err.message):"Не удалось загрузить устройства.")}).finally(()=>{if(!cancelled)setLoading(false)})})
.then(r => r.json() as Promise<BackendServer[]>) return()=>{cancelled=true;controller.abort()}
.then(data => { },[isLive,backendUrl,canRead,refresh])
if (cancelled) return const termServers=isLive?liveServers:mockServersToTermServers()
setLiveServers(data.map(s => ({ const matches=url.server?termServers.filter(server=>server.uid===url.server):url.host?termServers.filter(server=>server.host===url.host):[]
uid: String(s.id), const selected=matches.length===1?matches[0]:null
backendId: s.id, const unavailable=(loaded||!isLive)&&(url.server||url.host)&&!selected
name: s.name || 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(" · ")}))
host: s.host, function select(id:string){const href=patchTerminalUrl(new URLSearchParams(params.toString()),id||null)+window.location.hash;window.history.pushState(null,"",href)}
country: s.country || "", const blocked=!!error||loading||isLive&&!loaded
status: s.status, 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"
enabled: s.enabled, 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>}</>}/>}
rosVersion: s.os, 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>}>
identityName: s.identityName, {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>}
}))) </ServerRailLayout>
setServersLoading(false)
})
.catch(() => { if (!cancelled) setServersLoading(false) })
})
return () => { cancelled = true }
}, [isLive, backendUrl, refreshKey])
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"}`
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 (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={
<Button variant="outline" size="sm" onClick={() => {
setRefreshKey(k => k + 1)
if (isLive) setSelectedUid("")
}}>
<RefreshCwIcon className="size-4" />Переподключить
</Button>
}
/>
<div className="flex-1 overflow-hidden p-6">
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
{/* ── sidebar ── */}
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
{/* server picker */}
<div>
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
{isLive && serversLoading && (
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
)}
{isLive && !serversLoading && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
<span className="size-1 rounded-full bg-emerald-400" />LIVE
</span>
)}
</div>
{isLive && !serversLoading && liveServers.length === 0 && (
<p className="text-xs text-muted-foreground px-2.5">
Нет доступных серверов
</p>
)}
<div className="space-y-1">
{termServers.map(s => {
const isOffline = s.status === "offline"
const isSelected = s.uid === selectedUid
return (
<button
key={s.uid}
disabled={isOffline || !s.enabled}
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
className={cn(
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
"flex items-center gap-2",
isSelected
? "bg-primary text-primary-foreground"
: "hover:bg-muted",
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
)}
>
<span className={cn(
"inline-block size-1.5 rounded-full shrink-0",
s.status === "online" ? "bg-emerald-500" :
s.status === "degraded" ? "bg-amber-400" :
s.status === null ? "bg-sky-400" : "bg-red-500",
)} />
{s.country && <Flag code={s.country} />}
<span className="truncate font-mono">{s.name}</span>
</button>
)
})}
</div>
</div>
{/* quick commands */}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Быстрые команды
</p>
<div className="space-y-1">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</div>
{/* hints */}
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
<p>↑↓ — история команд</p>
<p>Ctrl+L — очистить экран</p>
{isLive
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>
}
</div>
</div>
{/* ── terminal ── */}
{selected ? (
<Terminal
key={termKey}
server={selected}
isLive={isLive}
backendUrl={backendUrl}
/>
) : (
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
</div>
)}
</div>
</div>
</div>
)
} }
File diff suppressed because it is too large. Load diff
+691 -547
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; }
}
+365
View File
@@ -0,0 +1,365 @@
"use client"
import { useCallback, useEffect, useRef, useState, useMemo } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { DataPageCard } from "@/components/data-page-card"
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { UserSheet } from "@/components/users/user-sheet"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { servers as mockServers } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import {
ALL_SECTIONS,
INIT_USERS,
userInitials,
type AppUser,
type AppUserForm,
type UserServerOption,
} from "@/lib/users"
import { serverListSchema } from "@mmapp/contracts/servers"
import { requestJson } from "@/shared/api/http-client"
import { can } from "@/lib/auth"
import {
userApiError,
createAppUser,
getAppUser,
createUserBinding,
deleteAppUser,
deleteUserBinding,
listAppUsers,
updateAppUser,
} from "@/shared/api/users"
import { saveUserSteps, userProfileRequest, type UserSaveCheckpoint } from "@/lib/user-save"
import { toast } from "sonner"
import {
CableIcon,
PlusIcon,
TrashIcon,
UserCheckIcon,
UsersIcon,
} from "lucide-react"
function toServerOptionsFromMock(): UserServerOption[] {
return mockServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status === "unknown" ? null : s.status,
}))
}
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 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[]>(() => isLive ? [] : INIT_USERS)
const [userSearch, setUserSearch] = useState("")
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 [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 [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 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)
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
const [list, srvs] = await Promise.allSettled([
listAppUsers(backendUrl, { signal }).then(rows => {
if (current()) { setUsers(rows); setUsersReadAt(Date.now()); setUsersError(null) }
return rows
}).catch(error => {
if (current()) setUsersError(userApiError(error))
throw error
}),
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(raw => {
const rows = serverListSchema.parse(raw)
if (new Set(rows.map(row => row.id)).size !== rows.length || rows.some(row => row.id <= 0)) throw new Error("Некорректный каталог серверов.")
if (current()) {
setServerOptions(rows.map(s => ({
id: String(s.id), name: s.name || s.host, host: s.host, site: s.site, country: s.country, status: s.status,
})))
setServersReadAt(Date.now()); setServersError(null)
}
return rows
}).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(() => {
let cancelled = false
if (isLive && canRead) queueMicrotask(() => { if (!cancelled) void loadLive() })
return () => { cancelled = true; loadController.current?.abort() }
}, [isLive, canRead, 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 activeCount = users.filter((u) => u.active).length
const handleSave = async (form: AppUserForm) => {
if (!writeReady || mutationLock.current || saveBlocked) return
if (!isLive) {
if (!editUser) {
const id = "u" + Date.now()
setUsers(prev => [...prev, { id, ...form, last: "—", avatar: userInitials(form.name), bindings: form.bindings.map((b, i) => ({ ...b, id: "b" + id + "-" + i, userId: id })) }])
} else setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
setSheetOpen(false); toast.success("Профиль сохранён в демо."); return
}
mutationLock.current = 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 {
const saved = await saveUserSteps(form, checkpoint, {
create: f => createAppUser(backendUrl, userProfileRequest(f), init()),
update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f), init()),
read: id => getAppUser(backendUrl, id, init()),
removeBinding: (userId, id) => deleteUserBinding(backendUrl, userId, id, init()),
addBinding: (userId, b) => createUserBinding(backendUrl, userId, {
serverId: Number(b.serverId), interfaceName: b.interfaceName, interfaceType: b.interfaceType,
peerPublicKey: b.peerPublicKey, peerName: b.peerName, comment: b.comment,
}, init()),
}, current, state => {
if (!current()) return
setSaveBlocked(!!state.createUncertain)
if (state.user) {
if (!editUser) setSaveResume(true)
const confirmed = state.user
setUsers(prev => prev.some(u => u.id === confirmed.id) ? prev.map(u => u.id === confirmed.id ? confirmed : u) : [...prev, confirmed])
}
})
if (!current() || !saved) return
setSheetOpen(false); saveCheckpoint.current = null
setOperationNotice("Профиль и привязки подтверждены API.")
toast.success("Пользователь сохранён.")
const refreshed = await loadLive()
if (current() && !refreshed) setOperationNotice("Профиль и привязки подтверждены API. Обновление каталогов не удалось; повторите чтение.")
} catch (error) {
if (!current()) return
const detail = userApiError(error)
setSaveError(checkpoint.createUncertain
? "Результат создания неизвестен. Повтор заблокирован: проверьте список пользователей. " + detail
: checkpoint.bindingsNeedRead
? "Профиль подтверждён API; привязки сохранены частично. При повторе сначала прочитаем их текущее состояние. " + detail
: detail)
} finally {
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
if (alive.current) setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteTarget || !writeReady || mutationLock.current) return
const target = deleteTarget
if (!isLive) {
setUsers(prev => prev.filter(u => u.id !== target.id)); setDeleteTarget(null); setDeleteError(null)
toast.success("Пользователь удалён в демо.")
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 {
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)
setOperationNotice("Удаление пользователя подтверждено API.")
toast.success("Пользователь удалён.")
const refreshed = await loadLive()
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 (
<div className="flex flex-col h-full min-w-0">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
actions={
<>
{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>
</>
}
/>
<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 */}
<KpiStatGrid
aria-label="Сводка пользователей"
items={[
{
id: "all",
label: "Пользователи",
value: usersKnown ? users.length : "—",
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "active",
label: "Активные",
value: usersKnown ? activeCount : "—",
icon: <UserCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ifaces",
label: "Привязанные интерфейсы",
value: usersKnown ? boundCount : "—",
icon: <CableIcon className="size-4" />,
iconClassName: "text-info",
},
]}
/>
{/* 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 */}
{canRead && (usersKnown || loading) && <DataPageCard>
<DataPageToolbar search={userSearch} onSearchChange={setUserSearch} searchLabel="Поиск по имени, логину или email" searchPlaceholder="Поиск по имени, логину или email…" countLabel={usersKnown ? `${visibleUsers.length} из ${users.length}` : "Данные пока неизвестны"} />
<UsersDataGrid
users={visibleUsers}
emptyTitle={userSearch.trim() ? "Нет совпадений" : "Нет пользователей"}
emptyDescription={userSearch.trim() ? "Измените поиск" : "Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"}
serversCount={serversKnown ? serverOptions.length : null}
allSectionsCount={ALL_SECTIONS.length}
isLoading={loading && !usersKnown}
actionsDisabled={!writeReady || saving || deleting}
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>}
</div>
<UserSheet
returnFocus={formReturnFocus}
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
open={sheetOpen}
user={editUser}
users={users}
servers={serverOptions}
isLive={isLive}
backendUrl={backendUrl}
saving={saving}
saveError={saveError}
saveBlocked={saveBlocked}
saveResume={saveResume}
onCheckCatalogs={() => void loadLive()}
onSave={(f) => { void handleSave(f) }}
onClose={() => { if (!saving) { setSheetOpen(false); saveCheckpoint.current = null } }}
/>
{deleteTarget && (
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v && !deleting) { setDeleteTarget(null); setDeleteError(null) } }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<TrashIcon />
</AlertDialogMedia>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
<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>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
}
+337 -213
View File
@@ -1,273 +1,397 @@
"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 { Card, CardContent } from "@/components/ui/card" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
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 { cn } from "@/lib/utils" import { Alert, AlertDescription } from "@/components/ui/alert"
import { import {
NetworkIcon, PlusIcon, CopyIcon, CheckIcon, NetworkIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
CodeXmlIcon, LayersIcon,
} from "lucide-react" } from "lucide-react"
import { import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
Sheet, SheetContent, SheetHeader, SheetTitle, import { useDataSource } from "@/lib/data-source"
SheetDescription, SheetFooter, SheetClose, import { generateVxlanRsc } from "@/lib/vxlan-export"
} from "@/components/ui/sheet" 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 [copied, setCopied] = useState(false) const code = useMemo(
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel]) () => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
[tunnel, serverById],
function handleCopy() { )
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <CodeExportSheet
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl"> open={open}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b"> onClose={onClose}
<div className="flex items-start justify-between gap-4"> className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
<div> title="Экспорт VXLAN"
<SheetTitle>Экспорт VXLAN</SheetTitle> description={mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "время получения неизвестно"} · ${stale ? "актуальность не подтверждена" : "последний успешный опрос"}`}
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription> formats={[
</div> {
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}> id: "rsc",
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>} label: "MikroTik .rsc",
</Button> filename: `${tunnel?.name ?? "vxlan"}.rsc`,
</div> code,
</SheetHeader> },
<div className="flex-1 overflow-y-auto"> ]}
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all"> />
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
) )
} }
// ─── 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>
)}
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
{/* Info banner */} {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
aria-label="Сводка VXLAN"
items={[
{
id: "tunnels",
label: "Туннелей",
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : scopedTunnels.length,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных",
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : upCount,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "vni",
label: "VNI",
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "servers",
label: "Серверов",
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
<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">
<Card> <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>
<CardContent className="px-5 py-4"> </OpsPanel>
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7 · /interface/vxlan — быстрые команды
</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>
</CardContent>
</Card>
</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>
}
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
"use client"
export default function AccessDeniedPage() {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
<h1 className="text-lg font-semibold">Нет доступа</h1>
<p className="text-muted-foreground max-w-md text-sm">
У вашей учётной записи нет приложения MikrotikManager (`mm`) или
необходимых прав. Обратитесь к администратору auth-portal.
</p>
<a
href="/auth/callback"
className="text-primary text-sm underline-offset-4 hover:underline"
>
Войти снова
</a>
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import {
clearPortalHandoffFlag,
clearToken,
can,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getToken,
parseHashToken,
permissionForPath,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
setToken,
} from "@/lib/auth"
import { permittedAuthReturnPath } from "@/lib/auth-return-path"
export default function AuthCallbackPage() {
const router = useRouter()
const [message, setMessage] = useState("Перенаправление на Auth Portal…")
useEffect(() => {
let cancelled = false
void (async () => {
await ensureAuthConfig()
if (cancelled) return
const params = new URLSearchParams(window.location.search)
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") {
redirectToPortalLoginInteractive()
return
}
const { accessToken } = parseHashToken(window.location.hash)
if (accessToken) {
setToken(accessToken)
clearPortalHandoffFlag()
const claims = getClaims()
if (!claims) {
clearToken()
redirectToPortalLoginInteractive()
return
}
if (!claims.apps.includes("mm")) {
setMessage("Нет доступа к приложению")
router.replace("/access-denied")
return
}
try {
const res = await fetch("/api/auth/config", {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (res.status === 401) {
clearToken()
redirectToPortalLoginInteractive()
return
}
} catch {
/* ignore network — proceed */
}
const next = returnPath()
if (next === "/access-denied") {
router.replace("/access-denied")
return
}
router.replace(next)
return
}
if (getToken() && getClaims()) {
clearPortalHandoffFlag()
if (!getClaims()!.apps.includes("mm")) {
router.replace("/access-denied")
return
}
router.replace(returnPath())
return
}
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()
})()
return () => {
cancelled = true
}
}, [router])
return (
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
{message}
</div>
)
}
+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 {
+14 -1
View File
@@ -1,8 +1,21 @@
# Path to SQLite database file # PostgreSQL connection string
DATABASE_URL=postgres://mmapp:[email protected]:5432/mmapp
# SQLite file used only for one-shot boot ETL (empty PG + existing mikrotik.db)
DATABASE_PATH=./mikrotik.db DATABASE_PATH=./mikrotik.db
# SQLITE_IMPORT_FULL_HISTORY=true
# Port for the Fastify server # Port for the Fastify server
PORT=8000 PORT=8000
# Allowed CORS origin (Next.js frontend) # Allowed CORS origin (Next.js frontend)
CORS_ORIGIN=http://localhost:3000 CORS_ORIGIN=http://localhost:3000
# Portal SSO (false = open API for local/dev)
AUTH_REQUIRED=false
# Same HS256 secret as auth-portal JWT_SECRET when AUTH_REQUIRED=true
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
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/
+30 -3
View File
@@ -5,10 +5,23 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \ && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Do not set NODE_ENV=production here — npm would omit typescript needed for the build stage.
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
COPY packages/contracts/package.json packages/contracts/ COPY packages/contracts/package.json packages/contracts/
COPY backend/package.json backend/ COPY backend/package.json backend/
RUN npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --include-workspace-root --ignore-scripts \ # Drop root frontend deps (Next/React/UI) so backend image stays lean.
RUN node -e "\
const fs=require('fs');\
const p=JSON.parse(fs.readFileSync('package.json','utf8'));\
p.dependencies={};\
p.devDependencies={};\
delete p.scripts;\
p.workspaces=['packages/*','backend'];\
fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');\
"
# Prefer npm ci; if lockfile rejects stripped root package.json, fall back to install.
RUN (npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts \
|| npm install --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts) \
&& npm rebuild better-sqlite3 && npm rebuild better-sqlite3
FROM deps AS build FROM deps AS build
@@ -18,10 +31,21 @@ COPY packages/contracts packages/contracts
COPY backend backend COPY backend backend
RUN npm run build -w @mmapp/contracts \ RUN npm run build -w @mmapp/contracts \
&& npm run build -w mikrotik-manager-backend \ && npm run build -w mikrotik-manager-backend \
&& npm prune --omit=dev && npm prune --omit=dev \
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
&& mkdir -p backend/node_modules
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
@@ -32,6 +56,9 @@ COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/contracts ./packages/contracts 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
RUN mkdir -p /app/data 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
COPY --from=build /app/backend/node_modules ./backend/node_modules
EXPOSE 8000 EXPOSE 8000
CMD ["node", "backend/dist/index.js"] CMD ["node", "backend/dist/index.js"]
Loaded 100 of 2558 files, more files were not shown because too many files have changed in this diff. Show more