Compare commits

...
74 Commits
Author SHA1 Message Date
DenozordecandCursor f5d97e463a fix(services): показывать живой health и историю выхода из пула
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 2m3s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m14s
Таблица IP брала hysteresis unknown вместо последней пробы. Failover теперь показывает выход и возврат в пул, а не только DNS add.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:43:22 +07:00
DenozordecandCursor be8f94143f fix(health): не затирать график uptime пробой down в том же бакете
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m38s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m7s
Если в минутном бакете есть живой IP, линия строится по его пингу, а не по null от Down.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:07:24 +07:00
DenozordecandCursor fa7d2b6df3 feat(services): считать сервис живым при одном OK и писать историю failover
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
CD / publish (push) Successful in 2m4s
quality / changes (push) Successful in 11s
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 1m1s
CD / quality (push) Successful in 2m30s
Сервис up, если жив хотя бы один IP. Failover показывает Down по FQDN и журнал add/remove A-записей.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:49:17 +07:00
DenozordecandCursor 7938d2f707 fix(services): считать failover по IP Health а не по строке ноды
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 4s
CD / publish (push) Successful in 1m43s
quality / changes (push) Successful in 9s
quality / web (push) Successful in 52s
quality / api (push) Successful in 42s
CD / quality (push) Successful in 1m50s
Панель брала service_nodes, которую затирал group apply.
Теперь тот же binding ip_health, что таблица активов; group не пишет в ноду.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:08:10 +07:00
DenozordecandCursor 994e79e118 fix(services): считать failover по DNS-пулу а не по статусу ноды
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / docker-check (push) Skipped
CD / publish (push) Canceled after 0s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
CD / quality (push) Canceled after 34s
quality / web (push) Canceled after 22s
Красный инцидент только если адреса нет в active_addresses, даже при unhealthy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:53:44 +07:00
DenozordecandCursor 87b0f1a894 fix(services): оформить failover timeline в стиле страницы сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m39s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m6s
Frame stacked как у мониторинга, Alert и semantic timeline вместо плоского степпера.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:31:30 +07:00
DenozordecandCursor ba4e04a224 feat(services): заменить один общий FQDN списком и убрать Другие FQDN
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 2m1s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / web (push) Successful in 1m6s
CD / quality (push) Successful in 1m18s
Каждый общий домен смотрит на весь пул IP; CNAME без UI сохраняются.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:21:15 +07:00
DenozordecandCursor 4c59780ff0 fix(health): импортировать isHealthy при нескольких FQDN на одни IP
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m36s
quality / changes (push) Successful in 8s
quality / web (push) Skipped
quality / api (push) Successful in 42s
CD / quality (push) Successful in 54s
Схлопывание дубликатов в группе вызывало ReferenceError при сохранении сервиса.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:01:43 +07:00
DenozordecandCursor 1457389ae7 feat(services): объединить адреса сервиса в один блок формы
CD / update-wiki (push) Successful in 7s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m36s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m6s
Общий FQDN, пул IP и доп. домен на строке IP — в одном Frame, без смены API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:33:02 +07:00
DenozordecandCursor 153be28799 fix(health): показывать пинг при наведении на график uptime
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m57s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / web (push) Successful in 1m11s
CD / quality (push) Successful in 1m23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:44:17 +07:00
DenozordecandCursor 39fac7834f fix(ui): не дублировать крошку Настройки при смене раздела
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m58s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m15s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:28:06 +07:00
DenozordecandCursor d267e40157 fix(health): выровнять график источников и KPI на карточке сервиса
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:27:21 +07:00
DenozordecandCursor 634a9dc362 fix(health): заменить плитки источников на компактный мультивыбор
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m39s
quality / changes (push) Successful in 9s
quality / web (push) Successful in 54s
quality / api (push) Successful in 43s
CD / quality (push) Successful in 1m48s
Крупные KPI-кнопки и вкладка «Все» заменены на ToggleGroup: фильтр занимает меньше места и не сбрасывает последний источник.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 02:13:57 +07:00
DenozordecandCursor 1bf6cfa0d0 fix(health): разворачивать CNAME до IP в статусе таблицы
Пробы CNAME больше не ключуются hostname: цель разворачивается до origin/пула, а уже сохранённый статус по CNAME копируется на IP сервиса.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 02:13:09 +07:00
DenozordecandCursor 3da6de9311 fix(health): сузить тип переключателя источников для tsc -b
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m53s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 1m3s
ALL_TAB как литерал 'all', чтобы SourceTab не попадал в HealthCheckProvider[].

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 01:49:42 +07:00
DenozordecandCursor 7a8bacade9 feat(health): объединить источники, график и таймлайн в один блок мониторинга
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / docker-check (push) Skipped
CD / publish (push) Skipped
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / web (push) Failing after 49s
CD / quality (push) Failing after 1m1s
Переключатель серий в шапке Frame меняет график uptime и смены статуса без отдельного списка плиток.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 01:44:16 +07:00
DenozordecandCursor 5d84c7bf6c fix(services): сохранять источники health-check и агрегацию в форме сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 9s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 2m5s
quality / changes (push) Successful in 12s
quality / web (push) Successful in 1m14s
quality / api (push) Successful in 53s
CD / quality (push) Successful in 2m23s
GET отдавал sqlite-типы (0/1 и JSON-строка), Zod отклонял PATCH; форма сбрасывалась на refetch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 01:03:00 +07:00
Denozordec 78811bc9b1 feat(uptime-chart): enhance tooltip functionality and refactor delta display
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m53s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / web (push) Successful in 54s
CD / quality (push) Successful in 1m6s
- Added useEffect to set tooltip portal for improved rendering.
- Introduced UptimeDelta component to streamline delta display logic.
- Updated ChartTooltip to utilize ChartTooltipContent for better formatting of tooltip data.
- Refactored chart margin and overflow properties for improved layout consistency.
2026-08-20 00:49:27 +07:00
Denozordec a458465153 feat(certificates): enhance service certificate management and monitoring
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m35s
quality / changes (push) Successful in 10s
quality / web (push) Successful in 1m8s
quality / api (push) Successful in 1m9s
CD / quality (push) Successful in 2m31s
- Added new endpoints for listing and checking service certificates, improving visibility into SSL status.
- Integrated certificate monitoring options into service binding updates, allowing for flexible SSL management.
- Updated the service detail grid to include SSL monitoring controls, enhancing user interaction with certificate settings.
- Refactored related components and schemas to support the new certificate features, ensuring consistency across the application.
- Improved test coverage for certificate functionalities, validating the new features and ensuring reliability.
2026-08-20 00:32:43 +07:00
Denozordec 69119a08a4 feat(health): enhance HealthTimeline and HealthSourceTiles components
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 2m11s
quality / changes (push) Successful in 10s
quality / api (push) Skipped
quality / web (push) Successful in 1m1s
CD / quality (push) Successful in 1m15s
- Added optional props `emptyTitle` and `emptyDescription` to HealthTimeline for customizable empty state messages.
- Refactored HealthSourceTiles to export `HEALTH_PROVIDER_ITEMS` and introduced a new `HealthProviderStatusTiles` component for improved health monitoring.
- Updated UptimeChart to support period selection and filtering, enhancing data visualization capabilities.
- Improved ServiceDetailPage by integrating HealthProviderStatusTiles and ServiceHealthMonitor for better service health insights.
2026-08-20 00:09:55 +07:00
Denozordec ba03d2be9d feat(services): enhance service health logging and component structure
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m37s
quality / changes (push) Successful in 9s
quality / web (push) Successful in 59s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 2m0s
- Updated the service health log retrieval to include a limit parameter, improving performance and control over log data.
- Refactored the ServiceUnitCard component to export the LbModeTile function, enhancing reusability across the application.
- Simplified routing for service health and nodes pages by redirecting to the service overview, improving user navigation.
- Enhanced the service detail page with additional state management and mutation hooks for better service configuration handling.
- Removed unused components and streamlined the service routing and subdomains pages for improved clarity and maintainability.
2026-08-19 23:45:53 +07:00
Denozordec d8fc4ac949 feat(services): update ServiceUnitCard with background color for FramePanel
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m36s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 1m3s
- Added `bg-muted` class to FramePanel in ServiceUnitCard for improved visual consistency.
- Updated documentation to reflect the new background color setting for FramePanel, enhancing clarity on component usage.
2026-08-19 23:13:58 +07:00
Denozordec d063323402 feat(services): add load balancing mode and active IPs to service view
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m41s
quality / changes (push) Successful in 9s
quality / web (push) Successful in 1m15s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 2m17s
- Introduced `lb_mode` to specify the load balancing strategy for services, supporting options like round robin, failover, and weighted.
- Added `active_ips` to track currently active IPs for each service, enhancing visibility into service health and configuration.
- Updated relevant components to display load balancing mode and active IPs, improving user experience and service management capabilities.
2026-08-19 23:00:21 +07:00
Denozordec 6bced71037 feat(ui): refactor ServiceIpList and ServiceUnitCard components for improved layout and functionality
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 4s
CD / publish (push) Successful in 1m37s
quality / changes (push) Successful in 10s
quality / api (push) Skipped
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m9s
- Replaced div elements with Item components in ServiceIpList for better alignment and structure.
- Introduced alignWithMenu prop to align IP Switch with the card menu.
- Updated ServiceUnitCard to utilize Item components, enhancing the visual consistency and responsiveness of the service card layout.
- Improved overall user experience by streamlining component structure and ensuring proper alignment of UI elements.
2026-08-19 22:37:54 +07:00
DenozordecandCursor 44d0eb0114 ci: учитывать несколько коммитов в одном пуше при detect paths
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m37s
quality / changes (push) Successful in 8s
quality / web (push) Successful in 51s
quality / api (push) Successful in 45s
CD / quality (push) Successful in 1m47s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 22:09:14 +07:00
DenozordecandCursor 9f00dfcf84 chore(reui): обновить ReUI agent skill до актуальной версии
CD / update-wiki (push) Successful in 4s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Skipped
quality / changes (push) Failing after 7s
quality / web (push) Skipped
quality / api (push) Skipped
CD / quality (push) Failing after 9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:04:19 +07:00
DenozordecandCursor 9b9dcc3b12 fix(ui): выровнять health-check плитки и тип TCP/HTTP под ReUI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:04:09 +07:00
Denozordec 6008cd763a refactor(health): clean up health check configuration and UI components
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m44s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / web (push) Successful in 58s
CD / quality (push) Successful in 1m6s
- Removed unused imports and alerts related to Cloudflare, Globalping, and Local health checks from the HealthCheckConfigFields component.
- Simplified the logic for rendering health check sources and improved layout responsiveness by adding min-width classes.
- Introduced new Cloudflare and Globalping SVG icons for better visual representation in the HealthSourceTiles component.
- Refactored the TilePanel component to enhance its structure and ensure proper handling of minimum width for child elements.

This commit streamlines the health check configuration UI, improving maintainability and user experience.
2026-08-19 19:37:36 +07:00
DenozordecandCursor b9bea44dce feat(health): добавить Globalping и мультивыбор источников проб
CD / update-wiki (push) Successful in 8s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m40s
quality / changes (push) Successful in 5s
quality / web (push) Successful in 54s
quality / api (push) Successful in 46s
CD / quality (push) Successful in 1m49s
Несколько источников проб сразу и правило агрегации на сервисе вместо XOR Local/Cloudflare.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 18:32:18 +07:00
DenozordecandCursor 4c4908558b feat(health): деплоить probe-Worker из CFDM и опрашивать цели с edge
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
CD / publish (push) Successful in 2m21s
quality / changes (push) Successful in 9s
quality / web (push) Successful in 1m4s
quality / api (push) Successful in 54s
CD / quality (push) Successful in 2m17s
Worker сам ходит на origin по Cron Trigger; CFDM кладёт цели в KV и забирает результаты без POST /probe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:22:07 +07:00
Denozordec 2c92e78b24 feat(health-checks): enhance health check configuration and logging
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m35s
quality / changes (push) Successful in 5s
quality / web (push) Successful in 51s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 1m44s
- Added new health worker URL and token fields to the AppConfig interface, allowing for Cloudflare Worker integration.
- Updated health check routes to utilize the new worker configuration, enabling dynamic health checks via Cloudflare Workers.
- Introduced a health log endpoint for services, providing detailed logs of health probe results.
- Enhanced health check service logic to support both local and Cloudflare Worker providers, improving flexibility in health monitoring.
- Updated UI components to reflect changes in health check provider settings and display relevant health information.

This commit significantly improves the health check management capabilities, allowing for better integration with Cloudflare Workers and enhanced logging features.
2026-08-19 16:27:34 +07:00
Denozordec d63c86065c feat(health-checks): implement health check IP toggling and configuration updates
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m38s
quality / changes (push) Successful in 5s
quality / web (push) Successful in 51s
quality / api (push) Successful in 43s
CD / quality (push) Successful in 1m43s
- Added functionality to toggle individual IPs for services, allowing for dynamic management of IP health status.
- Enhanced the health check configuration in the UI, enabling users to set parameters directly from the settings page.
- Updated service views to include IP health tracking, improving visibility into the status of each IP associated with a service.
- Refactored relevant components to support the new IP toggling feature, ensuring a seamless user experience.

This commit significantly enhances the health management capabilities of services, providing users with more control over IP configurations and health monitoring.
2026-08-19 15:33:47 +07:00
Denozordec 4224db8eb3 feat(services): enhance service health management with IP health tracking
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m33s
quality / changes (push) Successful in 5s
quality / web (push) Successful in 55s
quality / api (push) Successful in 41s
CD / quality (push) Successful in 1m45s
- Introduced IP health tracking in service views, allowing for detailed monitoring of individual IP statuses and latencies.
- Updated the service configuration to include an `ip_health` array, providing structured health data for each IP.
- Enhanced the `attachServiceHealth` function to aggregate IP health data alongside overall service health.
- Modified relevant components to display IP health information, improving visibility and user experience in service management interfaces.

This commit significantly improves the health monitoring capabilities of services, enabling better insights into the status of individual IPs associated with each service.
2026-08-19 14:57:19 +07:00
Denozordec 4ca948292d refactor(services): streamline service edit functionality and improve component structure
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m35s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m2s
- Removed unused imports and refactored the ServiceEditSheet component to enhance readability and maintainability.
- Introduced new utility functions for managing service binding drafts and handling common FQDN changes.
- Updated the ServiceCatalogSection to improve layout responsiveness and enhance the display of service units.
- Simplified the ServiceUnitCard component by removing unnecessary elements and optimizing the layout for better user experience.

This commit improves the overall structure and functionality of service management components, making them more efficient and user-friendly.
2026-08-19 14:27:02 +07:00
Denozordec 50c5c21c18 feat(services): enhance service edit and display components with common domain support
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m36s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 1m1s
- Added a common FQDN input field in the ServiceEditSheet to streamline domain management for services.
- Updated binding handling to synchronize the common domain across service bindings.
- Improved the ServiceKanbanCard to display both common domain and IP addresses for better visibility.
- Refactored the ServiceFqdnList to support customizable text classes and added a new ServiceIpList component for IP display.

This commit enhances the user experience by simplifying domain management and improving the display of service information.
2026-08-19 13:29:15 +07:00
Denozordec ab4ccbd7a1 feat(services): add copyable FQDN functionality and improve service display
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m41s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 59s
- Enhanced the ServiceFqdnList component to support a copyable feature, allowing users to easily copy FQDNs to the clipboard.
- Updated the ServiceKanbanCard to utilize the new copyable functionality in the FQDN list.
- Refined the services grouped catalog logic to improve data handling and display.
- Removed the obsolete services-grouped-columns file to streamline the codebase.

This commit improves user experience by facilitating easier access to FQDNs and optimizing service management components.
2026-08-19 13:03:52 +07:00
Denozordec b2dbb4ad98 feat(health-checks): enhance health check configuration and UI components
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m54s
quality / changes (push) Successful in 5s
quality / web (push) Successful in 54s
quality / api (push) Successful in 52s
CD / quality (push) Successful in 1m55s
- Introduced a new HealthProviderToggle component to manage health check providers (local/cloudflare) in the UI.
- Updated health check configuration to include additional parameters such as retries and consecutive success/failure counts.
- Improved the service edit and group edit sheets to support the new health check provider options.
- Enhanced documentation to clarify the use of ACTIONS_PAT and GITEA_TOKEN for wiki updates.

This commit improves the health check management experience and expands the configuration options for better service monitoring.
2026-08-19 12:39:33 +07:00
Denozordec 3f6f402872 feat(health-checks): enhance health check functionality and add new routes
quality / commitlint (push) Skipped
CD / update-wiki (push) Failing after 8s
quality / docker-check (push) Skipped
CD / publish (push) Successful in 1m33s
quality / changes (push) Successful in 5s
quality / web (push) Successful in 50s
quality / api (push) Successful in 41s
CD / quality (push) Successful in 1m40s
- Introduced origin health check routes and integrated them into the application.
- Updated health check configuration to include success recovery thresholds.
- Expanded error handling with new error codes for health check failures.
- Added new service routes for managing health checks, including creation and listing.
- Improved health check service logic to track consecutive successes and failures.

This commit enhances the health check capabilities, providing better monitoring and management of service health.
2026-08-19 12:26:12 +07:00
DenozordecandCursor 9c00b268dc fix(ui): выровнять очередь внимания дашборда под Frame и IconTile
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
CD / publish (push) Successful in 2m36s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / web (push) Successful in 1m6s
CD / quality (push) Successful in 1m21s
Соседние Frame-колонки как в EvoBGP вместо вложенной оболочки; compact empty через IconTile elevated; счётчики ReUI Badge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 01:11:36 +07:00
Denozordec a185b3a2cb chore: update dependencies and improve wiki update process
CD / update-wiki (push) Successful in 11s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Successful in 54s
quality / changes (push) Successful in 9s
quality / web (push) Successful in 1m34s
quality / api (push) Successful in 1m4s
CD / quality (push) Successful in 2m52s
- Added new dependencies for @tanstack/router-cli and updated related packages in pnpm-lock.yaml.
- Modified the build script in package.json to include `tsr generate` for route generation.
- Enhanced the wiki update job in Gitea Actions to use a fallback for the GITEA_TOKEN and improved the authorization method for pushing changes.
- Updated .dockerignore to clarify generated files.

This commit improves the build process and ensures smoother updates to the wiki documentation.
2026-08-19 01:00:24 +07:00
Denozordec ed4d6f8a46 chore: update Docker configuration and CI/CD setup
quality / commitlint (push) Skipped
CD / update-wiki (push) Failing after 8s
quality / docker-check (push) Skipped
CD / publish (push) Failing after 11m24s
quality / changes (push) Successful in 7s
quality / web (push) Successful in 1m8s
quality / api (push) Successful in 41s
CD / quality (push) Successful in 2m0s
- Removed Dockerfile and Dockerfile.test as part of the transition to a pre-built image.
- Updated .dockerignore and .gitignore to reflect new build context and ignored files.
- Modified docker-compose.yml to use the new image for the application.
- Enhanced documentation in AGENTS.md and README.md to include CI/CD details and release process.
- Added new dependencies for commit linting and semantic release in package.json and pnpm-lock.yaml.

This commit streamlines the Docker setup and improves the CI/CD workflow with Gitea Actions.
2026-08-19 00:42:38 +07:00
DenozordecandCursor 597017b625 ci: переключить registry на git.shx.one
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 48s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Обновить Gitea Actions и ссылки на образы.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 16:43:37 +07:00
DenozordecandCursor 7cd86fcda1 fix(ui): выровнять KPI IconTile chrome и доступность
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build and Push CFDM Docker Image / build-and-push (push) Successful in 2m15s
Переименовать KpiStats, добавить skip-link и IconTile в kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 15:36:54 +07:00
Denozordec ce8393e0c3 refactor(services): update service creation logic and enhance service group schema
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build and Push CFDM Docker Image / build-and-push (push) Successful in 2m2s
- Changed service creation to default `enabled` to true in the database schema.
- Updated service group schema to set default values for `icon`, `domain`, and health check parameters.
- Refactored service routes to use `await` for fetching service views, ensuring proper asynchronous handling.
- Improved error handling in service groups query to provide clearer feedback on response validation.

This commit enhances the overall service management experience by ensuring services are enabled by default and improving the robustness of the service group schema.
2026-08-07 13:18:47 +07:00
Denozordec 3fb8480f8a Update ReUI skill documentation to reflect the addition of 3 new components, increasing the total from 17 to 20. Adjust descriptions and examples accordingly, including updates to the data-grid API to align with TanStack Table v9. Ensure all references to components and their usage are consistent across the documentation.
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 2m2s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 7s
2026-08-07 09:46:30 +07:00
DenozordecandCursor 721332e767 fix(integrations): не считать CNAME hostname за IP при sync
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m49s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
JOIN dns_record клал content CNAME в target_ips и блокировал разворот до origin IP / service IPs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 01:30:25 +07:00
DenozordecandCursor ec70104085 fix(integrations): отдавать origin IP и cnameTarget для CNAME sync
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build and Push CFDM Docker Image / build-and-push (push) Successful in 2m6s
Публичный DNS даёт CF anycast; для матча в VPS Tracker нужны origin из dns_records и цель CNAME.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 01:16:26 +07:00
DenozordecandCursor c01c75116a fix(integrations): разворачивать CNAME до IP при sync в VPS Tracker
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build and Push CFDM Docker Image / build-and-push (push) Successful in 2m16s
CNAME-bindings отдавали пустой ips[]; теперь IP берутся из локальной цепочки bindings, DNS resolve4 или IP сервиса — чтобы vps-tracker мог матчить домен к VPS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 00:59:01 +07:00
DenozordecandCursor 19620c0803 fix(integrations): отдавать bindings в ответе sync для VPS Tracker
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m50s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Ручной sync работает через pull: VPS Tracker получает bindings в ответе и не зависит от обратного push. Уточнены сообщения об ошибках сети.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 00:36:04 +07:00
DenozordecandCursor 60da2b9032 feat(integrations): добавить полный sync bindings в VPS Tracker
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m56s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Endpoint для запроса полной синхронизации по integration token.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 23:24:04 +07:00
DenozordecandCursor 3e4cacab42 feat(services): сделать multi-FQDN привязки first-class в UI
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 2m9s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 7s
Исправить prune при domains: [], показать все FQDN в каталоге/kanban, улучшить sheet привязок и панель на домене; убрать мёртвый код.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 01:29:08 +07:00
DenozordecandCursor 859ad23006 feat(audit): локальный журнал и push в auth-portal
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 5s
Build and Push CFDM Docker Image / build-and-push (push) Successful in 2m9s
Таблица audit_log, recordAudit на CRUD, GET /api/v1/audit и dual-write source_app=cfdm.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:24:32 +07:00
DenozordecandCursor 30447d8380 chore(rules): правило kpi-hybrid — только hybrid KPI из kit
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 48s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 4s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 02:35:52 +07:00
DenozordecandCursor c9eee6fdcb chore(security): игнорировать локальные mcp.json
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 5s
Build and Push CFDM Docker Image / build-and-push (push) Successful in 49s
.cursor/mcp.json и .mcp.json в gitignore; убраны из индекса; добавлен mcp.json.example без секрета.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 00:29:57 +07:00
Denozordec 2b3c48cdde chore(.gitignore): add daemon.pid to ignore list
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 48s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Updated .gitignore to exclude the daemon.pid file from version control. Modified daemon.pid to reflect a new process ID and updated the timestamp for the last start time.
2026-07-21 00:26:16 +07:00
DenozordecandCursor 32248d88a4 chore(web): унифицировать ReUI PRO kit и rules под EvoBGP hybrid
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m48s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Синхронизированы KpiStatGrid/QuickActionGrid (kpi-cols), design contract и ReUI skill; ReUI PRO выше shadcn в rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 00:23:01 +07:00
DenozordecandCursor 278a6b64b4 fix(certificates): не мониторить SSL без health-check с TLS verify
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m59s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Биндинги с выключенным health-check (imsk/mmsk) исключаются из авто-мониторинга; sticky footer в редактировании сервиса.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 18:09:51 +07:00
DenozordecandCursor 56f2ed78e5 fix(certificates): убрать stale SSL-хосты и вернуть Save в FormSheet
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m59s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 5s
Группы с health-check без verify_tls исключаются из мониторинга сертификатов; list/summary чистят stale. FormSheet — sticky footer со скроллом тела.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 17:57:55 +07:00
Denozordec 1d497d01c2 feat(health-check): add TLS verification option to health check configuration
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m59s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 7s
Introduced a new `verify_tls` boolean option in health check configurations across various services, allowing users to specify whether to validate TLS certificates during health checks. Updated related components and services to accommodate this new option, ensuring proper handling in both the backend and frontend. Enhanced tests to validate the new functionality and ensure correct behavior with different configurations.
2026-07-20 17:28:02 +07:00
Denozordec 9783974949 feat(health-check): introduce health probe gap configuration and enhance health check logic
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build and Push CFDM Docker Image / build-and-push (push) Successful in 2m0s
Added `healthProbeGapMs` configuration to control the minimum pause between probes to different physical targets. Updated health check service to utilize this configuration, ensuring efficient probing without overwhelming the targets. Enhanced the `runAllChecks` function to group probes by physical IP and implement the new gap logic. Updated related tests to validate the new functionality.
2026-07-20 03:57:37 +07:00
Denozordec a58d91ff5e feat(health-check): enhance HTTP probe functionality with URL helpers
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 5s
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m51s
Added new utility functions `hostForUrl` and `buildHttpProbeUrl` to improve URL construction for health checks. Updated `httpProbe` to utilize these functions, ensuring proper handling of FQDN and IP addresses. Enhanced tests to validate the new URL building logic and ensure correct behavior with different hostnames and IPs.
2026-07-20 03:41:55 +07:00
DenozordecandCursor 9d8c2aff86 feat(app-switcher): читать конфиг из auth-portal вместо локального editor
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 5s
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m58s
CURRENT_APP_ID cfdm; настройка ссылок только на портале.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 20:59:03 +07:00
DenozordecandCursor f1767b8a1f fix(ui): выровнять ритм Dashboard с list-страницами
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 5s
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m47s
PageHeader вынесен из OpsDashboard; empty чартов/очереди — EmptyState по центру.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 20:47:28 +07:00
DenozordecandCursor 0a4293b735 fix(ui): центрировать EmptyState по empty-state-12
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m59s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
ResourcePage оборачивает пустой список в Frame; в панелях — centered=false.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 20:33:48 +07:00
Denozordec e748c875bc refactor(auth): replace redirectToPortalLogin with redirectToPortalLogout
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m45s
Updated authentication flow to use redirectToPortalLogout instead of redirectToPortalLogin in NavUser and AppearanceSettingsPage components. Introduced redirectToPortalLogout function to handle SSO session termination and redirect users to the logout URL.
2026-07-18 18:36:34 +07:00
Denozordec 6a6cb34eeb feat(auth): implement portal SSO and local admin authentication
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m57s
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Added support for portal SSO with JWT authentication and local admin login. Updated environment configuration to include AUTH_REQUIRED, AUTH_JWT_SECRET, AUTH_ISSUER, and AUTH_PORTAL_URL. Enhanced the auth plugin to handle JWT verification based on the new configuration. Introduced new routes for authentication and updated the API client to manage token handling and redirects. Improved user experience by integrating authentication checks across various routes and components.
2026-07-18 18:25:29 +07:00
Denozordec 60e15ca40a refactor(ui): улучшить структуру компонентов AppSidebar и PageHeader
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m11s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m9s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Обновлены компоненты AppSidebar и PageHeader для улучшения структуры и адаптивности. В AppSidebar добавлен новый элемент SidebarGroupContent для более четкой организации навигации. В PageHeader упрощена разметка и улучшены стили, включая изменения в отображении заголовка и описания. Также обновлены стили в AppShell для интеграции SearchMenu и TooltipProvider, что улучшает взаимодействие с пользователем.
2026-07-17 23:56:29 +07:00
Denozordec 1893778e3a feat(ui): добавить новый элемент управления для EvoBGP в AppSwitcher и обновить документацию
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m10s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m6s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Добавлен новый элемент управления для EvoBGP в конфигурацию AppSwitcher с соответствующими иконкой и ярлыком. Обновлены стили и документация для Shared App Shell chrome, включая ссылки на новые превью и улучшения в компоновке. Изменены классы CSS для AppShell и PageShell для лучшей адаптивности.
2026-07-17 23:14:43 +07:00
Denozordec 76a58beb7e fix(layout): обновить стили PopoverContent для улучшения компоновки
Build, Test, and Push CFDM Docker Image / test (push) Successful in 6m18s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m57s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Изменены классы CSS в компоненте SystemMonitorPopover для применения flexbox, что улучшает организацию и адаптивность содержимого. Это изменение направлено на улучшение визуального представления и взаимодействия с пользователем.
2026-07-17 20:34:19 +07:00
Denozordec 13c079c969 feat(ui): добавить новый маршрут и интерфейс для настроек внешнего вида
Build, Test, and Push CFDM Docker Image / test (push) Failing after 36s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Добавлен новый маршрут для страницы настроек внешнего вида в интерфейсе. Обновлены компоненты навигации и заголовка для поддержки нового маршрута. Включены изменения в документацию и интерфейсы для отображения новых элементов управления. Также добавлена поддержка отображения быстрого доступа к разделам в панели управления.
2026-07-17 20:34:05 +07:00
Denozordec cd616c7a03 refactor(ui): улучшить компоновку и стили KPI карточек
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m25s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m46s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Обновлены компоненты KpiStatCardBody и KpiStatGridSkeleton для улучшения визуального представления и компоновки. Изменены классы CSS для более компактного и адаптивного отображения, добавлены новые элементы для лучшей организации информации. Обновлена документация для отражения изменений в дизайне KPI карточек.
2026-07-17 18:36:51 +07:00
Denozordec 733c041482 feat(ui): добавить поддержку варианта для KPI карточек
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m10s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m46s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 5s
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Обновлены компоненты CertKpiCards, DomainKpiCards, GroupKpiCards и ServiceKpiCards для поддержки новых вариантов отображения ('warning' и 'destructive') в зависимости от состояния данных. Также обновлен интерфейс KpiStatCard для включения свойства variant. Обновлены ссылки в документации для отражения изменений в дизайне KPI карточек.
2026-07-17 17:15:01 +07:00
DenozordecandCursor d8340e8a0b feat(ui): обновить KPI с card-35 на stats-12 во всех компонентах
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m27s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m52s
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Failing after 6s
Заменены ссылки на блоки card-35 на stats-12 в документации и компонентах, включая KpiStatGrid, CertKpiCards, DomainKpiCards, GroupKpiCards и ServiceKpiCards. Удален неиспользуемый компонент AppCard. Добавлены иконки для улучшения визуализации KPI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 15:30:25 +07:00
DenozordecandCursor 704624cbe7 feat(ui): единый PageHeader h1 на всех разделах
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m4s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m45s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Заголовок раздела над KPI/контентом; FrameTitle text-sm больше не заменяет page-level h1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 14:29:44 +07:00
372 changed files with 32405 additions and 19723 deletions

No files matched your search

+4 -4
View File
@@ -1,17 +1,17 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 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
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 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)
+4 -2
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Public, no key: `c-*` examples and the 20 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 (their key) or https://reui.io/pricing (upgrade).
+95 -17
View File
@@ -1,24 +1,23 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**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 v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
`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 `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
- 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 = useReactTable({
const table = useTable({
features: dataGridFeatures,
data,
columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
})
<DataGrid table={table} recordCount={data.length}>
@@ -28,9 +27,37 @@ const table = useReactTable({
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
- **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 documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
- **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
@@ -79,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
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 filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**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
@@ -284,6 +349,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**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.
## alert
**Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **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).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Free, no key:** the 20 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
+2 -2
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `type`: `"component"` (one of the 20 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`.
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
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 `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
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)).
+4 -4
View File
@@ -1,17 +1,17 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 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
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 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)
+4 -2
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Public, no key: `c-*` examples and the 20 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 (their key) or https://reui.io/pricing (upgrade).
+95 -17
View File
@@ -1,24 +1,23 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**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 v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
`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 `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
- 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 = useReactTable({
const table = useTable({
features: dataGridFeatures,
data,
columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
})
<DataGrid table={table} recordCount={data.length}>
@@ -28,9 +27,37 @@ const table = useReactTable({
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
- **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 documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
- **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
@@ -79,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
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 filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**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
@@ -284,6 +349,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**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.
## alert
**Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **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).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Free, no key:** the 20 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
+2 -2
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `type`: `"component"` (one of the 20 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`.
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
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 `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
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)).
-6
View File
@@ -1,6 +0,0 @@
{
"pid": 47044,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-7bcc7a2b16d00925",
"startedAt": 1784215894915
}
File renamed without changes.
+5 -4
View File
@@ -20,13 +20,14 @@ Fastify backend: `apps/api/`. Контракты — `@cfdm/shared` (Zod). Front
- Дублирование Zod schemas в `apps/web` — только re-export из `@cfdm/shared`
- Самописные формы без `Field` + RHF + Zod
## shadcn-паттерны
## UI-паттерны (ReUI Frame)
| API-данные | UI |
|------------|-----|
| Список | `Table` / `DataTableCard` |
| Создание | `Card` + `FieldGroup` + RHF |
| Статус | `Badge` variants |
| Список | `ResourcePage` (Frame + DataGrid + Filters) |
| Создание / редактирование | `FormSheet` + `FieldGroup` + RHF |
| Settings | `SettingsShell` + Frame + `SettingRow` |
| Статус | ReUI `Badge` / `StatusBadge` |
| Ошибка | `sonner` `toast.error` |
## Согласованность
+4 -4
View File
@@ -35,7 +35,7 @@ Monorepo — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). ReUI — [`reui-m
|-----------|------|
| `PageShell` | `page-shell.tsx` |
| `ResourcePage` | `reui-kit/resource-page.tsx` |
| `OpsDashboard` / `KpiStatGrid` | `reui-kit/ops-dashboard.tsx`, `kpi-stat-grid.tsx` |
| `OpsDashboard` / `KpiStatGrid` / `QuickActionGrid` | `reui-kit/ops-dashboard.tsx`, `kpi-stat-grid.tsx`, `quick-action-grid.tsx` |
| `KanbanBoard` | `reui-kit/kanban-board.tsx` |
| `DetailPanel` | `reui-kit/detail-panel.tsx` |
| `SettingsShell` | `reui-kit/settings-shell.tsx` |
@@ -58,7 +58,7 @@ Monorepo — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). ReUI — [`reui-m
cd apps/web
pnpm dlx shadcn@latest add button field input ...
pnpm dlx shadcn@latest add @reui/frame @reui/data-grid @reui/filters
pnpm dlx shadcn@latest add @reui/card-35 @reui/settings-16 @reui/auth-13
pnpm dlx shadcn@latest add @reui/stats-12 @reui/card-35 @reui/settings-16 @reui/auth-13
pnpm dlx shadcn@latest apply b2fA --only theme -y
```
@@ -76,7 +76,7 @@ pnpm dlx shadcn@latest apply b2fA --only theme -y
| `<table>`, raw select | DataGrid / `Select` |
| `bg-emerald-*` | semantic / ReUI `variant` |
| Card как ops-shell | ReUI `Frame` |
| Hand-roll KPI | `KpiStatGrid` / [card-35](https://reui.io/preview/base/card-35) |
| Hand-roll KPI | `KpiStatGrid` / [stats-12](https://reui.io/preview/base/stats-12) |
| `space-y-*` | `flex` + `gap-*` |
| `@/components/ui/*` | `@cfdm/ui/components/*` |
@@ -86,7 +86,7 @@ pnpm dlx shadcn@latest apply b2fA --only theme -y
|------|------|---------|
| Shell | `layout/app-shell.tsx` | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
| Login | `routes/login.tsx` | [auth-13](https://reui.io/preview/base/auth-13) |
| Dashboard | `routes/_auth/index.tsx` | [card-35](https://reui.io/preview/base/card-35) / dashboard-1 |
| Dashboard | `routes/_auth/index.tsx` | [stats-12](https://reui.io/preview/base/stats-12) / dashboard-1 |
| Lists | `ResourcePage` | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
| Settings | `settings/integrations.tsx` | [settings-16](https://reui.io/preview/base/settings-16) |
+11 -6
View File
@@ -27,7 +27,8 @@ alwaysApply: false
| Зона | Preview |
|------|---------|
| KPI | [card-35](https://reui.io/preview/base/card-35) |
| KPI | [stats-12](https://reui.io/preview/base/stats-12) — EvoBGP hybrid SoT |
| Quick Actions | [stats-12](https://reui.io/preview/base/stats-12) · [card-12](https://reui.io/preview/base/card-12) → `QuickActionGrid` |
| List | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
| Settings | [settings-16](https://reui.io/preview/base/settings-16), [Application Settings](https://reui.io/blocks/application/settings) |
| Settings rows / Health-check | [settings-2](https://reui.io/preview/base/settings-2), [settings-3](https://reui.io/preview/base/settings-3) |
@@ -36,7 +37,7 @@ alwaysApply: false
| Forms | [form-7](https://reui.io/preview/base/form-7) |
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
**SettingRow:** `FieldSeparator` только между соседними rows; не между toggle-row и nested fields (Health-check).
**SettingRow:** `FieldSeparator` opt-in (`separated`); не между toggle-row и nested fields (Health-check). Settings-секции — отдельные Frame + `gap`, без hairline под PageHeader.
## Иерархия компонентов
@@ -50,7 +51,7 @@ apps/web/src/components/ ← shared + domain + layout
kanban-board.tsx ← kanban + KanbanBoardSkeleton
detail-panel.tsx ← detail: Frame header/metrics
settings-shell.tsx
ops-dashboard.tsx ← KPI card-35 + charts
ops-dashboard.tsx ← KPI stats-12 + charts
catalog-board-toggle.tsx
empty-state.tsx
query-state.tsx
@@ -73,7 +74,8 @@ apps/web/src/components/ ← shared + domain + layout
| Kanban | `KanbanBoard` / `KanbanBoardSkeleton` | ReUI `kanban` + `Frame` |
| Detail | `DetailPanel` | ReUI `Frame` |
| Settings | `SettingsShell` | — |
| Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [card-35](https://reui.io/preview/base/card-35) |
| Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [stats-12](https://reui.io/preview/base/stats-12) hybrid |
| Quick Actions | `QuickActionGrid` | Frame tiles + Badge «Перейти» |
| Empty | `EmptyState` | `Empty` |
| Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` |
| Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) |
@@ -127,7 +129,7 @@ Toggle «Доска» / «К каталогу» — всегда `outline` в `p
## Dashboard KPI
Эталон: [card-35](https://reui.io/preview/base/card-35) — muted **label** + крупный **value** + optional **hint**; клик по карточке через `to` (`Link`), без отдельных hint-links.
Эталон: [stats-12](https://reui.io/preview/base/stats-12) — icon tile + value + label + optional badge/footer; клик через `to` (`Link`) / `onSelect`. Compact strip: [card-35](https://reui.io/preview/base/card-35).
## Sidebar
@@ -149,7 +151,7 @@ Toggle «Доска» / «К каталогу» — всегда `outline` в `p
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
| List + tabs + filters | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
| Line tabs | [c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) |
| KPI | [card-35](https://reui.io/preview/base/card-35) — на dashboard, certificates, domains, groups, services |
| KPI | [stats-12](https://reui.io/preview/base/stats-12) — на dashboard, certificates, domains, groups, services |
| Login | [auth-13](https://reui.io/preview/base/auth-13) |
| Settings | [settings-16](https://reui.io/preview/base/settings-16) / [settings-6](https://reui.io/preview/base/settings-6) |
| Empty | [empty-state-12](https://reui.io/preview/base/empty-state-12) |
@@ -158,6 +160,7 @@ Toggle «Доска» / «К каталогу» — всегда `outline` в `p
```
AppShell main: gap-4 md:gap-6, px-4 md:px-6, py-4 md:py-5
(--sidebar-width: 240px; header h-12; AppSwitcher + AppsMenu — Shared App Shell chrome)
PageShell: gap-4 md:gap-6
Title/desc: gap-px
Card/Frame grid: gap-4 (dashboard denser: gap-2 md:gap-3)
@@ -167,6 +170,8 @@ Toolbar / header actions: gap-2
Tabs list (line): gap-5
```
Shared chrome (vps-tracker / CFDM / EvoBGP): см. [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md) — секция **Shared App Shell chrome**. Preview: [app-shell-12](https://reui.io/preview/base/app-shell-12).
**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner / plain «Загрузка…» без Skeleton.
## UX/UI (состояния данных)
+30
View File
@@ -0,0 +1,30 @@
---
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
alwaysApply: true
---
# KPI hybrid — только kit (stats-12 + IconTile)
Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
## MUST
| Зона | Компонент / DNA |
|------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
Импорты UI: `@cfdm/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
- Другой size / radius / solid brand fill вместо `elevated`
- `card-35` как замена stats-12 hybrid KPI
- Копипаст ReUI block в route — adapt через `reui-kit/`
- Голый lucide `size-4` в name-cell без IconTile
+20 -8
View File
@@ -11,10 +11,14 @@ alwaysApply: true
| Документ | URL |
|----------|-----|
| **Introduction** | https://reui.io/docs |
| **llms.txt** | https://reui.io/llms.txt |
| **Get Started** | https://reui.io/docs/get-started |
| **Styling** | https://reui.io/docs/styling |
| **Registry** | https://reui.io/docs/registry |
| **MCP** | https://reui.io/docs/mcp |
| **Agent Skills** | https://reui.io/docs/agent-skills |
| **Cursor MCP** | https://reui.io/docs/cursor |
| **Blocks** | https://reui.io/blocks |
| **Settings blocks** | https://reui.io/blocks/application/settings |
| **License** | https://reui.io/docs/license-setup |
@@ -27,6 +31,10 @@ alwaysApply: true
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
**Registry (актуально):** 20 free components — `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.claude/skills/reui` (v `668fb463eb`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
**Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции.
## Когда ReUI vs shadcn
| Задача | Registry | Импорт |
@@ -36,11 +44,14 @@ alwaysApply: true
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
| Filters | `@reui` | `@/components/reui/filters` |
| Frame surface | `@reui` | `@/components/reui/frame` |
| KPI | block [card-35](https://reui.io/preview/base/card-35) | `reui-kit/KpiStatGrid` |
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
| Number / date / autocomplete / color / kanban / timeline / stepper / tree | `@reui` | `@/components/reui/*` |
| Event Calendar / Gantt / Icon Tile (registry, 20 free) | `@reui` | CLI `@reui/<name>` → `@/components/reui/*` при установке |
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не устаревший DataGridCard.
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
## MCP workflow
@@ -64,11 +75,11 @@ alwaysApply: true
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`
**Kit:** `ResourcePage`, `KpiStatGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
**Blocks (reference):** `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
## License
@@ -83,7 +94,7 @@ REUI_LICENSE_KEY=
| Зона | Preview |
|------|---------|
| KPI | https://reui.io/preview/base/card-35 |
| KPI / Quick Actions | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
| List | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | https://reui.io/preview/base/settings-16 |
| Auth | https://reui.io/preview/base/auth-13 |
@@ -96,8 +107,9 @@ REUI_LICENSE_KEY=
- ReUI в `packages/ui` / импорт как `@cfdm/ui`
- Radix-варианты docs — только Base UI
- Raw `bg-emerald-*` вместо ReUI `variant`
- Hand-roll data-grid/filters/KPI/settings rows при наличии `@reui` / kit
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
- Смешивать Card и Frame на ops-экране
- Ставить shadcn/ui blocks выше ReUI PRO
## Чеклист
+4 -4
View File
@@ -6,18 +6,18 @@ alwaysApply: false
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 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
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 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)
+25 -30
View File
@@ -1,49 +1,44 @@
---
description: ВСЕГДА использовать MCP-плагин shadcn UI перед любым UI-кодом
description: shadcn MCP — primitives secondary; pages/blocks → user-reui first
alwaysApply: true
---
# shadcn MCP — обязательно
# shadcn MCP — primitives (secondary)
Перед **любой** задачей с UI (новый экран, компонент, стили, рефакторинг внешнего вида) — **сначала MCP** `plugin-shadcn-shadcn`, не память и не веб-поиск.
Перед UI-задачей: **сначала** MCP `user-reui` для pages / KPI / lists / settings / Frame ([`reui-mcp.mdc`](reui-mcp.mdc)).
MCP `plugin-shadcn-shadcn` — для **primitives** `@shadcn` и registry sync, не вместо PRO blocks.
CLI и docs — **после** MCP, по команде из `get_add_command_for_items`.
## Порядок (строго)
## Порядок (primitives)
0. **Codegraph** `codegraph_explore` — найти существующие реализации и shared-обёртки (один вызов перед правками)
1. **`get_project_registries`** — какие registry доступны в проекте
2. **`search_items_in_registries`** — компонент, block, example (`query`: `"card"`, `"tabs demo"`, `"dashboard"`, `"scroll-area"`)
3. **`get_item_examples_from_registries`** — полный код примера перед написанием JSX
4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...`
5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc))
6. **CLI docs (обязательно):** `cd apps/web && pnpm dlx shadcn@latest docs <component>` — сверить API/props с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components)
7. Сверить examples из MCP с API из docs CLI — реализовать только после совпадения
8. Адаптировать пример под TanStack Router / Query → `apps/web/src/`
9. **Context7** — только TanStack / Recharts / не-shadcn (не заменяет шаги 1–8 для примитивов)
10. **`get_audit_checklist`** — перед merge PR
11. **Codegraph** `codegraph_status` — Pending sync пустой после правок
0. Поиск существующих shared / `reui-kit`
1. **`get_project_registries`**
2. **`search_items_in_registries`** — primitive / example
3. **`get_item_examples_from_registries`**
4. **`get_add_command_for_items`** → CLI из `apps/web`
5. `pnpm dlx shadcn@latest docs <component>` — сверить API
6. Адаптировать под TanStack → `apps/web/src/`
7. Context7 — только TanStack / Recharts / не-shadcn
8. **`get_audit_checklist`** — перед merge
## Когда вызывать MCP
## Когда
| Задача | MCP |
|--------|-----|
| Новая страница / layout | `search` → `types: ["block"]` → examples → add block |
| Нет примитива в `@cfdm/ui` | `search` → `get_add_command_for_items` → add |
| Сомнение в API/props | `get_item_examples_from_registries` |
| Ревью UI перед сдачей | `get_audit_checklist` |
| Новая страница / KPI / list / settings | **`user-reui`** ([`reui-mcp.mdc`](reui-mcp.mdc)) |
| Нет примитива в `@cfdm/ui` | `plugin-shadcn-shadcn` → add |
| Сомнение в props примитива | examples + docs CLI |
## Запрещено
- Писать UI по памяти, не проверив MCP
- Копипаст с ui.shadcn.com без examples/add из MCP
- Самописные примитивы, если есть item в registry
- Пропускать MCP «потому что компонент простой»
- Писать UI по памяти
- Ставить ui.shadcn.com/blocks выше ReUI PRO
- Самописные примитивы при наличии registry item
## Сервер и инструменты
## Сервер
- **MCP server:** `plugin-shadcn-shadcn`
- **Инструменты:** `get_project_registries`, `search_items_in_registries`, `get_item_examples_from_registries`, `get_add_command_for_items`, `view_items_in_registries`, `list_items_in_registries`, `get_audit_checklist`
- Перед вызовом — прочитать schema в `mcps/plugin-shadcn-shadcn/tools/`
- **MCP:** `plugin-shadcn-shadcn` (+ `user-reui` primary)
- Schema: `mcps/plugin-shadcn-shadcn/tools/`
Связанные правила: [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc).
Связанные: [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc), [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc).
+16 -9
View File
@@ -1,26 +1,33 @@
---
description: shadcn/ui — глобальные UI-принципы проекта; frontend см. frontend-shadcn.mdc
description: shadcn/ui + ReUI PRO — глобальные UI-принципы; ReUI PRO выше shadcn
alwaysApply: true
---
# shadcn/ui — правила проекта
# shadcn/ui + ReUI PRO — правила проекта
UI строится **исключительно** по [shadcn/ui](https://ui.shadcn.com/docs/installation): [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks), [Monorepo](https://ui.shadcn.com/docs/monorepo).
UI строится по [shadcn/ui](https://ui.shadcn.com/docs/installation) + **ReUI PRO** (`@reui`): [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks), [ReUI Get Started](https://reui.io/docs/get-started), [llms.txt](https://reui.io/llms.txt).
**Первый шаг любой UI-задачи — MCP `plugin-shadcn-shadcn`** (см. [`shadcn-mcp.mdc`](shadcn-mcp.mdc)): search → examples → add command → CLI.
**Иерархия:** ReUI PRO (`user-reui`) **выше** базового shadcn. Pages / KPI / lists / settings / shell → ReUI; shadcn — primitives.
## Разработка frontend
**Первый шаг UI-задачи:**
1. MCP **`user-reui`** для pages / KPI / data-grid / settings / Frame / Quick Actions (`surface: frame`)
2. MCP **`plugin-shadcn-shadcn`** для primitives `@shadcn`
**Все правила frontend** — в [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc) и [`frontend-monorepo.mdc`](frontend-monorepo.mdc) (globs: `apps/web/**`, `packages/ui/**`).
См. [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`reui-mcp.mdc`](reui-mcp.mdc). Contract: [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md).
Кратко: docs → CLI из `apps/web` → Block → композиция → `pnpm --filter web build`. Кастомный CSS и самописные примитивы **запрещены**.
## Frontend
Детали: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc).
Кратко: MCP → CLI из `apps/web` → Block → kit → `pnpm --filter web build`. Кастомный CSS и самописные примитивы **запрещены**.
## Стек
- Monorepo: `apps/web` + `packages/ui` (`@cfdm/ui`), pnpm workspaces
- Vite + TanStack Router/Query + shadcn **base-nova**
- Vite + TanStack Router/Query + shadcn **base-nova** + ReUI **@reui**
- Конфиг: [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json)
- Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y` — единственный способ менять базовую тему `globals.css`; ReUI semantic tokens — [Styling](https://reui.io/docs/styling)
- Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y`; ReUI tokens — [Styling](https://reui.io/docs/styling)
- License: `REUI_LICENSE_KEY` в `apps/web/.env.local`
## Backend → UI
+4 -4
View File
@@ -1,17 +1,17 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 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
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 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)
+4 -2
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Public, no key: `c-*` examples and the 20 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 (their key) or https://reui.io/pricing (upgrade).
+95 -17
View File
@@ -1,24 +1,23 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**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 v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
`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 `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
- 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 = useReactTable({
const table = useTable({
features: dataGridFeatures,
data,
columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
})
<DataGrid table={table} recordCount={data.length}>
@@ -28,9 +27,37 @@ const table = useReactTable({
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
- **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 documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
- **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
@@ -79,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
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 filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**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
@@ -284,6 +349,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**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.
## alert
**Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **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).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Free, no key:** the 20 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
+2 -2
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `type`: `"component"` (one of the 20 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`.
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
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 `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
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)).
+48 -11
View File
@@ -1,15 +1,52 @@
# Build context = repository root (deploy/docker/docker-bake.hcl).
.git
.gitea
.github
.cursor
data
**/target
.claude
.codegraph
.agents
.opencode
memory-bank
.vscode
.idea
**/.DS_Store
**/Thumbs.db
**/.env
**/.env.*
!**/.env.example
!**/.env.*.example
node_modules
**/node_modules
**/.turbo
**/dist
**/*.md
**/.git*
frontend
*.db
*.db-shm
*.db-wal
.env
.env.*
**/.turbo
# Генерируется `tsr generate` в `web` build (tsc идёт до Vite-плагина).
apps/web/src/routeTree.gen.ts
apps/web/playwright-report
apps/web/test-results
*.md
AGENTS.md
CONTRIBUTING.md
LICENSE
docs
.pre-commit-config.yaml
.releaserc.json
.commitlintrc.*
commitlint.config.cjs
package-lock.json
data
*.exe
**/*.test.ts
**/*.spec.ts
coverage
.coverage
*.out
.release-version
CHANGELOG.md
deploy/docker/docker-bake.override.hcl
deploy/compose/runtime-logs
+22 -4
View File
@@ -4,15 +4,26 @@ CLOUDFLARE_API_TOKEN=
# Database
DATABASE_URL=sqlite:data/app.db
# Auth
JWT_SECRET=dev-secret-change-me
# Auth — portal SSO (prod) или локальный admin (dev)
# AUTH_REQUIRED=true → JWT от auth-portal, apps включает cfdm
AUTH_REQUIRED=false
AUTH_JWT_SECRET=dev-secret-change-me
# alias: JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
# Shared with auth-portal AUDIT_INGEST_SECRET — dual-write audit to portal
AUTH_AUDIT_INGEST_SECRET=dev-audit-ingest-secret
JWT_TTL_HOURS=24
# Legacy local login (только при AUTH_REQUIRED=false)
ADMIN_USERNAME=admin
# Leave empty for dev default password "admin"
ADMIN_PASSWORD_HASH=
# Frontend (Vite)
# Публичные URL приложений и integration token настраиваются в UI: Настройки → Интеграции
# Frontend (Vite) — apps/web/.env.local
# VITE_AUTH_ENABLED=true
# VITE_AUTH_PORTAL_URL=http://localhost:5175
# Публичные URL приложений и integration token: Настройки → Интеграции
# ReUI PRO (apps/web/components.json → @reui Authorization)
# Ключ: https://reui.io/docs/license-setup — класть в .env.local (gitignored)
@@ -25,3 +36,10 @@ RUST_LOG=info
# Certificate scheduler (cron)
CERT_CHECK_CRON=0 0 */6 * * *
# Local health-check engine (override in UI: Настройки → Health-check)
# HEALTH_CHECK_CRON=0 */2 * * * *
# HEALTH_DEGRADED_FAILURES=1
# HEALTH_DOWN_FAILURES=2
# HEALTH_SUCCESS_RECOVERIES=2
# HEALTH_LATENCY_WARN_MS=1000
+85
View File
@@ -0,0 +1,85 @@
# Gitea Actions
| Workflow | Когда | Что |
|----------|--------|-----|
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push; wiki параллельно |
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, web, api, commitlint, docker-check |
Подробнее: [docs/releasing.md](../docs/releasing.md).
## CI (quality gates)
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, корневой `package.json` / `pnpm-lock.yaml` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` quality jobs не запускают (wiki — отдельный job в CD).
На **pull request** — **commitlint**. При изменении `deploy/docker/**` / `.dockerignore` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
Кэшируется целиком: pnpm store + `node_modules` + corepack. При hit: `pnpm install --offline`. `HUSKY=0` в `scripts/ci/pnpm-ci.sh` (у корня есть `prepare: husky`).
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
Runner: `ubuntu-latest`, Docker для **docker-check** (PR) и **publish** (CD).
## CD (job publish)
После успешных quality gates на **push в main** job **publish**:
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
3. Зеркало base-образов в `cfdm-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `cfdm` (`cleanup: false`).
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
Job **update-wiki** идёт **параллельно** publish (не блокирует образы): при diff `docs/Home.md` копирует файл в wiki-репозиторий. Clone/push идут на публичный **`https://git.shx.one`** (не внутренний `gitea.server_url` / `192.168.x.x:3000`): Gitea `ROOT_URL` совпадает с Host, иначе `git-receive-pack` wiki отвечает `Repository not found`. Токен в URL `https://oauth2:<PAT>@…/*.wiki.git` — Gitea на неаутентифицированный wiki push даёт **404, не 401**, поэтому `http.extraHeader` / ASKPASS не срабатывают. Секрет: **`ACTIONS_PAT`**, fallback **`GITEA_TOKEN`**.
### Секреты
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `gitea.token`. Push OCI — **только PAT** (у job token Gitea нет права packages).
**`GITEA_TOKEN`**: опциональный wiki-only PAT (fallback, если нет `ACTIONS_PAT`).
### Теги образов
```text
git.shx.one/<owner>/cfdm:latest
git.shx.one/<owner>/cfdm:v1.2.3
git.shx.one/<owner>/cfdm:1.2.3
git.shx.one/<owner>/cfdm:<short-sha>
git.shx.one/<owner>/cfdm:sha-<full-sha>
```
Тот же манифест публикуется как `cloudflare-domain-manager` (drop-in для текущего `docker pull`).
Кэш сборки: `cfdm-buildcache:node-buildcache` и `cfdm-buildcache:base-*`.
Пример:
```bash
docker pull git.shx.one/denozord/cfdm:latest
docker pull git.shx.one/denozord/cloudflare-domain-manager:latest
```
См. [deploy/docker/README.md](../deploy/docker/README.md).
## act_runner: cache server
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
В `config.yaml` runner:
```yaml
cache:
enabled: true
dir: "" # по умолчанию $HOME/.cache/actcache
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
port: 8088
```
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
+203
View File
@@ -0,0 +1,203 @@
name: CD
on:
push:
branches: [main, master]
permissions:
contents: read
jobs:
quality:
uses: ./.gitea/workflows/quality.yaml
with:
is_pull_request: false
before_sha: ${{ github.event.before }}
head_sha: ${{ github.sha }}
allow_registry_login: false
secrets:
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
update-wiki:
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Checkout main repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2
- name: Check for changes in documentation
id: check_changes
run: |
set -euo pipefail
if ! git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
echo "changed=true" >> "$GITHUB_OUTPUT"
elif git diff HEAD~1 HEAD --quiet -- docs/Home.md; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Update and push Wiki content
if: steps.check_changes.outputs.changed == 'true'
env:
# ACTIONS_PAT уже пишет git (tags/releases). GITEA_TOKEN — опциональный
# wiki-only PAT; если он задан без write, Gitea отвечает 404, не 403.
WIKI_TOKEN: ${{ secrets.ACTIONS_PAT || secrets.GITEA_TOKEN }}
# Не gitea.server_url: на runner это внутренний http://192.168.x.x:3000,
# а ROOT_URL = git.shx.one — git-receive-pack wiki тогда даёт 404.
GITEA_PUBLIC_URL: https://git.shx.one
REPO: ${{ gitea.repository }}
run: |
set -euo pipefail
if [ -z "${WIKI_TOKEN:-}" ]; then
echo "ACTIONS_PAT / GITEA_TOKEN is empty — cannot push wiki"
exit 1
fi
PUBLIC_URL="${GITEA_PUBLIC_URL%/}"
TOKEN_ENC="$(python3 -c 'import urllib.parse,os; print(urllib.parse.quote(os.environ["WIKI_TOKEN"], safe=""))')"
WIKI_URL="${PUBLIC_URL}/${REPO}.wiki.git"
# Gitea на неаутентифицированный wiki push отвечает 404, не 401 —
# extraHeader/ASKPASS не помогают: токен должен быть в URL с первого запроса.
AUTH_INSTEAD="url.https://oauth2:${TOKEN_ENC}@${PUBLIC_URL#https://}/.insteadOf=${PUBLIC_URL}/"
GIT_TERMINAL_PROMPT=0 git -c "${AUTH_INSTEAD}" clone "${WIKI_URL}" cfdm.wiki
cp docs/Home.md cfdm.wiki/Home.md
cd cfdm.wiki
git config user.name "Gitea Actions"
git config user.email "actions@gitea"
git add Home.md
if git diff --staged --quiet; then
echo "Wiki Home.md already up to date"
exit 0
fi
git commit -m "docs: Update Wiki from main repository"
GIT_TERMINAL_PROMPT=0 git -c "${AUTH_INSTEAD}" push origin HEAD
publish:
needs: [quality]
if: >-
always() &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
needs.quality.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
releases: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
persist-credentials: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: Install release tooling
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: sh scripts/ci/pnpm-ci.sh
- name: Verify releasable commit messages
run: pnpm exec node scripts/commit/verify-release-commits.mjs
- name: Semantic release
run: pnpm exec semantic-release
env:
GITEA_URL: https://git.shx.one
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Detect new release
id: rel
run: |
set -euo pipefail
version=""
if [ -f .release-version ]; then
version="$(tr -d '[:space:]' < .release-version)"
echo "New release from semantic-release: $version"
else
git fetch --tags --force origin || true
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
if [ -n "${tag:-}" ]; then
version="${tag#v}"
echo "Reuse existing tag $tag on HEAD (release retry)"
fi
fi
if [ -n "${version:-}" ]; then
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "released=true" >> "$GITHUB_OUTPUT"
else
echo "released=false" >> "$GITHUB_OUTPUT"
echo "No releasable commits — skipping image publish"
fi
- name: Set up Docker Buildx
if: steps.rel.outputs.released == 'true'
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
with:
name: cfdm
driver: docker-container
cleanup: false
- name: Prepare image metadata
if: steps.rel.outputs.released == 'true'
id: meta
run: |
set -euo pipefail
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea Registry
if: steps.rel.outputs.released == 'true'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Mirror base images into buildcache
if: steps.rel.outputs.released == 'true'
env:
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
run: sh deploy/docker/mirror-base-images.sh
- name: Build and push images (bake)
if: steps.rel.outputs.released == 'true'
env:
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
CACHE_REF_NODE: git.shx.one/${{ steps.meta.outputs.owner_lc }}/cfdm-buildcache:node-buildcache
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
working-directory: deploy/docker
run: |
set -euxo pipefail
if [ -f "${MIRROR_ENV_FILE}" ]; then
set -a
# shellcheck disable=SC1090
. "${MIRROR_ENV_FILE}"
set +a
fi
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
-f docker-bake.hcl default --push
+23
View File
@@ -0,0 +1,23 @@
name: CI
on:
pull_request:
branches: [main, master]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
quality:
uses: ./.gitea/workflows/quality.yaml
with:
is_pull_request: true
base_sha: ${{ github.event.pull_request.base.sha }}
head_sha: ${{ github.event.pull_request.head.sha }}
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
secrets:
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
-189
View File
@@ -1,189 +0,0 @@
name: Build, Test, and Push CFDM Docker Image
on:
push:
branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**']
tags: ['v*']
paths: ['**']
pull_request:
branches: [main, develop]
paths: ['**']
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.12.1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint web
run: pnpm --filter web lint
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build test stage
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.test
load: true
tags: cfdm:test
provenance: false
- name: Run tests
run: docker run --rm cfdm:test
build-and-push:
needs: test
if: startsWith(gitea.ref, 'refs/tags/v') || (gitea.ref_name == 'main' && gitea.event_name == 'push')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Create version file
run: |
VERSION=$(cat VERSION)
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
BRANCH="${{ gitea.ref_name }}"
echo "APP_VERSION=${VERSION}" > ./version.txt
echo "BUILD_DATE=${BUILD_DATE}" >> ./version.txt
echo "GIT_BRANCH=${BRANCH}" >> ./version.txt
echo "GIT_COMMIT=${{ gitea.sha }}" >> ./version.txt
echo "GIT_COMMIT_SHORT=$(echo ${{ gitea.sha }} | cut -c1-7)" >> ./version.txt
echo "BUILD_TIMESTAMP=$(date -u +%s)" >> ./version.txt
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: git.shts.su/${{ gitea.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ gitea.ref_name == 'main' }}
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ gitea.ref_name == 'main' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
NODE_VERSION=22.23.0-bookworm-slim
cache-from: type=registry,ref=git.shts.su/${{ gitea.repository }}:buildcache
cache-to: type=registry,ref=git.shts.su/${{ gitea.repository }}:buildcache,mode=max
provenance: true
create-release:
needs: build-and-push
if: startsWith(gitea.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
run: |
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [[ -n "$LAST_TAG" ]]; then
CHANGELOG=$(git log --pretty=format:"- **%h** %s (%an, %ar)" --no-merges ${LAST_TAG}..HEAD 2>/dev/null || echo "")
else
CHANGELOG=$(git log --pretty=format:"- **%h** %s (%an, %ar)" --no-merges -10 2>/dev/null || echo "")
fi
if [[ -z "$CHANGELOG" ]]; then CHANGELOG="- No changes detected"; fi
echo "CHANGELOG<<EOF" >> $GITEA_OUTPUT
echo "$CHANGELOG" >> $GITEA_OUTPUT
echo "EOF" >> $GITEA_OUTPUT
- name: Create Release
run: |
VERSION="${{ gitea.ref_name }}"
RELEASE_DATA=$(cat <<EOF
{
"tag_name": "${VERSION}",
"target_commitish": "${{ gitea.sha }}",
"name": "Cloudflare Domain Manager ${VERSION}",
"body": "## Cloudflare Domain Manager ${VERSION}\n\n### Docker\n\n\`\`\`bash\ndocker pull git.shts.su/${{ gitea.repository }}:${VERSION}\ndocker run -d -p 8080:8080 -v ./data:/data --name cfdm git.shts.su/${{ gitea.repository }}:${VERSION}\n\`\`\`\n\n### Changelog\n\n${{ steps.changelog.outputs.CHANGELOG }}",
"draft": false,
"prerelease": false
}
EOF
)
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/release.json -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$RELEASE_DATA" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases")
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 409 ]; then
echo "Release OK (HTTP $HTTP_CODE)"
else
cat /tmp/release.json
exit 1
fi
update-wiki:
needs: test
if: gitea.ref_name == 'main' && gitea.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout main repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check for changes in documentation
id: check_changes
run: |
if git diff HEAD~1 HEAD --quiet -- docs/Home.md; then
echo "changed=false" >> $GITEA_OUTPUT
else
echo "changed=true" >> $GITEA_OUTPUT
fi
- name: Clone Wiki repository
if: steps.check_changes.outputs.changed == 'true'
run: |
WIKI_URL=$(echo "${{ gitea.server_url }}/${{ gitea.repository }}.wiki.git" | sed -e "s|://|://gitea-actions:${{ secrets.GITEA_TOKEN }}@|")
git clone "${WIKI_URL}" cfdm.wiki
- name: Update and push Wiki content
if: steps.check_changes.outputs.changed == 'true'
run: |
cp docs/Home.md cfdm.wiki/Home.md
cd cfdm.wiki
git config user.name "Gitea Actions"
git config user.email "actions@gitea"
git add Home.md
git diff --staged --quiet || git commit -m "docs: Update Wiki from main repository"
git push
+296
View File
@@ -0,0 +1,296 @@
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
name: quality
on:
workflow_call:
inputs:
is_pull_request:
type: boolean
required: true
base_sha:
type: string
required: false
default: ""
head_sha:
type: string
required: false
default: ""
before_sha:
type: string
required: false
default: ""
allow_registry_login:
type: boolean
required: false
default: false
secrets:
ACTIONS_PAT:
required: false
permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
outputs:
web: ${{ steps.detect.outputs.web }}
api: ${{ steps.detect.outputs.api }}
docker: ${{ steps.detect.outputs.docker }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
# Push may contain several commits; github.event.before is then
# more than one parent away. fetch-depth: 2 only has HEAD~1.
fetch-depth: 0
- id: detect
name: Detect changed paths per module
env:
IS_PR: ${{ inputs.is_pull_request }}
BASE_SHA: ${{ inputs.base_sha }}
HEAD_SHA: ${{ inputs.head_sha }}
BEFORE_SHA: ${{ inputs.before_sha }}
run: |
set -euo pipefail
web=false
api=false
docker=false
set_all_flags_true() {
web=true
api=true
docker=true
}
write_outputs() {
for v in web api docker; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
}
has_commit() {
git cat-file -e "${1}^{commit}" 2>/dev/null
}
if [ "$IS_PR" = "true" ]; then
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
else
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
before="$BEFORE_SHA"
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ] && has_commit "$before"; then
FILES="$(git diff --name-only "$before" "$after")"
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
else
set_all_flags_true
write_outputs
echo "No parent commit — full pipeline (all modules)"
exit 0
fi
fi
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
set_all_flags_true
write_outputs
echo "Empty diff — full pipeline fallback"
exit 0
fi
full_pipeline=false
while IFS= read -r f || [ -n "${f:-}" ]; do
[ -z "${f:-}" ] && continue
case "$f" in
.gitea/workflows/*|scripts/*)
full_pipeline=true
;;
.cursor/*|.claude/*|.codegraph/*|.agents/*)
;;
*.md|AGENTS.md)
;;
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
;;
apps/web/*|packages/ui/*)
web=true
;;
apps/api/*|packages/db/*)
api=true
;;
packages/shared/*)
web=true
api=true
;;
deploy/compose/*|deploy/docker/*|.dockerignore|docker-compose.yml)
docker=true
;;
docs/*)
;;
package.json|pnpm-lock.yaml|pnpm-workspace.yaml|turbo.json|.releaserc.json|commitlint.config.cjs)
full_pipeline=true
;;
*)
;;
esac
done <<< "$FILES"
if $full_pipeline; then
set_all_flags_true
fi
write_outputs
echo "Changed files (first 30):"
printf '%s\n' "$FILES" | head -n 30
echo "--- flags ---"
echo "web=$web api=$api docker=$docker full_pipeline=$full_pipeline"
web:
needs: [changes]
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, lint, test, build
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm --filter web lint
pnpm exec turbo run test --filter=web
pnpm exec turbo run build --filter=web
api:
needs: [changes]
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, test, build
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm exec turbo run test --filter=@cfdm/api
pnpm exec turbo run build --filter=@cfdm/api
commitlint:
if: inputs.is_pull_request
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: Lint commit messages
env:
BASE_SHA: ${{ inputs.base_sha }}
HEAD_SHA: ${{ inputs.head_sha }}
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
docker-check:
needs: [changes]
if: inputs.is_pull_request && needs.changes.outputs.docker == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
with:
name: cfdm
driver: docker-container
cleanup: false
- name: Log in to Gitea Registry
if: inputs.allow_registry_login
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: bake --print
working-directory: deploy/docker
env:
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
- name: bake (no push)
if: inputs.allow_registry_login
working-directory: deploy/docker
env:
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
run: |
set -euxo pipefail
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
export CACHE_REF_NODE="git.shx.one/${owner_lc}/cfdm-buildcache:node-buildcache"
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
+7
View File
@@ -32,3 +32,10 @@ Thumbs.db
# Build
version.txt
.release-version
CHANGELOG.md
.codegraph/daemon.pid
# Local MCP configs (may contain REUI license Bearer)
.cursor/mcp.json
.mcp.json
-16
View File
@@ -1,16 +0,0 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp"
]
},
"reui": {
"type": "http",
"url": "https://mcp.reui.io/api/mcp"
}
}
}
+4 -4
View File
@@ -1,17 +1,17 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 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
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 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)
+4 -2
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Public, no key: `c-*` examples and the 20 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 (their key) or https://reui.io/pricing (upgrade).
+95 -17
View File
@@ -1,24 +1,23 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**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 v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
`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 `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
- 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 = useReactTable({
const table = useTable({
features: dataGridFeatures,
data,
columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
})
<DataGrid table={table} recordCount={data.length}>
@@ -28,9 +27,37 @@ const table = useReactTable({
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
- **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 documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
- **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
@@ -79,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
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 filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**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
@@ -284,6 +349,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**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.
## alert
**Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **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).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Free, no key:** the 20 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
+2 -2
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `type`: `"component"` (one of the 20 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`.
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
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 `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
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)).
+50
View File
@@ -0,0 +1,50 @@
{
"branches": ["main", "master"],
"tagFormat": "v${version}",
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits",
"releaseRules": [
{ "type": "feat", "release": "minor" },
{ "type": "fix", "release": "patch" },
{ "type": "perf", "release": "patch" },
{ "type": "ci", "release": "patch" },
{ "type": "refactor", "release": "patch" },
{ "breaking": true, "release": "major" }
]
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md"
}
],
[
"@semantic-release/exec",
{
"successCmd": "echo ${nextRelease.version} > .release-version"
}
],
[
"@markwylde/semantic-release-gitea",
{
"giteaUrl": "https://git.shx.one",
"assets": [
{
"path": "CHANGELOG.md",
"label": "Changelog"
}
]
}
]
]
}
+40
View File
@@ -0,0 +1,40 @@
# Cloudflare Domain Manager — Руководство для ИИ
## Описание
CFDM — self-hosted управление доменами, DNS и SSL через Cloudflare API. Группы доменов, сервисы, health-check, балансировка.
## Стек
- Monorepo: pnpm + turbo (`apps/*`, `packages/*`)
- `apps/web` — Vite + React + TanStack + shadcn **base-nova** + ReUI Frame
- `apps/api` — Fastify + Drizzle + SQLite
- Packages: `@cfdm/ui`, `@cfdm/shared`, `@cfdm/db`
## Команды
```bash
pnpm install
pnpm --filter @cfdm/shared build
pnpm --filter @cfdm/db build
pnpm --filter @cfdm/api dev
pnpm --filter web dev
pnpm --filter web build
```
## ReUI PRO
Surface: **frame** (`apps/web/src/lib/ui-surface.ts` → `UI_SURFACE = 'frame'`).
Hierarchy: **ReUI PRO > shadcn**. Contract: [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
MCP `user-reui` primary + `plugin-shadcn-shadcn` primitives. CLI из `apps/web`.
KPI/QuickActions: EvoBGP hybrid SoT (`KpiStatGrid`, `QuickActionGrid`) + `IconTile` `elevated` `size-10.5`.
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).
Skill: `.claude/skills/reui` (`668fb463eb`).
## Docs
[`docs/Home.md`](docs/Home.md), [`CONTRIBUTING.md`](CONTRIBUTING.md), [`docs/ui-design-contract.md`](docs/ui-design-contract.md), [`docs/releasing.md`](docs/releasing.md).
## CI/CD
Gitea Actions: [`.gitea/workflows`](.gitea/workflows) (`ci.yaml` / `cd.yaml` / `quality.yaml`). Образы — `docker buildx bake` из `deploy/docker` (runtime `node:22-alpine`, теги `cfdm` + `cloudflare-domain-manager`). Подробнее: [`.gitea/README.md`](.gitea/README.md).
+6 -9
View File
@@ -17,23 +17,23 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
## Pull requests
- Target `develop` for features; `main` for release/hotfix
- CI `test` job must pass
- CI quality jobs (`web`, `api`) must pass
- At least one approval
- Rebase/merge when up to date with target branch
- Delete branch after merge
## Versioning
- SemVer in [`VERSION`](VERSION)
- Bump version in `release/*` or `hotfix/*` branches
- Tag `vX.Y.Z` on `main` after release merge
- SemVer через [semantic-release](https://semantic-release.gitbook.io/) на push в `main`
- Не править версию вручную; тег `vX.Y.Z` ставит CI
- Подробнее: [`docs/releasing.md`](docs/releasing.md)
## Branch protection (Gitea)
Configure for **main** and **develop**:
- Require pull request reviews
- Require status checks (`test`)
- Require status checks (`CI` / quality jobs `web`, `api`)
- Require branches to be up to date
- No force push, no deletion
@@ -54,7 +54,4 @@ pnpm --filter web dev
## Release process
1. `git checkout -b release/v0.2.0 develop`
2. Bump `VERSION`, fix release issues
3. PR to `main` → merge → tag `v0.2.0`
4. Merge release branch back to `develop`
Релизы делает workflow **CD** на push в `main` (semantic-release + bake). Не создавать ветки `release/*` и не bump'ать файлы версии вручную. См. [`docs/releasing.md`](docs/releasing.md).
-52
View File
@@ -1,52 +0,0 @@
# syntax=docker/dockerfile:1
ARG NODE_VERSION=22.23.0-bookworm-slim
FROM node:${NODE_VERSION} AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
COPY apps/web/package.json apps/web/
COPY apps/api/package.json apps/api/
COPY packages/ui/package.json packages/ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY apps/web apps/web
COPY apps/api apps/api
COPY packages/ui packages/ui
COPY packages/shared packages/shared
COPY packages/db packages/db
RUN pnpm turbo build --filter=web --filter=@cfdm/api \
&& pnpm --filter @cfdm/api deploy --prod /out \
&& cp -r apps/web/dist /out/static \
&& rm -rf /out/src /out/test /out/.turbo \
&& rm -rf /out/node_modules/@cfdm/db/src /out/node_modules/@cfdm/db/scripts /out/node_modules/@cfdm/db/.turbo \
&& rm -rf /out/node_modules/@cfdm/shared/src /out/node_modules/@cfdm/shared/.turbo \
&& find /out/dist /out/node_modules/@cfdm -type f \( -name '*.d.ts' -o -name '*.map' -o -name 'tsconfig*.json' -o -name 'vitest.config.ts' -o -name 'drizzle.config.ts' \) -delete
FROM node:${NODE_VERSION}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production \
STATIC_DIR=/app/static \
DATABASE_URL=sqlite:/data/app.db \
SERVER_PORT=8080
COPY --from=build /out ./
EXPOSE 8080
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["node", "dist/server.js"]
-28
View File
@@ -1,28 +0,0 @@
# syntax=docker/dockerfile:1
ARG NODE_VERSION=22.23.0-bookworm-slim
FROM node:${NODE_VERSION} AS test
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
COPY apps/web/package.json apps/web/
COPY apps/api/package.json apps/api/
COPY packages/ui/package.json packages/ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY apps/web apps/web
COPY apps/api apps/api
COPY packages/ui packages/ui
COPY packages/shared packages/shared
COPY packages/db packages/db
RUN pnpm turbo build --filter=@cfdm/shared --filter=@cfdm/db --filter=@cfdm/api
CMD ["sh", "-c", "pnpm --filter @cfdm/api test && pnpm --filter web test"]
+2 -2
View File
@@ -28,7 +28,7 @@ Open http://localhost:8080 — default login `admin` / `admin` (dev only).
- **Backend:** Node.js 22, Fastify 5, Drizzle ORM, SQLite (`apps/api`)
- **Shared:** Zod schemas (`packages/shared`), DB layer (`packages/db`)
- **Frontend:** pnpm monorepo (`apps/web` + `packages/ui`), React, Vite, TanStack Router/Query/Table, shadcn/ui (base-nova), Recharts
- **CI:** Gitea Actions (Gitflow)
- **CI:** Gitea Actions ([docs/releasing.md](docs/releasing.md)) — quality gates + semantic-release + bake (`cfdm` / `cloudflare-domain-manager`)
## Development
@@ -53,7 +53,7 @@ shadcn CLI: `cd apps/web && pnpm dlx shadcn@latest add <component>`
## Documentation
See [docs/Home.md](docs/Home.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
See [docs/Home.md](docs/Home.md), [docs/releasing.md](docs/releasing.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
## Load balancing & health checks
-1
View File
@@ -1 +0,0 @@
0.1.0
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup src/server.ts --format esm --dts",
"build": "tsup --config tsup.config.ts",
"start": "node dist/server.js",
"test": "vitest run"
},
+23 -66
View File
@@ -7,7 +7,6 @@ import {
} from "@fastify/type-provider-zod";
import type { AppConfig } from "./config.js";
import { loadConfig } from "./config.js";
import { repos } from "@cfdm/db";
import authPlugin from "./plugins/auth.js";
import cfClientPlugin from "./plugins/cf-client.js";
import { requireAuth } from "./plugins/auth.js";
@@ -24,6 +23,7 @@ import { dnsRoutes } from "./routes/dns.js";
import { subdomainRoutes } from "./routes/subdomains.js";
import { certificateRoutes } from "./routes/certificates.js";
import { syncRoutes } from "./routes/sync.js";
import { originHealthCheckRoutes } from "./routes/origin-health-checks.js";
import { healthCheckRoutes } from "./routes/health-check.js";
import {
domainMonitorRoutes,
@@ -31,9 +31,14 @@ import {
} from "./routes/domain-monitors.js";
import { settingsRoutes } from "./routes/settings.js";
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
import { auditRoutes } from "./routes/audit.js";
import * as certificateService from "./services/certificate-service.js";
import * as healthCheckService from "./services/health-check-service.js";
import * as serviceConfigService from "./services/service-config-service.js";
import {
createHealthCheckTask,
healthEngineFallbacksFromConfig,
scheduleHealthCheckJob,
} from "./services/health-check-scheduler.js";
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
import { AsyncTask, CronJob } from "toad-scheduler";
export interface BuildAppOptions {
@@ -80,9 +85,11 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await protectedApi.register(certificateRoutes);
await protectedApi.register(syncRoutes);
await protectedApi.register(healthCheckRoutes);
await protectedApi.register(originHealthCheckRoutes);
await protectedApi.register(domainMonitorRoutes);
await protectedApi.register(notificationRoutes);
await protectedApi.register(settingsRoutes);
await protectedApi.register(auditRoutes);
},
{ prefix: "/api/v1" },
);
@@ -121,69 +128,19 @@ export async function buildApp(opts: BuildAppOptions = {}) {
),
);
const healthTask = new AsyncTask(
"health-check",
async () => {
const thresholds = {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
};
const n = await healthCheckService.runAllChecks(app.db, {
thresholds,
onStatusChange: async (target, prev, next) => {
try {
const label =
next === "up"
? "OK"
: next === "degraded"
? "Slow"
: next === "down"
? "Down"
: "—";
repos.insertNotificationLog(
app.db,
"ip_health",
target.scope,
target.ref_id,
`${target.hostname || target.ip}: ${label}`,
`IP ${target.ip}: ${prev ?? "—"} → ${label}`,
);
await serviceConfigService.reconcileDnsForTarget(
app.db,
app.cf,
target.scope,
target.ref_id,
);
} catch (err) {
app.log.warn(
{ err, scope: target.scope, refId: target.ref_id },
"health-check reconcile failed",
);
}
},
});
const monitors = await healthCheckService.runDomainMonitors(
app.db,
thresholds,
);
app.log.info(
{ checked: n, monitors },
"health check completed",
);
},
(err) => {
app.log.warn({ err }, "health check failed");
},
);
app.scheduler.addCronJob(
new CronJob(
{ cronExpression: config.healthCheckCron },
healthTask,
{ preventOverrun: true },
),
);
const healthTask = createHealthCheckTask(app, config);
scheduleHealthCheckJob(app, config, healthTask);
app.decorate("reloadHealthCheckJob", () => {
scheduleHealthCheckJob(app, config, healthTask);
});
if (config.cloudflareApiToken) {
fireEnsureHealthWorker(
app.db,
app.cf,
healthEngineFallbacksFromConfig(config),
app.log,
);
}
}
return app;
+41 -2
View File
@@ -13,15 +13,37 @@ export interface AppConfig {
healthCheckCron: string;
healthDegradedFailures: number;
healthDownFailures: number;
healthSuccessRecoveries: number;
healthLatencyWarnMs: number;
/** Min pause between probes to different physical targets (same IP is probed once). */
healthProbeGapMs: number;
healthWorkerUrl: string;
healthWorkerToken: string;
logLevel: string;
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
authRequired: boolean;
authIssuer: string;
authPortalUrl: string;
/** Bearer secret for POST {authPortalUrl}/api/v1/ingest/audit */
authAuditIngestSecret: string | null;
}
function boolEnv(v: string | undefined, fallback: boolean): boolean {
if (v === undefined || v === "") return fallback;
return v === "1" || v.toLowerCase() === "true";
}
export function loadConfig(): AppConfig {
const isProd = process.env.NODE_ENV === "production";
const jwtSecret =
process.env.AUTH_JWT_SECRET ??
process.env.JWT_SECRET ??
(isProd ? "" : "dev-secret-change-me");
return {
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(),
jwtSecret: process.env.JWT_SECRET ?? "dev-secret-change-me",
jwtSecret: jwtSecret || "dev-secret-change-me",
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
adminPasswordHash:
@@ -31,12 +53,29 @@ export function loadConfig(): AppConfig {
? resolve(process.env.STATIC_DIR)
: null,
certCheckCron: process.env.CERT_CHECK_CRON ?? "0 0 */6 * * *",
healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "*/30 * * * * *",
// Default: every 2 minutes (was every 30s — hammered origins / anti-bot).
healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "0 */2 * * * *",
healthDegradedFailures:
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
healthSuccessRecoveries:
Number(process.env.HEALTH_SUCCESS_RECOVERIES ?? "2") || 2,
healthLatencyWarnMs:
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2000,
healthWorkerUrl: (process.env.HEALTH_WORKER_URL ?? "").trim(),
healthWorkerToken: (process.env.HEALTH_WORKER_TOKEN ?? "").trim(),
logLevel: process.env.LOG_LEVEL ?? "info",
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
authIssuer:
process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top",
authPortalUrl: (
process.env.AUTH_PORTAL_URL ??
process.env.VITE_AUTH_PORTAL_URL ??
"http://localhost:5175"
).replace(/\/$/, ""),
authAuditIngestSecret:
process.env.AUTH_AUDIT_INGEST_SECRET?.trim() ||
(!isProd ? "dev-audit-ingest-secret" : null),
};
}
+46 -2
View File
@@ -8,6 +8,14 @@ export type ErrorCode =
| "FORBIDDEN"
| "CONFLICT"
| "CLOUDFLARE_ERROR"
| "DNS_UPDATE_FAILED"
| "HEALTHCHECK_CREATE_FAILED"
| "ZONE_NOT_FOUND"
| "INVALID_IP"
| "INVALID_HOSTNAME"
| "RATE_LIMITED"
| "CLOUDFLARE_AUTH_FAILED"
| "SYNC_FAILED"
| "INTERNAL_ERROR";
export class AppError extends Error {
@@ -32,8 +40,8 @@ export class AppError extends Error {
return new AppError("UNAUTHORIZED", "unauthorized", 401);
}
static forbidden() {
return new AppError("FORBIDDEN", "forbidden", 403);
static forbidden(message = "forbidden") {
return new AppError("FORBIDDEN", message, 403);
}
static conflict(message: string) {
@@ -44,6 +52,42 @@ export class AppError extends Error {
return new AppError("CLOUDFLARE_ERROR", message, 502);
}
static dnsUpdateFailed(message: string) {
return new AppError(
"DNS_UPDATE_FAILED",
message,
502,
);
}
static healthcheckCreateFailed(message: string) {
return new AppError("HEALTHCHECK_CREATE_FAILED", message, 502);
}
static zoneNotFound(message = "зона Cloudflare не найдена") {
return new AppError("ZONE_NOT_FOUND", message, 404);
}
static invalidIp(message = "Некорректный IP-адрес") {
return new AppError("INVALID_IP", message, 400);
}
static invalidHostname(message = "Некорректное имя хоста") {
return new AppError("INVALID_HOSTNAME", message, 400);
}
static rateLimited(message = "Cloudflare временно ограничил запросы. Повторите попытку.") {
return new AppError("RATE_LIMITED", message, 429);
}
static cloudflareAuthFailed(message = "Cloudflare отклонил токен доступа") {
return new AppError("CLOUDFLARE_AUTH_FAILED", message, 401);
}
static syncFailed(message: string) {
return new AppError("SYNC_FAILED", message, 502);
}
static internal(message: string) {
return new AppError("INTERNAL_ERROR", message, 500);
}
+101
View File
@@ -0,0 +1,101 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyRequest } from "fastify";
import { appendAudit, type AppendAuditInput } from "@cfdm/db";
import { pushAuditEvents } from "../services/audit-portal-push.js";
export function clientIp(request: FastifyRequest): string | null {
const forwarded = request.headers["x-forwarded-for"];
if (typeof forwarded === "string" && forwarded.trim()) {
return forwarded.split(",")[0]?.trim() ?? null;
}
return request.ip ?? null;
}
export function actorFromRequest(
request: FastifyRequest,
): Pick<AppendAuditInput, "actorUserId" | "actorEmail" | "actorName"> {
const u = request.authUser;
if (u) {
return {
actorUserId: u.id,
actorEmail: u.email || null,
actorName: u.name || null,
};
}
const payload = request.user;
if (payload?.sub) {
return {
actorUserId: String(payload.sub),
actorEmail: payload.email ? String(payload.email) : null,
actorName: payload.name ? String(payload.name) : null,
};
}
return {
actorUserId: null,
actorEmail: null,
actorName: null,
};
}
type RecordAuditInput = Omit<
AppendAuditInput,
| "sourceApp"
| "eventId"
| "ip"
| "actorUserId"
| "actorEmail"
| "actorName"
>;
/** Local append + optional async push to auth-portal ingest. */
export function recordAudit(
app: FastifyInstance,
request: FastifyRequest,
input: RecordAuditInput,
): void {
const eventId = randomUUID();
const actor = actorFromRequest(request);
const ip = clientIp(request);
const createdAt = input.createdAt ?? new Date().toISOString();
const full: AppendAuditInput = {
...input,
...actor,
eventId,
sourceApp: "cfdm",
ip,
createdAt,
};
try {
appendAudit(app.db, full);
} catch (err) {
app.log.warn({ err }, "audit_log append failed");
}
const secret = app.config.authAuditIngestSecret;
const portalUrl = app.config.authPortalUrl;
if (!secret || !portalUrl) return;
void pushAuditEvents(portalUrl, secret, [
{
event_id: eventId,
source_app: "cfdm",
action: input.action,
severity: input.severity,
actor_user_id: actor.actorUserId,
actor_email: actor.actorEmail,
actor_name: actor.actorName,
target_type: input.targetType,
target_id: input.targetId,
summary: input.summary,
details: input.details,
ip,
created_at: createdAt,
},
]).catch((err) => {
app.log.warn({ err, eventId }, "audit portal push failed");
});
}
+110 -125
View File
@@ -1,152 +1,137 @@
import type {
CfDnsRecord,
CfHealthCheck,
CfZone,
CreateDnsRecordPayload,
PatchDnsRecordPayload,
} from "@cfdm/shared";
import { AppError } from "../errors.js";
import { withRetry, parseRetryAfter } from "./cf-retry.js";
import { createDnsAdapter } from "./cloudflare/dns-service.js";
import { createHealthCheckAdapter, type CfHealthCheckPayload } from "./cloudflare/healthcheck-service.js";
import { createKvAdapter } from "./cloudflare/kv-service.js";
import { createWorkersAdapter } from "./cloudflare/workers-service.js";
import { createZoneAdapter } from "./cloudflare/zone-service.js";
const BASE_URL = "https://api.cloudflare.com/client/v4";
interface CfResponse<T> {
success: boolean;
result?: T;
errors?: Array<{ code: number; message: string }>;
}
export type { CfHealthCheckPayload };
export class CloudflareClient {
constructor(private readonly token: string) {}
private readonly zones;
private readonly dns;
private readonly healthchecks;
private readonly kv;
private readonly workers;
private readonly token;
private async handleResponse<T>(
response: Response,
operation: string,
): Promise<T> {
if (response.status === 429) {
const wait = parseRetryAfter(response.headers) ?? 5000;
throw AppError.cloudflare(`rate limited, retry after ${wait}ms`);
}
const body = (await response.json()) as CfResponse<T>;
if (!body.success) {
const msg =
body.errors?.map((e) => e.message).join("; ") ??
"unknown cloudflare error";
throw AppError.cloudflare(`${operation}: ${msg}`);
}
if (body.result === undefined) {
throw AppError.cloudflare(`${operation}: empty result`);
}
return body.result;
constructor(token: string) {
this.token = token.trim();
this.zones = createZoneAdapter(token);
this.dns = createDnsAdapter(token);
this.healthchecks = createHealthCheckAdapter(token);
this.kv = createKvAdapter(token);
this.workers = createWorkersAdapter(token);
}
async listZones(): Promise<CfZone[]> {
return withRetry(async () => {
const all: CfZone[] = [];
let page = 1;
while (true) {
const url = new URL(`${BASE_URL}/zones`);
url.searchParams.set("per_page", "50");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw AppError.cloudflare(String(response.status));
}
const batch = await this.handleResponse<CfZone[]>(
response,
"list_zones",
);
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
});
get isConfigured(): boolean {
return this.token.length > 0;
}
async getZone(zoneId: string): Promise<CfZone> {
const response = await fetch(`${BASE_URL}/zones/${zoneId}`, {
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
});
return this.handleResponse(response, "get_zone");
listZones(): Promise<CfZone[]> {
return this.zones.listZones();
}
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return withRetry(async () => {
const all: CfDnsRecord[] = [];
let page = 1;
while (page <= 50) {
const url = new URL(`${BASE_URL}/zones/${zoneId}/dns_records`);
url.searchParams.set("per_page", "100");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw AppError.cloudflare(String(response.status));
}
const batch = await this.handleResponse<CfDnsRecord[]>(
response,
"list_dns_records",
);
if (batch.length === 0) break;
all.push(...batch);
page += 1;
}
return all;
});
getZone(zoneId: string): Promise<CfZone> {
return this.zones.getZone(zoneId);
}
async createDnsRecord(
zoneId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(`${BASE_URL}/zones/${zoneId}/dns_records`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
});
return this.handleResponse(response, "create_dns_record");
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return this.dns.listDnsRecords(zoneId);
}
async updateDnsRecord(
createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise<CfDnsRecord> {
return this.dns.createDnsRecord(zoneId, payload);
}
updateDnsRecord(
zoneId: string,
recordId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return this.handleResponse(response, "update_dns_record");
return this.dns.updateDnsRecord(zoneId, recordId, payload);
}
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
const response = await fetch(
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
},
);
await this.handleResponse(response, "delete_dns_record");
patchDnsRecord(
zoneId: string,
recordId: string,
payload: PatchDnsRecordPayload,
): Promise<CfDnsRecord> {
return this.dns.patchDnsRecord(zoneId, recordId, payload);
}
deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
return this.dns.deleteDnsRecord(zoneId, recordId);
}
listHealthChecks(zoneId: string): Promise<CfHealthCheck[]> {
return this.healthchecks.listHealthChecks(zoneId);
}
getHealthCheck(zoneId: string, id: string): Promise<CfHealthCheck> {
return this.healthchecks.getHealthCheck(zoneId, id);
}
createHealthCheck(zoneId: string, payload: CfHealthCheckPayload): Promise<CfHealthCheck> {
return this.healthchecks.createHealthCheck(zoneId, payload);
}
updateHealthCheck(
zoneId: string,
id: string,
payload: CfHealthCheckPayload,
): Promise<CfHealthCheck> {
return this.healthchecks.updateHealthCheck(zoneId, id, payload);
}
deleteHealthCheck(zoneId: string, id: string): Promise<void> {
return this.healthchecks.deleteHealthCheck(zoneId, id);
}
listAccounts() {
return this.workers.listAccounts();
}
listKvNamespaces(accountId: string) {
return this.kv.listNamespaces(accountId);
}
createKvNamespace(accountId: string, title: string) {
return this.kv.createNamespace(accountId, title);
}
kvGet(accountId: string, namespaceId: string, key: string) {
return this.kv.getValue(accountId, namespaceId, key);
}
kvPut(accountId: string, namespaceId: string, key: string, value: string) {
return this.kv.putValue(accountId, namespaceId, key, value);
}
putWorkerScript(opts: {
accountId: string;
scriptName: string;
source: string;
kvNamespaceId: string;
}) {
return this.workers.putScript(opts);
}
putWorkerSchedules(accountId: string, scriptName: string, crons: string[]) {
return this.workers.putSchedules(accountId, scriptName, crons);
}
enableWorkersDev(accountId: string, scriptName: string) {
return this.workers.enableWorkersDev(accountId, scriptName);
}
getWorkersSubdomain(accountId: string) {
return this.workers.getWorkersSubdomain(accountId);
}
}
+106
View File
@@ -0,0 +1,106 @@
import type { CfDnsRecord, CreateDnsRecordPayload, PatchDnsRecordPayload } from "@cfdm/shared";
import { withRetry } from "../cf-retry.js";
import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
export function createDnsAdapter(token: string) {
return {
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return withRetry(async () => {
const all: CfDnsRecord[] = [];
let page = 1;
while (page <= 50) {
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
url.searchParams.set("per_page", "100");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure(
"list_dns_records",
response.status,
String(response.status),
);
}
const batch = await handleCfResponse<CfDnsRecord[]>(
response,
"list_dns_records",
);
if (batch.length === 0) break;
all.push(...batch);
page += 1;
}
return all;
});
},
async createDnsRecord(
zoneId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}/dns_records`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
});
return handleCfResponse(response, "create_dns_record");
},
async updateDnsRecord(
zoneId: string,
recordId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "update_dns_record");
},
async patchDnsRecord(
zoneId: string,
recordId: string,
payload: PatchDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "patch_dns_record");
},
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
await handleCfResponse(response, "delete_dns_record");
},
};
}
@@ -0,0 +1,110 @@
import type { CfHealthCheck } from "@cfdm/shared";
import { CF_API_BASE, handleCfResponse } from "./http.js";
export interface CfHealthCheckHttpConfig {
method?: string;
path?: string;
expected_codes?: string[];
header?: Record<string, string[]>;
port?: number;
follow_redirects?: boolean;
allow_insecure?: boolean;
}
export interface CfHealthCheckTcpConfig {
method?: "connection_established";
port?: number;
}
export interface CfHealthCheckPayload {
address: string;
name: string;
type?: "HTTP" | "HTTPS" | "TCP";
description?: string;
interval?: number;
timeout?: number;
retries?: number;
consecutive_fails?: number;
consecutive_successes?: number;
suspended?: boolean;
check_regions?: string[];
http_config?: CfHealthCheckHttpConfig;
tcp_config?: CfHealthCheckTcpConfig;
}
export function createHealthCheckAdapter(token: string) {
return {
async listHealthChecks(zoneId: string): Promise<CfHealthCheck[]> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/healthchecks`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "list_healthchecks");
},
async getHealthCheck(zoneId: string, id: string): Promise<CfHealthCheck> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "get_healthcheck");
},
async createHealthCheck(
zoneId: string,
payload: CfHealthCheckPayload,
): Promise<CfHealthCheck> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/healthchecks`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "create_healthcheck");
},
async updateHealthCheck(
zoneId: string,
id: string,
payload: CfHealthCheckPayload,
): Promise<CfHealthCheck> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "update_healthcheck");
},
async deleteHealthCheck(zoneId: string, id: string): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
await handleCfResponse(response, "delete_healthcheck");
},
};
}
+134
View File
@@ -0,0 +1,134 @@
import type { CfDnsRecord } from "@cfdm/shared";
import { AppError } from "../../errors.js";
import { parseRetryAfter } from "../cf-retry.js";
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
export interface CfResponse<T> {
success: boolean;
result?: T;
errors?: Array<{ code: number; message: string }>;
}
export function mapCloudflareFailure(
operation: string,
status: number,
message: string,
): AppError {
const lower = message.toLowerCase();
if (status === 401 || status === 403 || lower.includes("authentication")) {
if (
operation.includes("workers") ||
operation.includes("kv_") ||
operation.includes("accounts")
) {
return AppError.cloudflareAuthFailed(
"Токену нужны права Account: Workers Scripts Write и Workers KV Storage Write. Zone DNS недостаточно.",
);
}
return AppError.cloudflareAuthFailed(
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
);
}
if (status === 429 || lower.includes("rate limit")) {
return AppError.rateLimited();
}
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
return AppError.zoneNotFound();
}
if (
operation.includes("healthcheck") &&
(lower.includes("plan") ||
lower.includes("not entitled") ||
lower.includes("not allowed") ||
lower.includes("permission"))
) {
return AppError.healthcheckCreateFailed(
"Cloudflare Health Checks недоступны для этой зоны. Используйте локальные проверки.",
);
}
if (operation.includes("dns") || operation.includes("dns_record")) {
return AppError.dnsUpdateFailed(`Не удалось обновить DNS в Cloudflare: ${message}`);
}
return AppError.cloudflare(`${operation}: ${message}`);
}
export async function handleCfResponse<T>(
response: Response,
operation: string,
): Promise<T> {
if (response.status === 429) {
const wait = parseRetryAfter(response.headers) ?? 5000;
throw AppError.rateLimited(
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
);
}
const body = (await response.json()) as CfResponse<T>;
if (!body.success) {
const msg =
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
throw mapCloudflareFailure(operation, response.status, msg);
}
if (body.result === undefined) {
throw mapCloudflareFailure(operation, response.status, "empty result");
}
return body.result;
}
/** KV PUT / schedules often return `{ success: true }` without `result`. */
export async function handleCfSuccess(
response: Response,
operation: string,
): Promise<void> {
if (response.status === 429) {
const wait = parseRetryAfter(response.headers) ?? 5000;
throw AppError.rateLimited(
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
);
}
const text = await response.text();
if (!text) {
if (!response.ok) {
throw mapCloudflareFailure(operation, response.status, String(response.status));
}
return;
}
let body: CfResponse<unknown>;
try {
body = JSON.parse(text) as CfResponse<unknown>;
} catch {
if (!response.ok) {
throw mapCloudflareFailure(operation, response.status, text.slice(0, 180));
}
return;
}
if (!body.success) {
const msg =
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
throw mapCloudflareFailure(operation, response.status, msg);
}
}
export async function cfRequest<T>(
token: string,
path: string,
operation: string,
init: RequestInit = {},
): Promise<T> {
const response = await fetch(`${CF_API_BASE}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
signal: init.signal ?? AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure(operation, response.status, String(response.status));
}
return handleCfResponse<T>(response, operation);
}
export type DnsRecordResult = CfDnsRecord;
+100
View File
@@ -0,0 +1,100 @@
import { CF_API_BASE, handleCfResponse, handleCfSuccess, mapCloudflareFailure } from "./http.js";
export interface CfKvNamespace {
id: string;
title: string;
}
export function createKvAdapter(token: string) {
return {
async listNamespaces(accountId: string): Promise<CfKvNamespace[]> {
const all: CfKvNamespace[] = [];
let page = 1;
while (true) {
const url = new URL(
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
);
url.searchParams.set("per_page", "100");
url.searchParams.set("page", String(page));
const response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("kv_list", response.status, String(response.status));
}
const batch = await handleCfResponse<CfKvNamespace[]>(response, "kv_list");
all.push(...batch);
if (batch.length < 100) break;
page += 1;
}
return all;
},
async createNamespace(accountId: string, title: string): Promise<CfKvNamespace> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ title }),
signal: AbortSignal.timeout(30_000),
},
);
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("kv_create", response.status, String(response.status));
}
return handleCfResponse<CfKvNamespace>(response, "kv_create");
},
async getValue(
accountId: string,
namespaceId: string,
key: string,
): Promise<string | null> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
if (response.status === 404) return null;
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("kv_get", response.status, String(response.status));
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw mapCloudflareFailure("kv_get", response.status, text.slice(0, 180));
}
return response.text();
},
async putValue(
accountId: string,
namespaceId: string,
key: string,
value: string,
): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "text/plain",
},
body: value,
signal: AbortSignal.timeout(30_000),
},
);
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("kv_put", response.status, String(response.status));
}
await handleCfSuccess(response, "kv_put");
},
};
}
@@ -0,0 +1,142 @@
import {
CF_API_BASE,
handleCfResponse,
handleCfSuccess,
mapCloudflareFailure,
} from "./http.js";
export interface CfAccount {
id: string;
name?: string;
}
export interface CfWorkersSubdomain {
subdomain?: string;
enabled?: boolean;
}
export function createWorkersAdapter(token: string) {
return {
async listAccounts(): Promise<CfAccount[]> {
const response = await fetch(`${CF_API_BASE}/accounts?per_page=50`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("list_accounts", response.status, String(response.status));
}
return handleCfResponse<CfAccount[]>(response, "list_accounts");
},
async putScript(opts: {
accountId: string;
scriptName: string;
source: string;
kvNamespaceId: string;
filename?: string;
}): Promise<void> {
const filename = opts.filename ?? "index.mjs";
const metadata = {
main_module: filename,
compatibility_date: "2025-04-01",
bindings: [
{
type: "kv_namespace",
name: "HEALTH_KV",
namespace_id: opts.kvNamespaceId,
},
],
};
const form = new FormData();
form.append(
"metadata",
new Blob([JSON.stringify(metadata)], { type: "application/json" }),
);
form.append(
filename,
new Blob([opts.source], { type: "application/javascript+module" }),
filename,
);
const response = await fetch(
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
{
method: "PUT",
headers: { Authorization: `Bearer ${token}` },
body: form,
signal: AbortSignal.timeout(60_000),
},
);
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("workers_put_script", response.status, String(response.status));
}
await handleCfSuccess(response, "workers_put_script");
},
async putSchedules(
accountId: string,
scriptName: string,
crons: string[],
): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/schedules`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(crons.map((cron) => ({ cron }))),
signal: AbortSignal.timeout(30_000),
},
);
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("workers_put_schedules", response.status, String(response.status));
}
await handleCfSuccess(response, "workers_put_schedules");
},
async enableWorkersDev(
accountId: string,
scriptName: string,
): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled: true }),
signal: AbortSignal.timeout(30_000),
},
);
if (response.status === 409) return;
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("workers_subdomain", response.status, String(response.status));
}
if (!response.ok && response.status !== 200 && response.status !== 201) {
await handleCfSuccess(response, "workers_subdomain");
}
},
async getWorkersSubdomain(accountId: string): Promise<string | null> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/workers/subdomain`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
if (response.status === 404) return null;
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("workers_get_subdomain", response.status, String(response.status));
}
const result = await handleCfResponse<CfWorkersSubdomain>(
response,
"workers_get_subdomain",
);
return result.subdomain?.trim() || null;
},
};
}
@@ -0,0 +1,40 @@
import type { CfZone } from "@cfdm/shared";
import { withRetry } from "../cf-retry.js";
import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
export function createZoneAdapter(token: string) {
return {
async listZones(): Promise<CfZone[]> {
return withRetry(async () => {
const all: CfZone[] = [];
let page = 1;
while (true) {
const url = new URL(`${CF_API_BASE}/zones`);
url.searchParams.set("per_page", "50");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
}
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
});
},
async getZone(zoneId: string): Promise<CfZone> {
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
return handleCfResponse(response, "get_zone");
},
};
}
+277
View File
@@ -0,0 +1,277 @@
import type { HealthCheckTarget } from "@cfdm/shared";
import {
clampGlobalpingLimit,
parseGlobalpingLocations,
} from "@cfdm/shared";
export const GLOBALPING_API_ROOT = "https://api.globalping.io";
export const GLOBALPING_MIN_POLL_MS = 500;
export const GLOBALPING_UA = "CFDM-health/1.0";
export interface GlobalpingClientOptions {
token?: string | null;
locations?: string;
limit?: number;
pollIntervalMs?: number;
maxWaitMs?: number;
fetchImpl?: typeof fetch;
}
export interface GlobalpingProbeResult {
ok: boolean;
latencyMs: number;
error: string | null;
colo: string | null;
}
interface MeasurementCreateBody {
type: "ping" | "http";
target: string;
inProgressUpdates: false;
limit: number;
locations: Array<{ magic: string }>;
measurementOptions: Record<string, unknown>;
}
interface MeasurementProbe {
continent?: string;
country?: string;
city?: string;
network?: string;
}
interface MeasurementResultRow {
probe?: MeasurementProbe;
result?: {
status?: string;
statusCode?: number;
timings?: { total?: number };
stats?: { avg?: number; loss?: number };
};
}
interface MeasurementResponse {
id?: string;
status?: string;
results?: MeasurementResultRow[];
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function locationLabel(probe: MeasurementProbe | undefined): string | null {
if (!probe) return null;
const city = probe.city?.trim();
const country = probe.country?.trim();
if (city && country) return `${city}, ${country}`;
return city || country || null;
}
export function buildMeasurementBody(
target: HealthCheckTarget,
options: GlobalpingClientOptions,
): MeasurementCreateBody {
const limit = clampGlobalpingLimit(options.limit, 3);
const locations = parseGlobalpingLocations(options.locations).map((magic) => ({
magic,
}));
const port = target.port ?? (target.type === "http" ? 80 : 80);
const ip = String(target.ip || "").trim();
const hostname = (target.hostname || ip).trim();
if (target.type === "http") {
const path = target.path?.trim() || "/";
const protocol = port === 443 ? "HTTPS" : "HTTP";
return {
type: "http",
target: ip,
inProgressUpdates: false,
limit,
locations,
measurementOptions: {
protocol,
port,
request: {
method: "GET",
host: hostname,
path: path.startsWith("/") ? path : `/${path}`,
},
},
};
}
return {
type: "ping",
target: ip,
inProgressUpdates: false,
limit,
locations,
measurementOptions: {
protocol: "TCP",
port,
},
};
}
function rowOk(target: HealthCheckTarget, row: MeasurementResultRow): boolean {
const result = row.result;
if (!result) return false;
const status = String(result.status ?? "").toLowerCase();
if (status && status !== "finished") return false;
if (target.type === "http") {
const code = result.statusCode;
if (code == null) return false;
if (target.expected_status != null) return code === target.expected_status;
return code >= 200 && code < 400;
}
const loss = result.stats?.loss;
if (loss != null && loss >= 100) return false;
return status === "finished" || status === "";
}
function rowLatency(row: MeasurementResultRow): number {
const total = row.result?.timings?.total;
if (typeof total === "number" && Number.isFinite(total)) return Math.round(total);
const avg = row.result?.stats?.avg;
if (typeof avg === "number" && Number.isFinite(avg)) return Math.round(avg);
return 0;
}
export function summarizeMeasurement(
target: HealthCheckTarget,
doc: MeasurementResponse,
): GlobalpingProbeResult {
const rows = doc.results ?? [];
if (rows.length === 0) {
return {
ok: false,
latencyMs: 0,
error: "Globalping: пустой результат",
colo: null,
};
}
const oks = rows.map((row) => rowOk(target, row));
const okCount = oks.filter(Boolean).length;
const ok = okCount > rows.length / 2;
const latencies = rows.map(rowLatency);
const latencyMs = Math.round(
latencies.reduce((sum, n) => sum + n, 0) / latencies.length,
);
const colo =
locationLabel(rows.find((_, i) => oks[i])?.probe) ??
locationLabel(rows[0]?.probe);
if (ok) {
return { ok: true, latencyMs, error: null, colo };
}
const expected =
target.type === "http" && target.expected_status != null
? `ожидали HTTP ${target.expected_status}`
: target.type === "http"
? "ожидали HTTP 2xx/3xx"
: "TCP ping с packet loss < 100%";
return {
ok: false,
latencyMs,
error: `Globalping: ${okCount}/${rows.length} проб успешны (${expected})`,
colo,
};
}
async function parseJson(response: Response): Promise<MeasurementResponse> {
try {
return (await response.json()) as MeasurementResponse;
} catch {
return {};
}
}
export async function runGlobalpingMeasurement(
target: HealthCheckTarget,
options: GlobalpingClientOptions = {},
): Promise<GlobalpingProbeResult> {
const fetchImpl = options.fetchImpl ?? fetch;
const pollMs =
options.pollIntervalMs === undefined
? GLOBALPING_MIN_POLL_MS
: Math.max(0, options.pollIntervalMs);
const maxWaitMs = options.maxWaitMs ?? Math.max(target.timeout_ms ?? 3000, 3000) + 15_000;
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": GLOBALPING_UA,
};
const token = options.token?.trim();
if (token) headers.Authorization = `Bearer ${token}`;
const created = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements`, {
method: "POST",
headers,
body: JSON.stringify(buildMeasurementBody(target, options)),
});
if (created.status === 429) {
return {
ok: false,
latencyMs: 0,
error: "Globalping: 429 rate limit",
colo: null,
};
}
if (created.status !== 202 && created.status !== 200) {
const body = await parseJson(created);
return {
ok: false,
latencyMs: 0,
error: `Globalping: HTTP ${created.status}${body.status ? ` (${body.status})` : ""}`,
colo: null,
};
}
const createdBody = await parseJson(created);
const id = createdBody.id?.trim();
if (!id) {
return {
ok: false,
latencyMs: 0,
error: "Globalping: нет id измерения",
colo: null,
};
}
const started = Date.now();
while (Date.now() - started < maxWaitMs) {
await sleep(pollMs);
const polled = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements/${id}`, {
method: "GET",
headers: {
Accept: "application/json",
"User-Agent": GLOBALPING_UA,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (polled.status === 429) {
return {
ok: false,
latencyMs: 0,
error: "Globalping: 429 rate limit",
colo: null,
};
}
if (!polled.ok) {
return {
ok: false,
latencyMs: 0,
error: `Globalping: HTTP ${polled.status} при опросе`,
colo: null,
};
}
const doc = await parseJson(polled);
if (String(doc.status ?? "").toLowerCase() === "in-progress") continue;
return summarizeMeasurement(target, doc);
}
return {
ok: false,
latencyMs: Date.now() - started,
error: "Globalping: timeout ожидания measurement",
colo: null,
};
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
* Format: cfdm:<section>:<read|write|admin>
*/
export type AuthUser = {
id: string;
email: string;
name: string;
apps: string[];
permissions: string[];
isAdmin?: boolean;
};
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true;
const parts = required.split(":");
if (parts.length !== 3) return false;
const [app, section, action] = parts;
if (action === "read") {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
);
}
if (action === "write") {
return granted.includes(`${app}:${section}:admin`);
}
return false;
}
type Rule = {
methods: string[];
match: (path: string) => boolean;
permission: string;
};
const RULES: Rule[] = [
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/v1/domains") ||
p.startsWith("/api/v1/domain-monitors") ||
p === "/api/v1/domain-monitors",
permission: "cfdm:domains:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/v1/domains") ||
p.startsWith("/api/v1/domain-monitors"),
permission: "cfdm:domains:write",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"),
permission: "cfdm:dns:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"),
permission: "cfdm:dns:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/v1/certificates"),
permission: "cfdm:certificates:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/v1/certificates"),
permission: "cfdm:certificates:write",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/v1/groups") ||
p.startsWith("/api/v1/service-groups"),
permission: "cfdm:groups:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/v1/groups") ||
p.startsWith("/api/v1/service-groups"),
permission: "cfdm:groups:write",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/v1/services") ||
p.startsWith("/api/v1/service-bindings") ||
p.startsWith("/api/v1/health-checks") ||
p === "/api/v1/ops-summary",
permission: "cfdm:services:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/v1/services") ||
p.startsWith("/api/v1/service-bindings") ||
p.startsWith("/api/v1/health-checks"),
permission: "cfdm:services:write",
},
{
methods: ["GET", "POST"],
match: (p) => p.startsWith("/api/v1/sync"),
permission: "cfdm:domains:write",
},
{
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/v1/settings") ||
p.startsWith("/api/v1/notifications") ||
p.startsWith("/api/v1/health-check"),
permission: "cfdm:settings:admin",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/v1/audit"),
permission: "cfdm:settings:admin",
},
];
/** Resolve required permission for method+path, or null if public / unknown. */
export function permissionForRequest(
method: string,
path: string,
): string | null {
const m = method.toUpperCase();
const pathname = path.split("?")[0] ?? path;
for (const rule of RULES) {
if (!rule.methods.includes(m)) continue;
if (rule.match(pathname)) return rule.permission;
}
// Default: any authenticated cfdm user for unmatched /api/v1/*
if (pathname.startsWith("/api/v1/")) return "cfdm:domains:read";
return null;
}
+101 -3
View File
@@ -1,28 +1,126 @@
import type { FastifyInstance, FastifyRequest } from "fastify";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import fp from "fastify-plugin";
import type { AppConfig } from "../config.js";
import { AppError } from "../errors.js";
import {
hasPermission,
permissionForRequest,
type AuthUser,
} from "../lib/permissions.js";
declare module "fastify" {
interface FastifyRequest {
authUser?: AuthUser;
}
}
declare module "@fastify/jwt" {
interface FastifyJWT {
payload: {
sub: string;
email?: string;
name?: string;
apps?: string[];
permissions?: string[];
is_admin?: boolean;
iss?: string;
exp?: number;
};
user: {
sub: string;
email?: string;
name?: string;
apps?: string[];
permissions?: string[];
is_admin?: boolean;
iss?: string;
exp?: number;
};
}
}
async function authPlugin(
app: FastifyInstance,
opts: { config: AppConfig },
) {
const { config } = opts;
if (config.authRequired && (!config.jwtSecret || config.jwtSecret.length < 8)) {
throw new Error(
"AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true",
);
}
await app.register(import("@fastify/jwt"), {
secret: opts.config.jwtSecret,
secret: config.jwtSecret,
...(config.authRequired
? {
verify: {
allowedIss: [config.authIssuer],
},
}
: {}),
});
if (config.authRequired) {
app.log.info(
{ issuer: config.authIssuer, portal: config.authPortalUrl },
"AUTH_REQUIRED=true — portal JWT middleware enabled",
);
} else {
app.log.info("AUTH_REQUIRED=false — local JWT / open protected routes with requireAuth");
}
}
export async function requireAuth(request: FastifyRequest): Promise<void> {
/**
* Protect /api/v1 routes.
* - AUTH_REQUIRED=false: Bearer JWT from local login (legacy admin).
* - AUTH_REQUIRED=true: portal JWT with apps.includes('cfdm') + permissions.
*/
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const config = request.server.config;
const authHeader = request.headers.authorization ?? "";
const token = authHeader.startsWith("Bearer ")
? authHeader.slice(7)
: "";
if (!token) throw AppError.unauthorized();
try {
await request.jwtVerify();
} catch {
throw AppError.unauthorized();
}
if (!config.authRequired) {
return;
}
const payload = request.user;
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : [];
const permissions = Array.isArray(payload.permissions)
? payload.permissions.map(String)
: [];
if (!apps.includes("cfdm")) {
throw AppError.forbidden("Нет доступа к приложению Cloudflare Domain Manager");
}
request.authUser = {
id: String(payload.sub),
email: String(payload.email ?? ""),
name: String(payload.name ?? ""),
apps,
permissions,
isAdmin: Boolean(payload.is_admin),
};
const required = permissionForRequest(request.method, request.url);
if (required && !hasPermission(permissions, required)) {
throw AppError.forbidden(`Недостаточно прав: ${required}`);
}
}
export default fp(authPlugin, { name: "auth" });
+22
View File
@@ -0,0 +1,22 @@
import type { FastifyInstance } from "fastify";
import { listAudit } from "@cfdm/db";
import { auditListQuerySchema } from "@cfdm/shared";
import { AppError } from "../errors.js";
export async function auditRoutes(app: FastifyInstance): Promise<void> {
app.get("/audit", async (request) => {
const parsed = auditListQuerySchema.safeParse(request.query);
if (!parsed.success) {
throw AppError.validation("Некорректные параметры запроса");
}
const q = parsed.data;
return listAudit(app.db, {
action: q.action,
severity: q.severity,
userId: q.user_id,
sourceApp: q.source_app,
limit: q.limit,
});
});
}
+37 -2
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import * as dnsService from "../services/dns-service.js";
import { recordAudit } from "../lib/audit.js";
export async function dnsRoutes(app: FastifyInstance) {
const createSchema = z.object({
@@ -31,12 +32,25 @@ export async function dnsRoutes(app: FastifyInstance) {
app.post("/domains/:id/dns", async (request) => {
const { id } = request.params as { id: string };
const body = createSchema.parse(request.body);
return dnsService.create(
const record = await dnsService.create(
request.server.db,
request.server.cf,
Number(id),
body,
);
recordAudit(request.server, request, {
action: "dns.create",
targetType: "app_resource",
targetId: String(record.id),
summary: `Создана DNS-запись ${record.name} (${record.record_type})`,
details: {
domain_id: Number(id),
record_type: record.record_type,
name: record.name,
content: record.content,
},
});
return record;
});
app.post("/domains/:id/dns/bulk", async (request) => {
@@ -69,13 +83,21 @@ export async function dnsRoutes(app: FastifyInstance) {
id: string;
recordId: string;
};
return dnsService.update(
const record = await dnsService.update(
request.server.db,
request.server.cf,
Number(id),
Number(recordId),
request.body as dnsService.UpdateDnsRequest,
);
recordAudit(request.server, request, {
action: "dns.update",
targetType: "app_resource",
targetId: String(recordId),
summary: `Обновлена DNS-запись ${record.name}`,
details: { domain_id: Number(id), record_id: Number(recordId) },
});
return record;
});
app.delete("/domains/:id/dns/:recordId", async (request) => {
@@ -83,12 +105,25 @@ export async function dnsRoutes(app: FastifyInstance) {
id: string;
recordId: string;
};
const record = dnsService.get(
request.server.db,
Number(id),
Number(recordId),
);
await dnsService.deleteRecord(
request.server.db,
request.server.cf,
Number(id),
Number(recordId),
);
recordAudit(request.server, request, {
action: "dns.delete",
severity: "warning",
targetType: "app_resource",
targetId: String(recordId),
summary: `Удалена DNS-запись ${record.name}`,
details: { domain_id: Number(id), name: record.name },
});
return { deleted: true };
});
+28 -2
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
import { bulkUpdateDomainsSchema, updateDomainSchema } from "@cfdm/shared";
import { z } from "zod";
import * as domainService from "../services/domain-service.js";
import { recordAudit } from "../lib/audit.js";
export async function domainRoutes(app: FastifyInstance) {
const createSchema = z.object({
@@ -17,12 +18,20 @@ export async function domainRoutes(app: FastifyInstance) {
app.post("/domains", async (request) => {
const body = createSchema.parse(request.body);
return domainService.createDomain(
const domain = await domainService.createDomain(
request.server.db,
request.server.cf,
body.group_id ?? null,
body.zone_name,
);
recordAudit(request.server, request, {
action: "domain.create",
targetType: "app_resource",
targetId: String(domain.id),
summary: `Добавлен домен ${domain.zone_name}`,
details: { zone_name: domain.zone_name, group_id: domain.group_id },
});
return domain;
});
app.post("/domains/bulk", async (request) => {
@@ -47,12 +56,29 @@ export async function domainRoutes(app: FastifyInstance) {
app.patch("/domains/:id", async (request) => {
const { id } = request.params as { id: string };
const body = updateDomainSchema.parse(request.body);
return domainService.updateDomain(request.server.db, Number(id), body);
const domain = domainService.updateDomain(request.server.db, Number(id), body);
recordAudit(request.server, request, {
action: "domain.update",
targetType: "app_resource",
targetId: String(domain.id),
summary: `Обновлён домен ${domain.zone_name}`,
details: body,
});
return domain;
});
app.delete("/domains/:id", async (request) => {
const { id } = request.params as { id: string };
const domain = domainService.getDomain(request.server.db, Number(id));
domainService.deleteDomain(request.server.db, Number(id));
recordAudit(request.server, request, {
action: "domain.delete",
severity: "warning",
targetType: "app_resource",
targetId: String(id),
summary: `Удалён домен ${domain.zone_name}`,
details: { zone_name: domain.zone_name },
});
return { deleted: true };
});
+29 -2
View File
@@ -1,6 +1,8 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { repos } from "@cfdm/db";
import * as groupService from "../services/group-service.js";
import { recordAudit } from "../lib/audit.js";
export async function groupRoutes(app: FastifyInstance) {
const bodySchema = z.object({
@@ -14,7 +16,15 @@ export async function groupRoutes(app: FastifyInstance) {
app.post("/groups", async (request) => {
const body = bodySchema.parse(request.body);
return groupService.createGroup(request.server.db, body.name, body.slug);
const group = groupService.createGroup(request.server.db, body.name, body.slug);
recordAudit(request.server, request, {
action: "group.create",
targetType: "app_resource",
targetId: String(group.id),
summary: `Создана группа «${group.name}»`,
details: { name: group.name, slug: group.slug },
});
return group;
});
app.get("/groups/:id", async (request) => {
@@ -25,17 +35,34 @@ export async function groupRoutes(app: FastifyInstance) {
app.patch("/groups/:id", async (request) => {
const { id } = request.params as { id: string };
const body = bodySchema.parse(request.body);
return groupService.updateGroup(
const group = groupService.updateGroup(
request.server.db,
Number(id),
body.name,
body.slug,
);
recordAudit(request.server, request, {
action: "group.update",
targetType: "app_resource",
targetId: String(group.id),
summary: `Обновлена группа «${group.name}»`,
details: { name: group.name, slug: group.slug },
});
return group;
});
app.delete("/groups/:id", async (request) => {
const { id } = request.params as { id: string };
const group = repos.getGroup(request.server.db, Number(id));
groupService.deleteGroup(request.server.db, Number(id));
recordAudit(request.server, request, {
action: "group.delete",
severity: "warning",
targetType: "app_resource",
targetId: String(id),
summary: `Удалена группа «${group.name}»`,
details: { name: group.name, slug: group.slug },
});
return { deleted: true };
});
}
+18 -4
View File
@@ -1,8 +1,13 @@
import type { FastifyInstance } from "fastify";
import { healthStatusQuerySchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import { getAppSettings, repos } from "@cfdm/db";
import * as healthCheckService from "../services/health-check-service.js";
import * as serviceConfigService from "../services/service-config-service.js";
import {
healthEngineFallbacksFromConfig,
} from "../services/health-check-scheduler.js";
import { mailboxFromSettings } from "../services/health/health-worker-deploy.js";
import { cronStaleAfterMs } from "../services/health/mailbox.js";
export async function healthCheckRoutes(app: FastifyInstance) {
app.get("/health-status", async (request) => {
@@ -16,13 +21,22 @@ export async function healthCheckRoutes(app: FastifyInstance) {
app.post("/health-check/run", async (request) => {
const config = request.server.config;
const fallbacks = healthEngineFallbacksFromConfig(config);
const settings = getAppSettings(
request.server.db,
fallbacks,
);
const thresholds = {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
degradedFailures: settings.healthDegradedFailures,
downFailures: settings.healthDownFailures,
latencyWarnMs: settings.healthLatencyWarnMs,
successRecoveries: settings.healthSuccessRecoveries,
};
const checked = await healthCheckService.runAllChecks(request.server.db, {
thresholds,
probeGapMs: config.healthProbeGapMs,
mailbox: mailboxFromSettings(request.server.db, request.server.cf, fallbacks),
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
onStatusChange: async (target, prev, next) => {
try {
const label =
+14 -3
View File
@@ -1,7 +1,6 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { healthCheck } from "../plugins/db.js";
import { getAppSwitcher } from "@cfdm/db";
import * as authService from "../services/auth.js";
export async function healthRoutes(app: FastifyInstance) {
@@ -32,8 +31,12 @@ export async function healthRoutes(app: FastifyInstance) {
}
export async function authRoutes(app: FastifyInstance) {
app.get("/settings/app-switcher", async (request) => {
return getAppSwitcher(request.server.db);
app.get("/auth/config", async (request) => {
const { config } = request.server;
return {
required: config.authRequired,
portal_url: config.authPortalUrl,
};
});
const loginSchema = z.object({
@@ -42,6 +45,14 @@ export async function authRoutes(app: FastifyInstance) {
});
app.post("/auth/login", async (request, reply) => {
if (request.server.config.authRequired) {
return reply.code(403).send({
error: {
code: "FORBIDDEN",
message: "Локальный вход отключён — используйте auth-portal",
},
});
}
const body = loginSchema.parse(request.body);
const result = await authService.login(
request.server.config,
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
import { vpsTrackerEventSchema } from "@cfdm/shared";
import { getAppSettingsSecrets } from "@cfdm/db";
import * as vpsTrackerEvents from "../services/vps-tracker-events.js";
import { buildAllSyncBindings } from "../services/vps-tracker-sync.js";
function verifyBearer(authHeader: string | undefined, expected: string): boolean {
if (!authHeader?.startsWith("Bearer ")) return false;
@@ -41,4 +42,31 @@ export async function integrationsVpsTrackerRoutes(app: FastifyInstance) {
);
return { ok: true, reconciled };
});
/**
* Полная синхронизация по запросу VPS Tracker (pull).
* Bindings возвращаются в ответе — клиент применяет их локально.
* Не требует доступности VPS Tracker URL из CFDM.
*/
app.post("/integrations/vps-tracker/sync", async (req, reply) => {
const secrets = getAppSettingsSecrets(app.db);
const token = secrets.vpsTrackerIntegrationToken;
if (!token) {
return reply.status(503).send({
ok: false,
error: "Integration not configured",
});
}
if (!verifyBearer(req.headers.authorization, token)) {
return reply.status(401).send({ ok: false, error: "Unauthorized" });
}
const bindings = await buildAllSyncBindings(app.db);
return {
ok: true,
count: bindings.length,
bindings,
fullSync: true,
};
});
}
@@ -0,0 +1,77 @@
import type { FastifyInstance } from "fastify";
import {
createOriginHealthCheckSchema,
} from "@cfdm/shared";
import * as originHealth from "../services/origin-health-check-service.js";
import { recordAudit } from "../lib/audit.js";
export async function originHealthCheckRoutes(app: FastifyInstance) {
app.get("/health-checks", async (request) => {
return originHealth.listOriginHealthChecks(request.server.db);
});
app.post("/health-checks", async (request) => {
const body = createOriginHealthCheckSchema.parse(request.body);
const check = await originHealth.createOriginHealthCheck(
request.server.db,
request.server.cf,
body,
);
recordAudit(request.server, request, {
action: "healthcheck.create",
targetType: "app_resource",
targetId: String(check.id),
summary: `Создан health check «${check.name}» (${check.provider})`,
});
return check;
});
app.get("/health-checks/:id", async (request) => {
const { id } = request.params as { id: string };
return originHealth.getOriginHealthCheck(request.server.db, Number(id));
});
app.patch("/health-checks/:id", async (request) => {
const { id } = request.params as { id: string };
const body = createOriginHealthCheckSchema.partial().parse(request.body);
const check = await originHealth.updateOriginHealthCheck(
request.server.db,
request.server.cf,
Number(id),
body,
);
recordAudit(request.server, request, {
action: "healthcheck.update",
targetType: "app_resource",
targetId: id,
summary: `Обновлён health check «${check.name}»`,
});
return check;
});
app.delete("/health-checks/:id", async (request) => {
const { id } = request.params as { id: string };
await originHealth.deleteOriginHealthCheck(
request.server.db,
request.server.cf,
Number(id),
);
recordAudit(request.server, request, {
action: "healthcheck.delete",
severity: "warning",
targetType: "app_resource",
targetId: id,
summary: `Удалён health check ${id}`,
});
return { deleted: true };
});
app.post("/health-checks/:id/sync", async (request) => {
const { id } = request.params as { id: string };
return originHealth.syncOriginHealthCheck(
request.server.db,
request.server.cf,
Number(id),
);
});
}
+25
View File
@@ -1,6 +1,9 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { certMonitoringSchema, changeIpSchema } from "@cfdm/shared";
import * as bindingService from "../services/binding-service.js";
import * as changeIp from "../services/change-ip-service.js";
import { recordAudit } from "../lib/audit.js";
export async function serviceBindingRoutes(app: FastifyInstance) {
const createSchema = z.object({
@@ -14,6 +17,7 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
service_id: z.number().optional(),
hostname: z.string().optional(),
target_ip: z.string().optional(),
cert_monitoring: certMonitoringSchema.optional(),
});
app.get("/service-bindings", async (request) => {
@@ -52,6 +56,27 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
return { deleted: true };
});
app.post("/service-bindings/:id/change-ip", async (request) => {
const { id } = request.params as { id: string };
const body = changeIpSchema.parse(request.body);
const result = await changeIp.changeBindingIp(
request.server.db,
request.server.cf,
Number(id),
body,
);
if (result.applied) {
recordAudit(request.server, request, {
action: "binding.change_ip",
targetType: "app_resource",
targetId: id,
summary: `Сменён IP: ${result.message}`,
details: result,
});
}
return result;
});
app.get("/domains/:id/service-bindings", async (request) => {
const { id } = request.params as { id: string };
return bindingService.listByDomain(request.server.db, Number(id));
+187 -5
View File
@@ -1,8 +1,19 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared";
import {
changeDomainSchema,
createServiceNodeSchema,
reorderServicesSchema,
toggleServiceIpSchema,
updateServiceConfigSchema,
updateServiceNodeSchema,
} from "@cfdm/shared";
import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js";
import * as nodeService from "../services/node-service.js";
import * as changeDomain from "../services/change-domain-service.js";
import * as certificateService from "../services/certificate-service.js";
import { recordAudit } from "../lib/audit.js";
export async function serviceRoutes(app: FastifyInstance) {
const createSchema = z.object({
@@ -39,7 +50,15 @@ export async function serviceRoutes(app: FastifyInstance) {
body.service_group_id,
);
}
return serviceConfig.getView(request.server.db, service.id);
const view = await serviceConfig.getView(request.server.db, service.id);
recordAudit(request.server, request, {
action: "service.create",
targetType: "app_resource",
targetId: String(service.id),
summary: `Создан сервис «${service.name}»`,
details: { name: service.name, slug: service.slug },
});
return view;
});
app.get("/services/:id", async (request) => {
@@ -47,20 +66,161 @@ export async function serviceRoutes(app: FastifyInstance) {
return serviceConfig.getView(request.server.db, Number(id));
});
app.patch("/services/:id", async (request) => {
app.get("/services/:id/health-log", async (request) => {
const { id } = request.params as { id: string };
const body = updateServiceConfigSchema.parse(request.body);
return serviceConfig.updateConfig(
repos.getService(request.server.db, Number(id));
return {
items: repos.listHealthProbeLogForService(
request.server.db,
Number(id),
200,
),
};
});
app.get("/services/:id/failover-log", async (request) => {
const { id } = request.params as { id: string };
repos.getService(request.server.db, Number(id));
return {
items: repos.listFailoverLogForService(
request.server.db,
Number(id),
200,
),
};
});
app.get("/services/:id/certificates", async (request) => {
const { id } = request.params as { id: string };
return certificateService.listServiceCertificates(
request.server.db,
Number(id),
);
});
app.post("/services/:id/certificates/check", async (request) => {
const { id } = request.params as { id: string };
const checked = await certificateService.runServiceChecks(
request.server.db,
Number(id),
);
return { checked };
});
app.get("/services/:id/overview", async (request) => {
const { id } = request.params as { id: string };
return nodeService.getOverview(request.server.db, Number(id));
});
app.get("/services/:id/nodes", async (request) => {
const { id } = request.params as { id: string };
return nodeService.listNodes(request.server.db, Number(id));
});
app.post("/services/:id/nodes", async (request) => {
const { id } = request.params as { id: string };
const body = createServiceNodeSchema.parse(request.body);
const node = nodeService.createNode(request.server.db, Number(id), body);
recordAudit(request.server, request, {
action: "node.create",
targetType: "app_resource",
targetId: String(node.id),
summary: `Добавлена нода ${node.address}`,
details: { service_id: Number(id), address: node.address },
});
return node;
});
app.patch("/services/:id/nodes/:nodeId", async (request) => {
const { id, nodeId } = request.params as { id: string; nodeId: string };
const body = updateServiceNodeSchema.parse(request.body);
const node = nodeService.updateNode(
request.server.db,
Number(id),
Number(nodeId),
body,
);
recordAudit(request.server, request, {
action: "node.update",
targetType: "app_resource",
targetId: String(node.id),
summary: `Обновлена нода ${node.address}`,
details: body,
});
return node;
});
app.delete("/services/:id/nodes/:nodeId", async (request) => {
const { id, nodeId } = request.params as { id: string; nodeId: string };
const node = repos.getNode(request.server.db, Number(nodeId));
nodeService.deleteNode(request.server.db, Number(id), Number(nodeId));
recordAudit(request.server, request, {
action: "node.delete",
severity: "warning",
targetType: "app_resource",
targetId: nodeId,
summary: `Удалена нода ${node.address}`,
});
return { deleted: true };
});
app.post("/services/:id/change-domain", async (request) => {
const { id } = request.params as { id: string };
const body = changeDomainSchema.parse(request.body);
const result = await changeDomain.changeServiceDomain(
request.server.db,
request.server.cf,
Number(id),
body,
);
if (result.applied) {
recordAudit(request.server, request, {
action: "service.change_domain",
targetType: "app_resource",
targetId: id,
summary: result.message,
details: result,
});
}
return result;
});
app.get("/ops-summary", async (request) => {
return nodeService.opsSummary(request.server.db);
});
app.patch("/services/:id", async (request) => {
const { id } = request.params as { id: string };
const body = updateServiceConfigSchema.parse(request.body);
const view = await serviceConfig.updateConfig(
request.server.db,
request.server.cf,
Number(id),
body,
);
recordAudit(request.server, request, {
action: "service.update",
targetType: "app_resource",
targetId: String(id),
summary: `Обновлён сервис «${view.name}»`,
details: body,
});
return view;
});
app.delete("/services/:id", async (request) => {
const { id } = request.params as { id: string };
const view = await serviceConfig.getView(request.server.db, Number(id));
repos.deleteService(request.server.db, Number(id));
recordAudit(request.server, request, {
action: "service.delete",
severity: "warning",
targetType: "app_resource",
targetId: String(id),
summary: `Удалён сервис «${view.name}»`,
details: { name: view.name, slug: view.slug },
});
return { deleted: true };
});
@@ -74,4 +234,26 @@ export async function serviceRoutes(app: FastifyInstance) {
body.enabled,
);
});
app.patch("/services/:id/ips/toggle", async (request) => {
const { id } = request.params as { id: string };
const body = toggleServiceIpSchema.parse(request.body);
const view = await serviceConfig.toggleServiceIp(
request.server.db,
request.server.cf,
Number(id),
body.ip,
body.enabled,
);
recordAudit(request.server, request, {
action: "service.ip.toggle",
targetType: "app_resource",
targetId: String(id),
summary: body.enabled
? `Включён IP ${body.ip} сервиса «${view.name}»`
: `Выключен IP ${body.ip} сервиса «${view.name}»`,
details: { ip: body.ip, enabled: body.enabled },
});
return view;
});
}
+53 -3
View File
@@ -5,15 +5,65 @@ import {
updateAppSettings,
} from "@cfdm/db";
import { pingVpsTracker } from "../services/vps-tracker-sync.js";
import { AppError } from "../errors.js";
import {
assertValidHealthCron,
healthEngineFallbacksFromConfig,
} from "../services/health-check-scheduler.js";
import { ensureHealthWorker } from "../services/health/health-worker-deploy.js";
export async function settingsRoutes(app: FastifyInstance) {
app.get("/settings", async (request) => {
return getAppSettings(request.server.db);
return getAppSettings(
request.server.db,
healthEngineFallbacksFromConfig(request.server.config),
);
});
app.patch("/settings", async (request) => {
const body = appSettingsPatchSchema.parse(request.body);
return updateAppSettings(request.server.db, body);
const parsed = appSettingsPatchSchema.safeParse(request.body);
if (!parsed.success) {
throw AppError.validation(
parsed.error.issues[0]?.message ?? "некорректные настройки",
);
}
const body = parsed.data;
if (body.healthCheckCron) {
assertValidHealthCron(body.healthCheckCron);
}
const fallbacks = healthEngineFallbacksFromConfig(request.server.config);
const current = getAppSettings(request.server.db, fallbacks);
const nextDegraded =
body.healthDegradedFailures ?? current.healthDegradedFailures;
const nextDown = body.healthDownFailures ?? current.healthDownFailures;
if (nextDown < nextDegraded) {
throw AppError.validation(
"ошибок до down не меньше, чем до degraded",
);
}
updateAppSettings(request.server.db, body, fallbacks);
if (body.healthCheckCron !== undefined) {
request.server.reloadHealthCheckJob?.();
const after = getAppSettings(request.server.db, fallbacks);
if (after.healthWorkerKvNamespaceId) {
try {
await ensureHealthWorker(
request.server.db,
request.server.cf,
fallbacks,
);
} catch {
// error stored in settings
}
}
}
return getAppSettings(request.server.db, fallbacks);
});
app.post("/settings/health/worker/ensure", async (request) => {
const fallbacks = healthEngineFallbacksFromConfig(request.server.config);
await ensureHealthWorker(request.server.db, request.server.cf, fallbacks);
return getAppSettings(request.server.db, fallbacks);
});
app.post("/settings/vps-tracker/test", async (request) => {
@@ -0,0 +1,23 @@
import { request } from "undici";
import type { IngestAuditEvent } from "@cfdm/shared";
export async function pushAuditEvents(
portalUrl: string,
secret: string,
events: IngestAuditEvent[],
): Promise<void> {
const base = portalUrl.replace(/\/$/, "");
const res = await request(`${base}/api/v1/ingest/audit`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${secret}`,
},
body: JSON.stringify({ events }),
});
if (res.statusCode >= 400) {
const body = await res.body.text();
throw new Error(`portal audit ingest ${res.statusCode}: ${body}`);
}
}
+15
View File
@@ -15,6 +15,7 @@ export interface UpdateBindingRequest {
service_id?: number;
hostname?: string;
target_ip?: string;
cert_monitoring?: string;
}
function normalizeHostname(hostname?: string): string {
@@ -92,6 +93,20 @@ export async function update(
req: UpdateBindingRequest,
): Promise<ServiceBindingView> {
const existing = repos.getBinding(db, id);
if (req.cert_monitoring !== undefined) {
repos.updateBindingLbConfig(db, id, {
cert_monitoring: req.cert_monitoring,
});
}
const hasIdentityPatch =
req.service_id !== undefined ||
req.hostname !== undefined ||
req.target_ip !== undefined;
if (!hasIdentityPatch) {
return repos.getBindingView(db, id);
}
const serviceId = req.service_id ?? existing.service_id;
if (req.service_id) repos.getService(db, req.service_id);
const hostname = req.hostname
+114 -89
View File
@@ -2,7 +2,7 @@ import { connect } from "node:net";
import { connect as tlsConnect } from "node:tls";
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Certificate, Domain, Subdomain } from "@cfdm/shared";
import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
import {
CERT_ERROR,
CERT_MONITOR_AUTO,
@@ -11,20 +11,29 @@ import {
CERT_UNKNOWN,
certStatusFromExpiry,
fqdnToDisplay,
parseFqdn,
shouldMonitorService,
} from "@cfdm/shared";
export interface CertificateTarget {
domainId: number;
subdomainId: number | null;
serviceId: number;
hostname: string;
}
function pruneStaleCertificates(db: Db): void {
const targets = resolveCertificateTargets(db);
repos.deleteCertificatesNotIn(
db,
targets.map((t) => t.hostname),
);
}
export function listCertificates(
db: Db,
status?: string,
): Certificate[] {
pruneStaleCertificates(db);
return repos.listCertificates(db, status);
}
@@ -32,6 +41,34 @@ export function getCertificate(db: Db, id: number): Certificate {
return repos.getCertificate(db, id);
}
export function listServiceCertificates(
db: Db,
serviceId: number,
): ServiceCertificateRow[] {
repos.getService(db, serviceId);
const certsByHost = new Map(
repos.listCertificates(db).map((cert) => [cert.hostname, cert]),
);
return repos.listBindingsByService(db, serviceId).map((binding) => {
const hostname = fqdnToDisplay(binding.hostname, binding.zone_name);
const cert = certsByHost.get(hostname);
return {
binding_id: binding.id,
domain_id: binding.domain_id,
service_id: binding.service_id,
hostname,
cert_monitoring:
(binding.cert_monitoring as ServiceCertificateRow["cert_monitoring"]) ??
"auto",
id: cert?.id ?? null,
status: cert?.status ?? "unknown",
expires_at: cert?.expires_at ?? null,
last_checked_at: cert?.last_checked_at ?? null,
last_error: cert?.last_error ?? null,
};
});
}
export async function checkHostname(
hostname: string,
): Promise<{ expiresAt: Date | null; error: string | null }> {
@@ -69,6 +106,7 @@ export async function checkAndStore(
domainId: number,
subdomainId: number | null,
hostname: string,
serviceId: number | null = null,
): Promise<Certificate> {
const { expiresAt, error } = await checkHostname(hostname);
@@ -81,6 +119,7 @@ export async function checkAndStore(
null,
CERT_ERROR,
error,
serviceId,
);
}
@@ -96,6 +135,7 @@ export async function checkAndStore(
expiresAt.toISOString(),
certStatusFromExpiry(days),
null,
serviceId,
);
}
@@ -107,23 +147,10 @@ export async function checkAndStore(
null,
CERT_UNKNOWN,
"unknown expiry",
serviceId,
);
}
function resolveMonitoringMode(
domain: Domain,
subdomain: Subdomain | null,
fqdn: string,
): string {
if (subdomain) {
return subdomain.cert_monitoring;
}
if (fqdn === domain.zone_name) {
return domain.cert_monitoring;
}
return CERT_MONITOR_AUTO;
}
function bindingSubdomain(
db: Db,
domainId: number,
@@ -133,10 +160,32 @@ function bindingSubdomain(
return repos.findSubdomainByDomainAndName(db, domainId, hostname);
}
export function buildServiceCertificateFqdns(
db: Db,
): Map<string, CertificateTarget> {
const result = new Map<string, CertificateTarget>();
type HealthTlsFlags = {
health_check_enabled: boolean;
health_check_verify_tls: boolean;
};
/**
* Auto SSL monitoring follows effective health-check with TLS verify.
* No health → no SSL. Health without verify_tls (self-signed) → no SSL.
* Binding inherits group health when its own health is off.
*/
function hasSslHealthGate(
own: HealthTlsFlags,
group: (HealthTlsFlags & { enabled: boolean }) | null,
): boolean {
if (own.health_check_enabled) {
return own.health_check_verify_tls;
}
if (group?.enabled && group.health_check_enabled) {
return group.health_check_verify_tls;
}
return false;
}
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
const targets: CertificateTarget[] = [];
const seen = new Set<string>();
for (const binding of repos.listAllBindings(db)) {
const service = repos.getService(db, binding.service_id);
@@ -148,82 +197,36 @@ export function buildServiceCertificateFqdns(
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
if (subdomain && !subdomain.enabled) continue;
const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO;
if (mode === CERT_MONITOR_SKIPPED) continue;
if (mode === CERT_MONITOR_AUTO) {
if (
!hasSslHealthGate(
{
health_check_enabled: binding.health_check_enabled,
health_check_verify_tls: binding.health_check_verify_tls,
},
group,
)
) {
continue;
}
} else if (mode !== CERT_MONITOR_REQUIRED) {
continue;
}
const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);
result.set(fqdn, {
if (seen.has(fqdn)) continue;
seen.add(fqdn);
targets.push({
domainId: binding.domain_id,
subdomainId: subdomain?.id ?? null,
serviceId: binding.service_id,
hostname: fqdn,
});
}
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
for (const group of repos.listServiceGroups(db)) {
if (!group.enabled || !group.domain?.trim()) continue;
const parsed = parseFqdn(group.domain, knownZones);
if (!parsed) continue;
const domain = repos.findDomainByZoneName(db, parsed.zoneName);
if (!domain) continue;
const subdomain =
parsed.hostname === "@"
? null
: bindingSubdomain(db, domain.id, parsed.hostname);
if (subdomain && !subdomain.enabled) continue;
result.set(parsed.fqdn, {
domainId: domain.id,
subdomainId: subdomain?.id ?? null,
hostname: parsed.fqdn,
});
}
return result;
}
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
const serviceFqdns = buildServiceCertificateFqdns(db);
const targets = new Map<string, CertificateTarget>();
for (const domain of repos.listAllDomains(db)) {
if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) {
targets.set(domain.zone_name, {
domainId: domain.id,
subdomainId: null,
hostname: domain.zone_name,
});
}
}
for (const sub of repos.listAllSubdomains(db)) {
if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) {
targets.set(sub.fqdn, {
domainId: sub.domain_id,
subdomainId: sub.id,
hostname: sub.fqdn,
});
}
}
for (const [fqdn, meta] of serviceFqdns) {
const domain = repos.getDomain(db, meta.domainId);
const subdomain = meta.subdomainId
? repos.getSubdomain(db, meta.subdomainId)
: null;
const monitoring = resolveMonitoringMode(domain, subdomain, fqdn);
if (monitoring === CERT_MONITOR_SKIPPED) continue;
if (
monitoring === CERT_MONITOR_AUTO ||
monitoring === CERT_MONITOR_REQUIRED
) {
targets.set(fqdn, meta);
}
}
return [...targets.values()];
return targets;
}
export async function runAllChecks(db: Db): Promise<number> {
@@ -234,6 +237,7 @@ export async function runAllChecks(db: Db): Promise<number> {
target.domainId,
target.subdomainId,
target.hostname,
target.serviceId,
);
}
repos.deleteCertificatesNotIn(
@@ -243,6 +247,27 @@ export async function runAllChecks(db: Db): Promise<number> {
return targets.length;
}
export async function runServiceChecks(
db: Db,
serviceId: number,
): Promise<number> {
repos.getService(db, serviceId);
const targets = resolveCertificateTargets(db).filter(
(target) => target.serviceId === serviceId,
);
for (const target of targets) {
await checkAndStore(
db,
target.domainId,
target.subdomainId,
target.hostname,
target.serviceId,
);
}
return targets.length;
}
export function statusSummary(db: Db): Array<[string, number]> {
pruneStaleCertificates(db);
return repos.countCertificatesByStatus(db);
}
@@ -0,0 +1,118 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { ChangeDomainInput } from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import * as dnsService from "./dns-service.js";
import { withBindingLock } from "./routing/index.js";
import { applyBindingDesiredDns, fqdnToDisplay } from "./service-config-service.js";
export interface ChangeDomainItem {
binding_id: number;
hostname: string;
from_fqdn: string;
to_fqdn: string;
}
export interface ChangeDomainPreview {
from_domain_id: number;
to_domain_id: number;
from_zone: string;
to_zone: string;
items: ChangeDomainItem[];
dry_run: boolean;
applied: boolean;
message: string;
}
export async function changeServiceDomain(
db: Db,
cf: CloudflareClient,
serviceId: number,
input: ChangeDomainInput,
): Promise<ChangeDomainPreview> {
repos.getService(db, serviceId);
if (input.from_domain_id === input.to_domain_id) {
throw AppError.validation("укажите другой целевой домен");
}
const fromDomain = repos.getDomain(db, input.from_domain_id);
const toDomain = repos.getDomain(db, input.to_domain_id);
const bindings = repos
.listBindingsByService(db, serviceId)
.filter((b) => b.domain_id === input.from_domain_id);
const selected = input.hostnames?.length
? bindings.filter((b) => input.hostnames!.includes(b.hostname))
: bindings;
if (selected.length === 0) {
throw AppError.validation("нет привязок для переноса");
}
const items: ChangeDomainItem[] = selected.map((binding) => ({
binding_id: binding.id,
hostname: binding.hostname,
from_fqdn: fqdnToDisplay(binding.hostname, fromDomain.zone_name),
to_fqdn: fqdnToDisplay(binding.hostname, toDomain.zone_name),
}));
const preview: ChangeDomainPreview = {
from_domain_id: fromDomain.id,
to_domain_id: toDomain.id,
from_zone: fromDomain.zone_name,
to_zone: toDomain.zone_name,
items,
dry_run: Boolean(input.dry_run),
applied: false,
message: `Перенос ${items.length} привязок ${fromDomain.zone_name} → ${toDomain.zone_name}`,
};
if (input.dry_run) return preview;
const createdRecordIds: number[] = [];
try {
for (const binding of selected) {
const existing = repos.findBinding(
db,
serviceId,
toDomain.id,
binding.hostname,
);
if (existing) {
throw AppError.conflict(
`привязка ${fqdnToDisplay(binding.hostname, toDomain.zone_name)} уже существует`,
);
}
await withBindingLock(binding.id, async () => {
repos.bumpBindingVersion(db, binding.id);
const ips = repos.listBindingIps(db, binding.id);
repos.updateBindingDomain(db, binding.id, toDomain.id, binding.hostname);
await applyBindingDesiredDns(db, cf, binding.id, ips);
const newRecords = repos.listRecordsForBinding(db, binding.id);
createdRecordIds.push(...newRecords.map((r) => r.id));
const oldRecords = newRecords.filter((r) => r.domain_id === fromDomain.id);
for (const record of oldRecords) {
repos.unlinkBindingRecord(db, binding.id, record.id);
try {
await dnsService.deleteRecord(db, cf, fromDomain.id, record.id);
} catch {
// best-effort cleanup of old zone
}
}
});
}
} catch (err) {
throw err instanceof AppError
? err
: AppError.syncFailed(
err instanceof Error ? err.message : "не удалось перенести привязки",
);
}
void createdRecordIds;
return {
...preview,
dry_run: false,
applied: true,
message: `Привязки перенесены в ${toDomain.zone_name}. Старые записи зоны удалены.`,
};
}
+142
View File
@@ -0,0 +1,142 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { ChangeIpInput } from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import { isValidIpv4 } from "../lib/validators.js";
import { withBindingLock } from "./routing/index.js";
import { applyBindingDesiredDns } from "./service-config-service.js";
export interface ChangeIpPreview {
binding_id: number;
hostname: string;
zone_name: string;
from_ip: string;
to_ip: string;
dry_run: boolean;
applied: boolean;
message: string;
}
async function patchRecordContent(
db: Db,
cf: CloudflareClient,
domainId: number,
recordId: number,
content: string,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const record = repos.getDnsRecord(db, domainId, recordId);
if (!record.cf_record_id) {
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
}
try {
const patched = await cf.patchDnsRecord(domain.cf_zone_id, record.cf_record_id, {
content,
});
repos.updateDnsFields(
db,
record.id,
patched.type ?? record.record_type,
patched.name ?? record.name,
patched.content ?? content,
patched.ttl ?? record.ttl,
patched.proxied ?? record.proxied,
patched.priority ?? record.priority,
"synced",
patched.id ?? record.cf_record_id,
null,
);
} catch (err) {
if (err instanceof AppError) throw err;
throw AppError.dnsUpdateFailed(
err instanceof Error ? err.message : "не удалось обновить запись в Cloudflare",
);
}
}
export async function changeBindingIp(
db: Db,
cf: CloudflareClient,
bindingId: number,
input: ChangeIpInput,
): Promise<ChangeIpPreview> {
const binding = repos.getBinding(db, bindingId);
const domain = repos.getDomain(db, binding.domain_id);
const current = repos.listBindingIpsWithMeta(db, bindingId);
if (current.length === 0) {
throw AppError.validation("у привязки нет IP для замены");
}
let fromIp = input.from_ip?.trim();
let toIp = input.to_ip?.trim();
if (input.node_id) {
const node = repos.getNode(db, input.node_id);
if (node.service_id !== binding.service_id) {
throw AppError.validation("нода не принадлежит сервису этой привязки");
}
toIp = node.address;
}
if (!fromIp) {
fromIp = current[0]!.ip;
}
if (!toIp) {
throw AppError.invalidIp("укажите новый IP или ноду");
}
if (!isValidIpv4(toIp)) {
throw AppError.invalidIp(`Некорректный IP-адрес: ${toIp}`);
}
if (!current.some((row) => row.ip === fromIp)) {
throw AppError.validation(`IP ${fromIp} нет в привязке`);
}
const preview: ChangeIpPreview = {
binding_id: bindingId,
hostname: binding.hostname,
zone_name: domain.zone_name,
from_ip: fromIp,
to_ip: toIp,
dry_run: Boolean(input.dry_run),
applied: false,
message: `${fromIp} → ${toIp}`,
};
if (input.dry_run || fromIp === toIp) {
return preview;
}
return withBindingLock(bindingId, async () => {
repos.bumpBindingVersion(db, bindingId);
const next = current.map((row) =>
row.ip === fromIp ? { ...row, ip: toIp } : row,
);
repos.replaceBindingIpsWithMeta(db, bindingId, next);
const records = repos.listRecordsForBinding(db, bindingId);
const match = records.find(
(record) =>
record.content === fromIp &&
(record.record_type.toUpperCase() === "A" ||
record.record_type.toUpperCase() === "AAAA"),
);
if (match) {
await patchRecordContent(db, cf, binding.domain_id, match.id, toIp);
} else {
await applyBindingDesiredDns(
db,
cf,
bindingId,
next.map((row) => row.ip),
);
}
return {
...preview,
dry_run: false,
applied: true,
message: `Запись обновлена в Cloudflare (${fromIp} → ${toIp}). Распространение зависит от TTL.`,
};
});
}
+47 -1
View File
@@ -1,6 +1,6 @@
import type { Db } from "@cfdm/db";
import { repos, type DnsListFilter } from "@cfdm/db";
import type { CreateDnsRecordPayload, DnsRecord } from "@cfdm/shared";
import type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from "@cfdm/shared";
import {
SYNC_CONFLICT,
SYNC_ERROR,
@@ -179,6 +179,52 @@ export async function update(
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
}
export async function patchContent(
db: Db,
cf: CloudflareClient,
domainId: number,
recordId: number,
payload: PatchDnsRecordPayload,
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const existing = repos.getDnsRecord(db, domainId, recordId);
if (!existing.cf_record_id) {
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
}
try {
const cfRec = await cf.patchDnsRecord(
domain.cf_zone_id,
existing.cf_record_id,
payload,
);
repos.updateDnsFields(
db,
existing.id,
cfRec.type ?? existing.record_type,
cfRec.name ?? existing.name,
cfRec.content ?? existing.content,
cfRec.ttl ?? existing.ttl,
cfRec.proxied ?? existing.proxied,
cfRec.priority ?? existing.priority,
SYNC_SYNCED,
cfRec.id ?? existing.cf_record_id,
null,
);
return repos.getDnsRecord(db, domainId, existing.id);
} catch (e) {
repos.setDnsSyncStatus(
db,
existing.id,
SYNC_ERROR,
existing.cf_record_id,
e instanceof Error ? e.message : String(e),
);
throw e instanceof AppError
? e
: AppError.dnsUpdateFailed(e instanceof Error ? e.message : String(e));
}
}
export async function deleteRecord(
db: Db,
cf: CloudflareClient,
@@ -0,0 +1,158 @@
import type { FastifyInstance } from "fastify";
import { AsyncTask, CronJob } from "toad-scheduler";
import {
getAppSettings,
getAppSettingsSecrets,
updateAppSettings,
type HealthEngineFallbacks,
} from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { AppConfig } from "../config.js";
import { AppError } from "../errors.js";
import * as healthCheckService from "./health-check-service.js";
import * as serviceConfigService from "./service-config-service.js";
import {
mailboxFromSettings,
} from "./health/health-worker-deploy.js";
import { cronStaleAfterMs } from "./health/mailbox.js";
declare module "fastify" {
interface FastifyInstance {
reloadHealthCheckJob?: () => void;
}
}
export const HEALTH_CHECK_JOB_ID = "health-check";
export function healthEngineFallbacksFromConfig(
config: AppConfig,
): HealthEngineFallbacks {
return {
healthCheckCron: config.healthCheckCron,
healthDegradedFailures: config.healthDegradedFailures,
healthDownFailures: config.healthDownFailures,
healthLatencyWarnMs: config.healthLatencyWarnMs,
healthSuccessRecoveries: config.healthSuccessRecoveries,
healthWorkerUrl: config.healthWorkerUrl,
healthWorkerTokenSet: Boolean(config.healthWorkerToken),
};
}
export function assertValidHealthCron(expr: string): void {
const cronExpression = expr.trim();
const parts = cronExpression.split(/\s+/).filter(Boolean);
if (parts.length < 5 || parts.length > 6) {
throw AppError.validation("некорректное cron-выражение");
}
try {
const job = new CronJob(
{ cronExpression },
new AsyncTask("validate-cron", async () => undefined),
{ id: "validate-cron" },
);
job.stop();
} catch {
throw AppError.validation("некорректное cron-выражение");
}
}
export function createHealthCheckTask(
app: FastifyInstance,
config: AppConfig,
): AsyncTask {
const fallbacks = healthEngineFallbacksFromConfig(config);
return new AsyncTask(
HEALTH_CHECK_JOB_ID,
async () => {
const settings = getAppSettings(app.db, fallbacks);
const thresholds = {
degradedFailures: settings.healthDegradedFailures,
downFailures: settings.healthDownFailures,
latencyWarnMs: settings.healthLatencyWarnMs,
successRecoveries: settings.healthSuccessRecoveries,
};
const mailbox = mailboxFromSettings(app.db, app.cf, fallbacks);
const secrets = getAppSettingsSecrets(app.db);
const n = await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: config.healthProbeGapMs,
mailbox,
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
globalping: {
token: secrets.globalpingToken,
locations: secrets.globalpingLocations,
limit: secrets.globalpingLimit,
},
onStatusChange: async (target, prev, next) => {
try {
const label =
next === "up"
? "OK"
: next === "degraded"
? "Slow"
: next === "down"
? "Down"
: "—";
repos.insertNotificationLog(
app.db,
"ip_health",
target.scope,
target.ref_id,
`${target.hostname || target.ip}: ${label}`,
`IP ${target.ip}: ${prev ?? "—"} → ${label}`,
);
await serviceConfigService.reconcileDnsForTarget(
app.db,
app.cf,
target.scope,
target.ref_id,
);
} catch (err) {
app.log.warn(
{ err, scope: target.scope, refId: target.ref_id },
"health-check reconcile failed",
);
}
},
});
if (mailbox) {
updateAppSettings(
app.db,
{ healthWorkerLastIngestAt: new Date().toISOString() },
fallbacks,
);
}
const monitors = await healthCheckService.runDomainMonitors(
app.db,
thresholds,
);
app.log.info({ checked: n, monitors }, "health check completed");
},
(err) => {
app.log.warn({ err }, "health check failed");
},
);
}
export function scheduleHealthCheckJob(
app: FastifyInstance,
config: AppConfig,
task: AsyncTask,
): void {
const scheduler = app.scheduler;
if (!scheduler) return;
if (scheduler.existsById(HEALTH_CHECK_JOB_ID)) {
scheduler.removeById(HEALTH_CHECK_JOB_ID);
}
const settings = getAppSettings(
app.db,
healthEngineFallbacksFromConfig(config),
);
scheduler.addCronJob(
new CronJob(
{ cronExpression: settings.healthCheckCron },
task,
{ preventOverrun: true, id: HEALTH_CHECK_JOB_ID },
),
);
}
+340 -63
View File
@@ -1,21 +1,63 @@
import { connect } from "node:net";
import { connect, isIP } from "node:net";
import { resolve4, resolve6 } from "node:dns/promises";
import { Agent, fetch as undiciFetch } from "undici";
import { Agent, buildConnector, fetch as undiciFetch } from "undici";
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
import type { HealthCheckTarget, IpHealthState, HealthCheckProvider } from "@cfdm/shared";
import {
aggregateHealthOk,
parseHealthAggregate,
targetProviders,
} from "@cfdm/shared";
import { AppError } from "../errors.js";
import { nextHealthState } from "./health/state-machine.js";
import { LocalHealthCheckProvider } from "./health/local.js";
import { workerNotConfiguredResult } from "./health/worker.js";
import {
globalpingNotConfiguredResult,
probeWithGlobalping,
} from "./health/globalping.js";
import {
buildTargetsDoc,
indexResults,
isResultsStale,
originProbeKey,
type HealthMailbox,
} from "./health/mailbox.js";
import type { GlobalpingClientOptions } from "../lib/globalping-client.js";
export interface HealthCheckThresholds {
degradedFailures: number;
downFailures: number;
latencyWarnMs: number;
successRecoveries?: number;
}
export interface ProbeResult {
ok: boolean;
latencyMs: number;
error: string | null;
colo?: string | null;
}
/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */
export function hostForUrl(ipOrHost: string): string {
return isIP(ipOrHost) === 6 ? `[${ipOrHost}]` : ipOrHost;
}
/**
* Build http(s) URL authority for the probe.
* Prefer FQDN in the URL (correct Host/SNI); TCP dial goes to configured IP via custom connector.
*/
export function buildHttpProbeUrl(
urlHost: string,
port: number,
pathWithSlash: string,
useTls: boolean,
): string {
const defaultPort = useTls ? 443 : 80;
const portPart = port === defaultPort ? "" : `:${port}`;
return `${useTls ? "https" : "http"}://${hostForUrl(urlHost)}${portPart}${pathWithSlash}`;
}
function tcpProbe(
@@ -59,6 +101,41 @@ function tcpProbe(
});
}
/** Dial `connectAddr` for TCP/TLS while URL Host/SNI stay on the FQDN. */
function createIpPinnedAgent(
connectAddr: string,
sniHost: string,
useTls: boolean,
timeoutMs: number,
verifyTls: boolean,
): Agent {
const connector = buildConnector({
rejectUnauthorized: verifyTls,
timeout: timeoutMs,
});
return new Agent({
connect(opts, callback) {
connector(
{
...opts,
// Force socket to configured IP (or CNAME target), not public DNS of FQDN.
hostname: connectAddr,
host: connectAddr,
servername:
useTls && isIP(sniHost) === 0
? sniHost
: (opts.servername as string | undefined),
},
callback,
);
},
});
}
/**
* HTTP(S) probe: URL/Host/SNI use hostname (vhost), TCP connects to configured IP when numeric.
* Avoids re-resolving FQDN via public DNS (which skewed group vs binding latency for the same IP).
*/
async function httpProbe(
ip: string,
target: HealthCheckTarget,
@@ -69,21 +146,23 @@ async function httpProbe(
const pathWithSlash = path.startsWith("/") ? path : `/${path}`;
const port = target.port ?? 80;
const useTls = port === 443;
const urlHost = useTls ? target.hostname || ip : ip;
const url = `${useTls ? "https" : "http"}://${urlHost}${pathWithSlash}`;
const dispatcher =
useTls && target.hostname
? new Agent({
connect: {
servername: target.hostname,
rejectUnauthorized: false,
},
})
const connectAddr = String(ip || "").trim();
const headerHost = (target.hostname || "").trim() || connectAddr;
const url = buildHttpProbeUrl(headerHost, port, pathWithSlash, useTls);
const verifyTls = target.verify_tls === true;
const family = isIP(connectAddr);
const pinToIp = family === 4 || family === 6;
const dispatcher = pinToIp
? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs, verifyTls)
: useTls
? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs, verifyTls)
: undefined;
try {
const response = await undiciFetch(url, {
method: "GET",
headers: { Host: target.hostname || ip },
signal: AbortSignal.timeout(timeoutMs),
redirect: "manual",
dispatcher,
@@ -177,27 +256,36 @@ export async function probeTarget(
function deriveState(
ok: boolean,
latencyMs: number,
prev: { consecutive_failures: number; status: string } | null,
prev: {
consecutive_failures: number;
consecutive_successes?: number;
status: string;
} | null,
thresholds: HealthCheckThresholds,
): { state: IpHealthState; failures: number } {
if (!ok) {
const failures = (prev?.consecutive_failures ?? 0) + 1;
if (failures >= thresholds.downFailures) {
return { state: "down", failures };
}
if (failures >= thresholds.degradedFailures) {
return { state: "degraded", failures };
}
return { state: "degraded", failures };
}
if (latencyMs > thresholds.latencyWarnMs) {
return { state: "degraded", failures: 0 };
}
return { state: "up", failures: 0 };
): { state: IpHealthState; failures: number; successes: number; node: string } {
const next = nextHealthState(ok, latencyMs, prev, {
degradedFailures: thresholds.degradedFailures,
downFailures: thresholds.downFailures,
latencyWarnMs: thresholds.latencyWarnMs,
successRecoveries: thresholds.successRecoveries ?? 2,
});
return {
state: next.legacy,
failures: next.failures,
successes: next.successes,
node: next.node,
};
}
export interface RunAllChecksOptions {
thresholds: HealthCheckThresholds;
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
probeGapMs?: number;
/** KV mailbox with Worker results. Missing → cloudflare targets fail, never Local fallback. */
mailbox?: HealthMailbox | null;
/** Results older than this are stale (default 10 min). */
staleAfterMs?: number;
globalping?: GlobalpingClientOptions | null;
onStatusChange?: (
target: HealthCheckTarget,
prevState: IpHealthState | null,
@@ -205,47 +293,231 @@ export interface RunAllChecksOptions {
) => void;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* One network hit per origin+provider. Group+binding on the same IP share a probe.
*/
export function physicalProbeKey(
target: HealthCheckTarget,
provider: HealthCheckProvider = target.provider,
): string {
return `${provider}|${originProbeKey(target)}`;
}
function logSourceResult(
db: Db,
target: HealthCheckTarget,
provider: HealthCheckProvider,
result: ProbeResult,
): void {
repos.insertHealthProbeLog(db, {
scope: target.scope,
refId: target.ref_id,
ip: target.ip,
provider,
status: result.ok ? "up" : "down",
ok: result.ok,
latencyMs: result.latencyMs,
colo: result.colo ?? null,
error: result.error,
});
}
function applyAggregatedStatus(
db: Db,
target: HealthCheckTarget,
sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>,
options: RunAllChecksOptions,
): void {
const policy = parseHealthAggregate(target.aggregate);
const oks = sources.map((s) => s.result.ok);
const aggregatedOk = aggregateHealthOk(oks, policy);
const latencies = sources.map((s) => s.result.latencyMs);
const latencyMs = latencies.length
? Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length)
: 0;
const colo =
sources.find((s) => s.result.colo)?.result.colo ??
sources[0]?.result.colo ??
null;
const error = aggregatedOk
? null
: sources
.map((s) => s.result.error)
.filter((msg): msg is string => Boolean(msg))
.join("; ") || "health aggregate down";
const statusProvider =
sources.length > 1 ? "aggregate" : (sources[0]?.provider ?? target.provider);
const prev = repos.getIpHealthStatusRow(
db,
target.scope,
target.ref_id,
target.ip,
);
const { state, failures, successes, node } = deriveState(
aggregatedOk,
latencyMs,
prev
? {
consecutive_failures: prev.consecutive_failures,
consecutive_successes: prev.consecutive_successes,
status: prev.status,
}
: null,
options.thresholds,
);
const prevState: IpHealthState | null = prev
? (prev.status as IpHealthState)
: null;
repos.upsertIpHealthStatus(
db,
target.scope,
target.ref_id,
target.ip,
state,
latencyMs,
failures,
error,
successes,
{ colo, provider: statusProvider },
);
const matchedNode = repos.findNodeByIp(db, target.ip);
// Binding-scope only: group apply must not clobber node with its own fetch failed.
if (matchedNode && matchedNode.enabled && target.scope === "binding") {
repos.updateNode(db, matchedNode.id, {
health_status: node,
consecutive_failures: failures,
consecutive_successes: successes,
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
last_failure_reason: error,
});
}
if (prevState !== state) {
options.onStatusChange?.(target, prevState, state);
}
}
function staleWorkerResult(colo: string | null): ProbeResult {
return {
ok: false,
latencyMs: 0,
error: "Cloudflare Worker: результаты устарели или KV пуст",
colo,
};
}
export async function runAllChecks(
db: Db,
options: RunAllChecksOptions,
): Promise<number> {
const targets = repos.listHealthCheckTargets(db);
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
const local = new LocalHealthCheckProvider();
const staleAfterMs = options.staleAfterMs ?? 10 * 60_000;
const byOrigin = new Map<string, HealthCheckTarget[]>();
for (const target of targets) {
const prev = repos.getIpHealthStatusRow(
db,
target.scope,
target.ref_id,
target.ip,
);
const result = await probeTarget(target);
const { state, failures } = deriveState(
result.ok,
result.latencyMs,
prev
? {
consecutive_failures: prev.consecutive_failures,
status: prev.status,
}
: null,
options.thresholds,
);
const prevState: IpHealthState | null = prev
? (prev.status as IpHealthState)
: null;
repos.upsertIpHealthStatus(
db,
target.scope,
target.ref_id,
target.ip,
state,
result.latencyMs,
failures,
result.error,
);
if (prevState !== state) {
options.onStatusChange?.(target, prevState, state);
const key = originProbeKey(target);
const list = byOrigin.get(key);
if (list) list.push(target);
else byOrigin.set(key, [target]);
}
const needsCloudflare = targets.some((t) =>
targetProviders(t).includes("cloudflare"),
);
let mailboxResults = new Map<string, { ok: boolean; latencyMs: number; error: string | null }>();
let mailboxColo: string | null = null;
let mailboxStale = true;
const mailbox = options.mailbox ?? null;
if (needsCloudflare) {
const resultsDoc = mailbox ? await mailbox.getResults() : null;
mailboxResults = indexResults(resultsDoc);
mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
mailboxColo = resultsDoc?.colo ?? null;
if (mailbox) {
try {
const next = buildTargetsDoc(targets);
const current = await mailbox.getTargets();
if (current?.fingerprint !== next.fingerprint) {
await mailbox.putTargets(next);
}
} catch {
// ingest still proceeds
}
}
}
const probeCache = new Map<string, ProbeResult>();
let probeIndex = 0;
async function resolveProvider(
provider: HealthCheckProvider,
representative: HealthCheckTarget,
originKey: string,
): Promise<ProbeResult> {
const cacheKey = `${provider}|${originKey}`;
const cached = probeCache.get(cacheKey);
if (cached) return cached;
let result: ProbeResult;
if (provider === "local") {
if (probeIndex > 0 && gapMs > 0) await sleep(gapMs);
probeIndex += 1;
result = await local.probe(representative);
} else if (provider === "cloudflare") {
const item = mailboxResults.get(originKey);
if (!mailbox) result = workerNotConfiguredResult();
else if (mailboxStale || !item) result = staleWorkerResult(mailboxColo);
else {
result = {
ok: item.ok,
latencyMs: item.latencyMs,
error: item.error,
colo: mailboxColo,
};
}
} else {
if (!options.globalping?.token?.trim()) {
result = globalpingNotConfiguredResult();
} else {
if (probeIndex > 0 && gapMs > 0) await sleep(gapMs);
probeIndex += 1;
result = await probeWithGlobalping(representative, options.globalping);
}
}
probeCache.set(cacheKey, result);
return result;
}
for (const [originKey, group] of byOrigin) {
const representative =
group.find((t) => t.scope === "binding") ?? group[0]!;
const needed = new Set<HealthCheckProvider>();
for (const target of group) {
for (const provider of targetProviders(target)) needed.add(provider);
}
for (const provider of needed) {
await resolveProvider(provider, representative, originKey);
}
for (const target of group) {
const providers = targetProviders(target);
const sources = providers.map((provider) => ({
provider,
result: probeCache.get(`${provider}|${originKey}`)!,
}));
for (const source of sources) {
logSourceResult(db, target, source.provider, source.result);
}
applyAggregatedStatus(db, target, sources, options);
}
}
repos.pruneStaleIpHealthStatus(db, targets);
return targets.length;
}
@@ -266,6 +538,10 @@ export async function runDomainMonitors(
path: monitor.path,
expected_status: monitor.expected_status,
timeout_ms: monitor.timeout_ms,
verify_tls: false,
provider: "local",
providers: ["local"],
aggregate: "majority",
};
let result: ProbeResult;
if (monitor.type === "http") {
@@ -292,6 +568,7 @@ export async function runDomainMonitors(
result.latencyMs,
{
consecutive_failures: result.ok ? 0 : 1,
consecutive_successes: result.ok ? 1 : 0,
status: prevStatus,
},
thresholds,
+112
View File
@@ -0,0 +1,112 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { CfHealthCheck, HealthCheckTarget, OriginHealthCheck } from "@cfdm/shared";
import type { CloudflareClient } from "../../lib/cf-client.js";
import type { CfHealthCheckPayload } from "../../lib/cloudflare/healthcheck-service.js";
import { AppError } from "../../errors.js";
import type { ProbeResult } from "../health-check-service.js";
import type { HealthCheckProvider } from "./provider.js";
function toPayload(
check: OriginHealthCheck,
address: string,
): CfHealthCheckPayload {
const type = (check.protocol || "TCP").toUpperCase() as "HTTP" | "HTTPS" | "TCP";
const payload: CfHealthCheckPayload = {
address,
name: check.name,
type,
interval: check.interval_sec,
timeout: check.timeout,
retries: check.retries,
consecutive_fails: check.consecutive_fails,
consecutive_successes: check.consecutive_successes,
suspended: check.suspended,
};
if (type === "HTTP" || type === "HTTPS") {
payload.http_config = {
method: check.method ?? "GET",
path: check.path ?? "/",
expected_codes: check.expected_status != null ? [String(check.expected_status)] : ["200"],
};
} else {
payload.tcp_config = { method: "connection_established" };
}
return payload;
}
export class CloudflareHealthCheckProvider implements HealthCheckProvider {
readonly kind = "cloudflare" as const;
constructor(
private readonly db: Db,
private readonly cf: CloudflareClient,
) {}
async probe(target: HealthCheckTarget): Promise<ProbeResult> {
const node = repos.findNodeByIp(this.db, target.ip);
if (!node?.health_check_id) {
return { ok: false, latencyMs: 0, error: "нет Cloudflare Health Check" };
}
const check = repos.getHealthCheck(this.db, node.health_check_id);
if (!check.cf_zone_id || !check.cf_healthcheck_id) {
return { ok: false, latencyMs: 0, error: "Cloudflare Health Check не синхронизирован" };
}
try {
const remote = await this.cf.getHealthCheck(check.cf_zone_id, check.cf_healthcheck_id);
const status = (remote.status ?? "").toLowerCase();
const ok = status === "healthy" || status === "ok";
return {
ok,
latencyMs: 0,
error: ok ? null : remote.status ?? "unhealthy",
};
} catch (err) {
return {
ok: false,
latencyMs: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
async syncCreate(
check: OriginHealthCheck,
zoneId: string,
address: string,
): Promise<CfHealthCheck> {
try {
const remote = await this.cf.createHealthCheck(zoneId, toPayload(check, address));
repos.updateHealthCheck(this.db, check.id, {
cf_healthcheck_id: remote.id,
cf_zone_id: zoneId,
provider: "cloudflare",
});
return remote;
} catch (err) {
if (err instanceof AppError) throw err;
throw AppError.healthcheckCreateFailed(
err instanceof Error ? err.message : "не удалось создать Cloudflare Health Check",
);
}
}
async syncUpdate(
check: OriginHealthCheck,
address: string,
): Promise<CfHealthCheck> {
if (!check.cf_zone_id || !check.cf_healthcheck_id) {
throw AppError.healthcheckCreateFailed("Cloudflare Health Check не привязан к зоне");
}
return this.cf.updateHealthCheck(
check.cf_zone_id,
check.cf_healthcheck_id,
toPayload(check, address),
);
}
async syncDelete(check: OriginHealthCheck): Promise<void> {
if (!check.cf_zone_id || !check.cf_healthcheck_id) return;
await this.cf.deleteHealthCheck(check.cf_zone_id, check.cf_healthcheck_id);
}
}
@@ -0,0 +1,40 @@
import type { HealthCheckTarget } from "@cfdm/shared";
import {
runGlobalpingMeasurement,
type GlobalpingClientOptions,
} from "../../lib/globalping-client.js";
import type { ProbeResult } from "../health-check-service.js";
export function globalpingNotConfiguredResult(): ProbeResult {
return {
ok: false,
latencyMs: 0,
error: "Globalping: токен не задан",
colo: null,
};
}
export async function probeWithGlobalping(
target: HealthCheckTarget,
options: GlobalpingClientOptions,
): Promise<ProbeResult> {
if (!options.token?.trim()) {
return globalpingNotConfiguredResult();
}
try {
const result = await runGlobalpingMeasurement(target, options);
return {
ok: result.ok,
latencyMs: result.latencyMs,
error: result.error,
colo: result.colo,
};
} catch (err) {
return {
ok: false,
latencyMs: 0,
error: err instanceof Error ? err.message : "Globalping: ошибка запроса",
colo: null,
};
}
}
@@ -0,0 +1,21 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export function loadHealthProbeWorkerSource(): string {
const dir = dirname(fileURLToPath(import.meta.url));
const candidates = [
join(dir, "health-probe-worker.mjs"),
join(process.cwd(), "dist/health-probe-worker.mjs"),
join(process.cwd(), "health-probe-worker.mjs"),
join(dir, "../../../../../workers/health-probe/src/index.mjs"),
join(process.cwd(), "../../workers/health-probe/src/index.mjs"),
join(process.cwd(), "workers/health-probe/src/index.mjs"),
];
for (const path of candidates) {
if (existsSync(path)) {
return readFileSync(path, "utf8");
}
}
throw new Error("не найден исходник Worker health-probe");
}
@@ -0,0 +1,185 @@
import { getAppSettings, repos, updateAppSettings, type HealthEngineFallbacks } from "@cfdm/db";
import type { Db } from "@cfdm/db";
import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, targetHasProvider } from "@cfdm/shared";
import type { CloudflareClient } from "../../lib/cf-client.js";
import { AppError } from "../../errors.js";
import { loadHealthProbeWorkerSource } from "./health-probe-script.js";
import {
buildTargetsDoc,
createCloudflareKvMailbox,
toCloudflareCron,
type HealthMailbox,
} from "./mailbox.js";
export const DEFAULT_HEALTH_FALLBACKS: HealthEngineFallbacks = {
healthCheckCron: "0 */2 * * * *",
healthDegradedFailures: 1,
healthDownFailures: 2,
healthLatencyWarnMs: 1000,
healthSuccessRecoveries: 2,
healthWorkerUrl: "",
healthWorkerTokenSet: false,
};
export async function resolveAccountId(
cf: CloudflareClient,
db: Db,
cached?: string | null,
): Promise<string> {
const trimmed = cached?.trim();
if (trimmed) return trimmed;
const domains = repos.listDomains(db);
for (const domain of domains) {
if (!domain.cf_zone_id) continue;
try {
const zone = await cf.getZone(domain.cf_zone_id);
const id = zone.account?.id?.trim();
if (id) return id;
} catch {
// try next zone / accounts list
}
}
const accounts = await cf.listAccounts();
const id = accounts[0]?.id?.trim();
if (!id) {
throw AppError.cloudflare(
"Не удалось определить Cloudflare account_id. Добавьте зону или расширьте права токена (Account Settings Read).",
);
}
return id;
}
export async function ensureKvNamespace(
cf: CloudflareClient,
accountId: string,
existingId?: string | null,
): Promise<string> {
if (existingId?.trim()) return existingId.trim();
const listed = await cf.listKvNamespaces(accountId);
const found = listed.find((ns) => ns.title === HEALTH_PROBE_KV_TITLE);
if (found?.id) return found.id;
const created = await cf.createKvNamespace(accountId, HEALTH_PROBE_KV_TITLE);
if (!created.id) {
throw AppError.cloudflare("Cloudflare не вернул id KV namespace");
}
return created.id;
}
export async function ensureHealthWorker(
db: Db,
cf: CloudflareClient,
fallbacks: HealthEngineFallbacks,
): Promise<{ url: string; kvNamespaceId: string; accountId: string }> {
const settings = getAppSettings(db, fallbacks);
try {
const accountId = await resolveAccountId(cf, db, settings.healthWorkerAccountId);
const kvNamespaceId = await ensureKvNamespace(
cf,
accountId,
settings.healthWorkerKvNamespaceId,
);
const source = loadHealthProbeWorkerSource();
await cf.putWorkerScript({
accountId,
scriptName: HEALTH_PROBE_SCRIPT_NAME,
source,
kvNamespaceId,
});
await cf.putWorkerSchedules(accountId, HEALTH_PROBE_SCRIPT_NAME, [
toCloudflareCron(settings.healthCheckCron),
]);
try {
await cf.enableWorkersDev(accountId, HEALTH_PROBE_SCRIPT_NAME);
} catch {
// workers.dev may already be on
}
const subdomain = await cf.getWorkersSubdomain(accountId);
const url = subdomain
? `https://${HEALTH_PROBE_SCRIPT_NAME}.${subdomain}.workers.dev`
: settings.healthWorkerUrl || `https://${HEALTH_PROBE_SCRIPT_NAME}.workers.dev`;
updateAppSettings(
db,
{
healthWorkerAccountId: accountId,
healthWorkerKvNamespaceId: kvNamespaceId,
healthWorkerUrl: url,
healthWorkerError: null,
healthWorkerDeployedAt: new Date().toISOString(),
},
fallbacks,
);
await syncCloudflareTargetsToKv(db, cf, fallbacks);
return { url, kvNamespaceId, accountId };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
updateAppSettings(db, { healthWorkerError: message }, fallbacks);
throw err;
}
}
export async function maybeEnsureHealthWorker(
db: Db,
cf: CloudflareClient,
fallbacks: HealthEngineFallbacks,
): Promise<void> {
const hasCloudflare = repos
.listHealthCheckTargets(db)
.some((target) => targetHasProvider(target, "cloudflare"));
if (!hasCloudflare) return;
const settings = getAppSettings(db, fallbacks);
if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) {
await syncCloudflareTargetsToKv(db, cf, fallbacks);
return;
}
await ensureHealthWorker(db, cf, fallbacks);
}
export function mailboxFromSettings(
db: Db,
cf: CloudflareClient,
fallbacks: HealthEngineFallbacks,
): HealthMailbox | null {
const settings = getAppSettings(db, fallbacks);
const accountId = settings.healthWorkerAccountId.trim();
const ns = settings.healthWorkerKvNamespaceId.trim();
if (!accountId || !ns) return null;
return createCloudflareKvMailbox(cf, accountId, ns);
}
export async function syncCloudflareTargetsToKv(
db: Db,
cf: CloudflareClient,
fallbacks: HealthEngineFallbacks,
mailbox?: HealthMailbox | null,
): Promise<void> {
const box = mailbox ?? mailboxFromSettings(db, cf, fallbacks);
if (!box) return;
const next = buildTargetsDoc(repos.listHealthCheckTargets(db));
const current = await box.getTargets();
if (current?.fingerprint === next.fingerprint) return;
await box.putTargets(next);
}
export function fireEnsureHealthWorker(
db: Db,
cf: CloudflareClient,
fallbacks: HealthEngineFallbacks,
log?: { warn: (obj: unknown, msg: string) => void },
): void {
if (process.env.VITEST) return;
if (!cf.isConfigured) return;
const hasCloudflare = repos
.listHealthCheckTargets(db)
.some((target) => targetHasProvider(target, "cloudflare"));
if (!hasCloudflare) {
void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => {
log?.warn({ err }, "health worker KV sync failed");
});
return;
}
void maybeEnsureHealthWorker(db, cf, fallbacks).catch((err) => {
log?.warn({ err }, "health worker ensure failed");
});
}
+11
View File
@@ -0,0 +1,11 @@
import type { HealthCheckTarget } from "@cfdm/shared";
import { probeTarget, type ProbeResult } from "../health-check-service.js";
import type { HealthCheckProvider } from "./provider.js";
export class LocalHealthCheckProvider implements HealthCheckProvider {
readonly kind = "local" as const;
probe(target: HealthCheckTarget): Promise<ProbeResult> {
return probeTarget(target);
}
}
+145
View File
@@ -0,0 +1,145 @@
import type {
HealthCheckTarget,
HealthProbeResultItem,
HealthProbeResultsDoc,
HealthProbeTargetItem,
HealthProbeTargetsDoc,
} from "@cfdm/shared";
import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, targetHasProvider } from "@cfdm/shared";
import type { CloudflareClient } from "../../lib/cf-client.js";
export interface HealthMailbox {
getTargets(): Promise<HealthProbeTargetsDoc | null>;
putTargets(doc: HealthProbeTargetsDoc): Promise<void>;
getResults(): Promise<HealthProbeResultsDoc | null>;
}
export function createCloudflareKvMailbox(
cf: CloudflareClient,
accountId: string,
namespaceId: string,
): HealthMailbox {
return {
async getTargets() {
return readJson<HealthProbeTargetsDoc>(cf, accountId, namespaceId, HEALTH_KV_TARGETS_KEY);
},
async putTargets(doc) {
await cf.kvPut(accountId, namespaceId, HEALTH_KV_TARGETS_KEY, JSON.stringify(doc));
},
async getResults() {
return readJson<HealthProbeResultsDoc>(cf, accountId, namespaceId, HEALTH_KV_RESULTS_KEY);
},
};
}
async function readJson<T>(
cf: CloudflareClient,
accountId: string,
namespaceId: string,
key: string,
): Promise<T | null> {
const raw = await cf.kvGet(accountId, namespaceId, key);
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export function originProbeKey(target: HealthCheckTarget): string {
const port = target.port ?? (target.type === "http" ? 80 : 80);
const ip = String(target.ip || "").trim().toLowerCase();
if (target.type === "http") {
const path = (target.path?.trim() || "/") || "/";
const expected = target.expected_status ?? "";
return `http|${ip}|${port}|${path}|${expected}`;
}
if (target.type === "tcp") return `tcp|${ip}|${port}`;
if (target.type === "ping") {
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
}
if (target.type === "dns") {
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
}
return `${target.type}|${ip}|${port}`;
}
export function cloudflareMailboxTargets(
targets: HealthCheckTarget[],
): HealthProbeTargetItem[] {
const unique = new Map<string, HealthProbeTargetItem>();
for (const target of targets) {
if (!targetHasProvider(target, "cloudflare")) continue;
if (target.type !== "tcp" && target.type !== "http") continue;
const key = originProbeKey(target);
if (unique.has(key)) continue;
unique.set(key, {
key,
ip: target.ip,
hostname: target.hostname || target.ip,
type: target.type,
port: target.port ?? (target.type === "http" ? 80 : 80),
path: target.path ?? "/",
expectedStatus: target.expected_status,
timeoutMs: target.timeout_ms ?? 3000,
verifyTls: Boolean(target.verify_tls),
});
}
return [...unique.values()].sort((a, b) => a.key.localeCompare(b.key));
}
export function fingerprintTargets(items: HealthProbeTargetItem[]): string {
return items
.map(
(item) =>
`${item.key}|${item.hostname}|${item.timeoutMs ?? ""}|${item.verifyTls ? "1" : "0"}`,
)
.join(";");
}
export function buildTargetsDoc(targets: HealthCheckTarget[]): HealthProbeTargetsDoc {
const items = cloudflareMailboxTargets(targets);
return {
fingerprint: fingerprintTargets(items),
updatedAt: new Date().toISOString(),
items,
};
}
export function indexResults(
doc: HealthProbeResultsDoc | null,
): Map<string, HealthProbeResultItem> {
const map = new Map<string, HealthProbeResultItem>();
if (!doc?.items) return map;
for (const item of doc.items) {
map.set(item.key, item);
}
return map;
}
export function isResultsStale(doc: HealthProbeResultsDoc | null, staleAfterMs: number): boolean {
if (!doc?.probedAt) return true;
const ts = Date.parse(doc.probedAt);
if (!Number.isFinite(ts)) return true;
return Date.now() - ts > staleAfterMs;
}
/** Drop seconds from toad 6-field cron for Cloudflare Workers (5-field). */
export function toCloudflareCron(expr: string): string {
const parts = expr.trim().split(/\s+/).filter(Boolean);
if (parts.length === 6) return parts.slice(1).join(" ");
if (parts.length === 5) return parts.join(" ");
throw new Error("некорректное cron-выражение");
}
export function cronStaleAfterMs(expr: string): number {
const cf = toCloudflareCron(expr);
const minute = cf.split(/\s+/)[0] ?? "*";
if (minute.startsWith("*/")) {
const n = Number(minute.slice(2));
if (Number.isFinite(n) && n > 0) return Math.max(n * 2, 5) * 60_000;
}
if (minute === "*") return 10 * 60_000;
return 10 * 60_000;
}
+7
View File
@@ -0,0 +1,7 @@
import type { HealthCheckTarget } from "@cfdm/shared";
import type { ProbeResult } from "../health-check-service.js";
export interface HealthCheckProvider {
readonly kind: "local" | "cloudflare";
probe(target: HealthCheckTarget): Promise<ProbeResult>;
}
@@ -0,0 +1,75 @@
import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
export interface HealthThresholds {
degradedFailures: number;
downFailures: number;
successRecoveries: number;
latencyWarnMs: number;
}
export interface HealthCounters {
status: string;
consecutive_failures: number;
consecutive_successes?: number;
}
export interface NextHealth {
legacy: IpHealthState;
node: NodeHealthState;
failures: number;
successes: number;
}
function wasHealthy(status: string | undefined): boolean {
return status === "up" || status === "healthy";
}
function wasUnhealthy(status: string | undefined): boolean {
return (
status === "down" ||
status === "unhealthy" ||
status === "checking" ||
status === "degraded"
);
}
export function nextHealthState(
ok: boolean,
latencyMs: number,
prev: HealthCounters | null,
thresholds: HealthThresholds,
): NextHealth {
if (!ok) {
const failures = (prev?.consecutive_failures ?? 0) + 1;
if (failures >= thresholds.downFailures) {
return { legacy: "down", node: "unhealthy", failures, successes: 0 };
}
return { legacy: "degraded", node: "degraded", failures, successes: 0 };
}
if (latencyMs > thresholds.latencyWarnMs) {
return { legacy: "degraded", node: "degraded", failures: 0, successes: 0 };
}
if (!prev || wasHealthy(prev.status) || !wasUnhealthy(prev.status)) {
return {
legacy: "up",
node: "healthy",
failures: 0,
successes: (prev?.consecutive_successes ?? 0) + 1,
};
}
const successes = (prev.consecutive_successes ?? 0) + 1;
if (successes >= thresholds.successRecoveries) {
return { legacy: "up", node: "healthy", failures: 0, successes };
}
return { legacy: "unknown", node: "checking", failures: 0, successes };
}
export function toLegacyHealth(status: NodeHealthState | IpHealthState): IpHealthState {
if (status === "healthy" || status === "up") return "up";
if (status === "unhealthy" || status === "down") return "down";
if (status === "degraded") return "degraded";
return "unknown";
}
+13
View File
@@ -0,0 +1,13 @@
export function workerNotConfiguredResult(): {
ok: false;
latencyMs: number;
error: string;
colo: null;
} {
return {
ok: false,
latencyMs: 0,
error: "Cloudflare Worker не настроен (нет KV mailbox)",
colo: null,
};
}
+150
View File
@@ -0,0 +1,150 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
CreateServiceNodeInput,
ServiceNode,
ServiceOverview,
UpdateServiceNodeInput,
} from "@cfdm/shared";
import { AppError } from "../errors.js";
import { isValidIpv4 } from "../lib/validators.js";
import { getView } from "./service-config-service.js";
import { selectActiveIpsByMode } from "./routing/index.js";
function assertAddress(address: string): void {
if (!isValidIpv4(address)) {
throw AppError.invalidIp(`Некорректный IP-адрес: ${address}`);
}
}
export function listNodes(db: Db, serviceId: number): ServiceNode[] {
repos.getService(db, serviceId);
return repos.listNodes(db, serviceId);
}
export function createNode(
db: Db,
serviceId: number,
input: CreateServiceNodeInput,
): ServiceNode {
repos.getService(db, serviceId);
assertAddress(input.address);
try {
return repos.createNode(db, serviceId, {
address: input.address,
protocol: input.protocol,
port: input.port,
enabled: input.enabled,
priority: input.priority,
weight: input.weight,
health_check_id: input.health_check_id,
});
} catch (err) {
if (err instanceof Error && err.name === "ConflictError") {
throw AppError.conflict(err.message);
}
throw err;
}
}
export function updateNode(
db: Db,
serviceId: number,
nodeId: number,
patch: UpdateServiceNodeInput,
): ServiceNode {
const node = repos.getNode(db, nodeId);
if (node.service_id !== serviceId) {
throw AppError.notFound(`node ${nodeId}`);
}
if (patch.address) assertAddress(patch.address);
return repos.updateNode(db, nodeId, patch);
}
export function deleteNode(db: Db, serviceId: number, nodeId: number): void {
const node = repos.getNode(db, nodeId);
if (node.service_id !== serviceId) {
throw AppError.notFound(`node ${nodeId}`);
}
repos.deleteNode(db, nodeId);
}
export async function getOverview(
db: Db,
serviceId: number,
): Promise<ServiceOverview> {
const service = await getView(db, serviceId);
const nodes = repos.listNodes(db, serviceId);
const bindings = repos.listBindingsByService(db, serviceId);
const first = bindings[0];
const routing = first?.routing_strategy ?? first?.lb_mode ?? "round_robin";
const healthCheck =
nodes
.map((n) => n.health_check_id)
.find((id): id is number => id != null) != null
? repos.getHealthCheck(
db,
nodes.find((n) => n.health_check_id != null)!.health_check_id!,
)
: null;
const active = new Set<string>();
for (const binding of bindings) {
const metas = repos.listBindingIpsWithMeta(db, binding.id);
const rows = metas.map((entry) => {
const status = repos.getIpHealthStatusRow(db, "binding", binding.id, entry.ip);
return {
ip: entry.ip,
weight: entry.weight,
priority: entry.priority,
health: status ? status.status : ("unknown" as const),
};
});
for (const ip of selectActiveIpsByMode(
{
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
},
rows,
)) {
active.add(ip);
}
}
return {
service,
nodes,
health_check: healthCheck,
routing_strategy: routing,
active_addresses: [...active],
};
}
export function opsSummary(db: Db) {
const allNodes = repos.listAllNodes(db);
const services = repos.listServices(db);
const domains = repos.listDomains(db);
const healthy = allNodes.filter(
(n) => n.health_status === "healthy" || n.health_status === "up",
).length;
const unhealthy = allNodes.filter(
(n) => n.health_status === "unhealthy" || n.health_status === "down",
).length;
const failoverActive = repos.listAllBindings(db).filter((binding) => {
if (binding.lb_mode !== "failover" || !binding.health_check_enabled) {
return false;
}
return binding.target_ips.some((ip) => {
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
return row?.status === "down";
});
}).length;
return {
domains: domains.length,
services: services.length,
nodes: allNodes.length,
healthy,
unhealthy,
active_failovers: failoverActive,
};
}
@@ -0,0 +1,126 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
CreateOriginHealthCheckInput,
OriginHealthCheck,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import { CloudflareHealthCheckProvider } from "./health/cloudflare.js";
export function listOriginHealthChecks(db: Db): OriginHealthCheck[] {
return repos.listHealthChecks(db);
}
export function getOriginHealthCheck(db: Db, id: number): OriginHealthCheck {
return repos.getHealthCheck(db, id);
}
export async function createOriginHealthCheck(
db: Db,
cf: CloudflareClient,
input: CreateOriginHealthCheckInput,
): Promise<OriginHealthCheck> {
const protocol = (input.protocol ?? "tcp").toLowerCase();
const check = repos.createHealthCheck(db, {
provider: input.provider,
name: input.name,
cf_zone_id: input.cf_zone_id ?? null,
protocol,
path: input.path,
method: input.method,
timeout: input.timeout,
interval_sec: input.interval_sec,
retries: input.retries,
expected_status: input.expected_status,
consecutive_fails: input.consecutive_fails,
consecutive_successes: input.consecutive_successes,
suspended: input.suspended,
});
if (input.node_id) {
const node = repos.getNode(db, input.node_id);
repos.updateNode(db, node.id, { health_check_id: check.id });
}
if (input.provider === "cloudflare") {
const zoneId = input.cf_zone_id;
if (!zoneId) {
throw AppError.zoneNotFound("укажите зону Cloudflare для Health Check");
}
const address = input.node_id
? repos.getNode(db, input.node_id).address
: check.name;
const provider = new CloudflareHealthCheckProvider(db, cf);
await provider.syncCreate(check, zoneId, address);
return repos.getHealthCheck(db, check.id);
}
return check;
}
export async function updateOriginHealthCheck(
db: Db,
cf: CloudflareClient,
id: number,
patch: Partial<CreateOriginHealthCheckInput>,
): Promise<OriginHealthCheck> {
const current = repos.getHealthCheck(db, id);
const updated = repos.updateHealthCheck(db, id, {
provider: patch.provider,
name: patch.name,
cf_zone_id: patch.cf_zone_id,
protocol: patch.protocol?.toLowerCase(),
path: patch.path,
method: patch.method,
timeout: patch.timeout,
interval_sec: patch.interval_sec,
retries: patch.retries,
expected_status: patch.expected_status,
consecutive_fails: patch.consecutive_fails,
consecutive_successes: patch.consecutive_successes,
suspended: patch.suspended,
});
if (updated.provider === "cloudflare" && updated.cf_healthcheck_id) {
const address = repos.findNodeByIp(db, updated.name)?.address ?? updated.name;
const provider = new CloudflareHealthCheckProvider(db, cf);
await provider.syncUpdate(updated, address);
}
void current;
return repos.getHealthCheck(db, id);
}
export async function deleteOriginHealthCheck(
db: Db,
cf: CloudflareClient,
id: number,
): Promise<void> {
const check = repos.getHealthCheck(db, id);
if (check.provider === "cloudflare") {
const provider = new CloudflareHealthCheckProvider(db, cf);
await provider.syncDelete(check);
}
repos.deleteHealthCheck(db, id);
}
export async function syncOriginHealthCheck(
db: Db,
cf: CloudflareClient,
id: number,
): Promise<OriginHealthCheck> {
const check = repos.getHealthCheck(db, id);
if (check.provider !== "cloudflare") {
throw AppError.validation("синхронизация доступна только для Cloudflare Health Checks");
}
if (!check.cf_zone_id) {
throw AppError.zoneNotFound();
}
const provider = new CloudflareHealthCheckProvider(db, cf);
const address = repos.findNodeByIp(db, check.name)?.address ?? check.name;
if (check.cf_healthcheck_id) {
await provider.syncUpdate(check, address);
} else {
await provider.syncCreate(check, check.cf_zone_id, address);
}
return repos.getHealthCheck(db, id);
}
@@ -0,0 +1,25 @@
const locks = new Map<number, Promise<void>>();
export async function withBindingLock<T>(
bindingId: number,
fn: () => Promise<T>,
): Promise<T> {
const previous = locks.get(bindingId) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
});
locks.set(
bindingId,
previous.then(() => current).catch(() => current),
);
await previous.catch(() => undefined);
try {
return await fn();
} finally {
release();
if (locks.get(bindingId) === current) {
locks.delete(bindingId);
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { LbIpRow } from "./types.js";
import { isHealthy } from "./health.js";
export function failoverDesired(rows: LbIpRow[]): string[] {
if (rows.length === 0) return [];
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
const sorted = [...pool].sort(
(a, b) => a.priority - b.priority || a.weight - b.weight,
);
const minPriority = sorted[0]!.priority;
const primaries = sorted.filter((r) => r.priority === minPriority);
if (healthy.length > 0) {
return primaries.map((r) => r.ip);
}
return [sorted[0]!.ip];
}
+5
View File
@@ -0,0 +1,5 @@
import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
export function isHealthy(state: IpHealthState | NodeHealthState | string): boolean {
return state === "up" || state === "healthy";
}
+26
View File
@@ -0,0 +1,26 @@
import type { LbMode } from "@cfdm/shared";
import { failoverDesired } from "./failover.js";
import { roundRobinDesired } from "./round-robin.js";
import type { LbIpRow, LbTargetConfig } from "./types.js";
export type { LbIpRow, LbTargetConfig } from "./types.js";
export { isHealthy } from "./health.js";
export { withBindingLock } from "./binding-lock.js";
export function selectActiveIpsByMode(
config: LbTargetConfig,
rows: LbIpRow[],
): string[] {
if (rows.length === 0) return [];
if (config.lb_mode === "failover") {
return failoverDesired(rows);
}
// weighted = round_robin on DNS (one A per IP)
return roundRobinDesired(rows);
}
export function strategyLabel(mode: LbMode): string {
if (mode === "failover") return "Failover";
if (mode === "weighted") return "Round Robin (weighted alias)";
return "Round Robin";
}
@@ -0,0 +1,8 @@
import type { LbIpRow } from "./types.js";
import { isHealthy } from "./health.js";
export function roundRobinDesired(rows: LbIpRow[]): string[] {
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
return pool.map((r) => r.ip);
}
+13
View File
@@ -0,0 +1,13 @@
import type { IpHealthState, LbMode } from "@cfdm/shared";
export interface LbTargetConfig {
lb_mode: LbMode;
health_check_enabled: boolean;
}
export interface LbIpRow {
ip: string;
weight: number;
priority: number;
health: IpHealthState;
}
+412 -212
View File
@@ -2,6 +2,8 @@ import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
DnsRecord,
HealthCheckAggregate,
HealthCheckProvider,
HealthCheckScope,
HealthCheckType,
IpHealthState,
@@ -16,6 +18,7 @@ import {
SYNC_PENDING_PUSH,
SYNC_SYNCED,
dnsRecordNamesMatch,
isIpLiteral,
normalizeDnsRecordName,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
@@ -24,6 +27,54 @@ import { isValidIpv4 } from "../lib/validators.js";
import * as dnsService from "./dns-service.js";
import * as domainService from "./domain-service.js";
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
import {
isHealthy,
selectActiveIpsByMode,
withBindingLock,
type LbIpRow,
type LbTargetConfig,
} from "./routing/index.js";
export type { LbIpRow, LbTargetConfig };
export { selectActiveIpsByMode };
export function failoverARecordDiff(
existingA: readonly string[],
desiredIps: readonly string[],
): { added: string[]; removed: string[] } {
const before = new Set(existingA);
const after = new Set(desiredIps);
return {
added: desiredIps.filter((ip) => !before.has(ip)),
removed: existingA.filter((ip) => !after.has(ip)),
};
}
function recordFailoverDnsDiff(
db: Db,
bindingId: number,
hostname: string,
zoneName: string,
existingRecords: DnsRecord[],
desiredIps: string[],
): void {
const existingA = existingRecords
.filter((record) => record.record_type.toUpperCase() === "A")
.map((record) => record.content);
const { added, removed } = failoverARecordDiff(existingA, desiredIps);
if (added.length === 0 && removed.length === 0) return;
const binding = repos.getBinding(db, bindingId);
repos.insertFailoverLog(db, {
serviceId: binding.service_id,
bindingId,
fqdn: fqdnToDisplay(hostname, zoneName),
entries: [
...added.map((ip) => ({ ip, action: "added" as const })),
...removed.map((ip) => ({ ip, action: "removed" as const })),
],
});
}
export interface ServiceDomainInput {
fqdn: string;
@@ -40,6 +91,10 @@ export interface ServiceDomainInput {
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
}
export interface ToggleRequest {
@@ -59,6 +114,10 @@ export interface ServiceGroupBody {
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
}
export interface UpdateServiceGroupBody {
@@ -74,6 +133,10 @@ export interface UpdateServiceGroupBody {
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
}
export interface UpdateServiceConfigRequest {
@@ -134,54 +197,6 @@ function aggregateSyncStatus(statuses: string[]): string | null {
return statuses[0] ?? null;
}
function isHealthy(state: IpHealthState): boolean {
return state === "up" || state === "unknown";
}
export interface LbTargetConfig {
lb_mode: LbMode;
health_check_enabled: boolean;
}
export interface LbIpRow {
ip: string;
weight: number;
priority: number;
health: IpHealthState;
}
export function selectActiveIpsByMode(
config: LbTargetConfig,
rows: LbIpRow[],
): string[] {
if (rows.length === 0) return [];
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
if (config.lb_mode === "failover") {
const sorted = [...pool].sort(
(a, b) => a.priority - b.priority || a.weight - b.weight,
);
const minPriority = sorted[0]!.priority;
const primaries = sorted.filter((r) => r.priority === minPriority);
if (healthy.length > 0) {
return primaries.map((r) => r.ip);
}
return [sorted[0]!.ip];
}
if (config.lb_mode === "weighted") {
// Cloudflare не допускает дублирования A-записей с одинаковым name+content,
// поэтому weighted на уровне DNS реализован как RR по одному A на IP.
// Веса сохраняются в БД и используются для приоритизации/отображения;
// точное weighted-распределение требует CF Load Balancer (см. README).
return pool.map((r) => r.ip);
}
return pool.map((r) => r.ip);
}
function getBindingLbState(
db: Db,
bindingId: number,
@@ -278,7 +293,11 @@ async function collectKnownZones(
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const service = repos.getService(db, serviceId);
const ips = repos.listServiceIps(db, serviceId);
const ipRows = repos.listServiceIpRows(db, serviceId);
const ips = ipRows.map((row) => row.ip);
const ip_enabled = Object.fromEntries(
ipRows.map((row) => [row.ip, row.enabled]),
);
const bindings = repos.listBindingsByService(db, serviceId);
const domainViews = bindings.map((binding) => {
@@ -303,6 +322,11 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
}
const { config, rows } = getBindingLbState(db, binding.id);
const bindingActiveIps = targetCname
? []
: selectActiveIpsByMode(config, rows);
return {
binding_id: binding.id,
domain_id: binding.domain_id,
@@ -322,10 +346,25 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
health_check_expected_status: binding.health_check_expected_status,
health_check_interval_sec: binding.health_check_interval_sec,
health_check_timeout_ms: binding.health_check_timeout_ms,
health_check_verify_tls: binding.health_check_verify_tls,
health_check_provider: binding.health_check_provider ?? "local",
health_check_providers: binding.health_check_providers ?? [
binding.health_check_provider ?? "local",
],
health_check_aggregate: binding.health_check_aggregate ?? "majority",
cert_monitoring: binding.cert_monitoring ?? "auto",
sync_status: aggregateSyncStatus(statuses),
active_ips: bindingActiveIps,
};
});
const activeIps = new Set<string>();
for (const domain of domainViews) {
for (const ip of domain.active_ips) {
activeIps.add(ip);
}
}
return {
id: service.id,
name: service.name,
@@ -339,26 +378,108 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
created_at: service.created_at,
updated_at: service.updated_at,
ips,
ip_enabled,
domains: domainViews,
health_status: "unknown",
health_latency_ms: null,
ip_health: [],
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
active_ips: [...activeIps],
};
}
const HEALTH_RANK: Record<string, number> = {
down: 3,
degraded: 2,
unknown: 1,
up: 0,
};
function cnameLookupKeys(value: string, zoneName?: string | null): string[] {
const trimmed = value.trim();
if (!trimmed) return [];
const noDot = trimmed.replace(/\.+$/, "");
const lower = noDot.toLowerCase();
const keys = new Set([trimmed, noDot, lower]);
if (zoneName && !lower.includes(".")) {
keys.add(`${lower}.${zoneName.trim().toLowerCase().replace(/\.+$/, "")}`);
}
return [...keys];
}
type ServiceHealthRow = {
ip: string;
status: IpHealthState;
latency_ms: number | null;
last_checked_at: string | null;
last_error: string | null;
provider: ServiceView["ip_health"][number]["provider"];
colo: string | null;
};
/** Health rows keyed by CNAME hostname (legacy probes) applied to service IPs. */
function fallbackCnameHealth(
rows: ServiceHealthRow[],
view: ServiceView,
): ServiceHealthRow | undefined {
const cnameKeys = new Set<string>();
for (const domain of view.domains ?? []) {
const cname = domain.target_cname?.trim();
if (!cname) continue;
for (const key of cnameLookupKeys(cname, domain.zone_name)) {
cnameKeys.add(key);
}
}
const hostnameRows = rows.filter((row) => !isIpLiteral(row.ip));
if (hostnameRows.length === 0) return undefined;
const matched =
cnameKeys.size === 0
? hostnameRows
: hostnameRows.filter((row) =>
cnameLookupKeys(row.ip).some((key) => cnameKeys.has(key)),
);
const candidates = matched.length > 0 ? matched : hostnameRows;
return candidates.reduce((worst, row) =>
(HEALTH_RANK[row.status] ?? 0) > (HEALTH_RANK[worst.status] ?? 0)
? row
: worst,
);
}
function attachServiceHealth(
db: Db,
views: ServiceView[],
): ServiceView[] {
const healthByService = repos.aggregateIpHealthByServiceIds(
db,
views.map((v) => v.id),
);
const ids = views.map((v) => v.id);
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
return views.map((view) => {
const health = healthByService.get(view.id);
const rows = ipHealthByService.get(view.id) ?? [];
const byIp = new Map(rows.map((row) => [row.ip, row]));
const cnameFallback = fallbackCnameHealth(rows, view);
const aRecordIps = new Set(
(view.domains ?? []).flatMap((domain) =>
domain.target_cname?.trim() ? [] : (domain.target_ips ?? []),
),
);
const ip_health = (view.ips ?? []).map((ip) => {
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
return {
ip,
status: row?.status ?? ("unknown" as const),
latency_ms: row?.latency_ms ?? null,
last_checked_at: row?.last_checked_at ?? null,
last_error: row?.last_error ?? null,
provider: row?.provider ?? "local",
colo: row?.colo ?? null,
};
});
return {
...view,
health_status: health?.health_status ?? "unknown",
health_latency_ms: health?.health_latency_ms ?? null,
ip_health,
};
});
}
@@ -400,23 +521,27 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
const withHealth = attachServiceHealth(db, allServiceViews);
const healthById = new Map(withHealth.map((v) => [v.id, v]));
const groupHealthById = repos.aggregateIpHealthByRefs(
const groupHealthById = repos.aggregateGroupScopeHealthByIds(
db,
"group",
groups.map((g) => g.id),
);
const groupViews = groupViewsRaw.map((group) => {
const services = group.services.map(
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null },
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null, ip_health: [], ip_enabled: {} },
);
const groupScopeHealth = groupHealthById.get(group.id);
const merged = repos.mergeHealthAggregates([
groupScopeHealth,
...services.map((s) => ({
// Only enabled services feed the group badge — a disabled service with a
// stale `down` must not keep the group red while live members are OK.
const enabledServiceHealth = services
.filter((s) => s.enabled)
.map((s) => ({
health_status: s.health_status,
health_latency_ms: s.health_latency_ms,
})),
}));
const merged = repos.mergeHealthAggregates([
groupScopeHealth,
...enabledServiceHealth,
]);
return {
...group,
@@ -432,6 +557,8 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
...s,
health_status: "unknown" as const,
health_latency_ms: null,
ip_health: [],
ip_enabled: {},
},
);
@@ -592,6 +719,14 @@ async function syncBindingADns(
if (desiredIps.length === 0) {
repos.setBindingDnsRecordId(db, bindingId, null);
recordFailoverDnsDiff(
db,
bindingId,
hostname,
zoneName,
existingRecords,
desiredIps,
);
return;
}
@@ -640,6 +775,14 @@ async function syncBindingADns(
}
repos.setBindingDnsRecordId(db, bindingId, primaryId);
recordFailoverDnsDiff(
db,
bindingId,
hostname,
zoneName,
existingRecords,
desiredIps,
);
}
async function cleanupBindingDns(
@@ -815,49 +958,6 @@ async function findOrImportDnsARecord(
);
}
async function serviceBindingsExistInDns(
db: Db,
cf: CloudflareClient,
serviceId: number,
): Promise<boolean> {
const bindings = repos.listBindingsByService(db, serviceId);
if (bindings.length === 0) return false;
for (const binding of bindings) {
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) {
const record = await findOrImportDnsRecord(
db,
cf,
binding.domain_id,
binding.zone_name,
binding.hostname,
"CNAME",
cnameTarget,
);
if (!record) return false;
continue;
}
const targetIps = repos.listBindingIps(db, binding.id);
if (targetIps.length === 0) return false;
for (const ip of targetIps) {
const record = await findOrImportDnsARecord(
db,
cf,
binding.domain_id,
binding.zone_name,
binding.hostname,
ip,
);
if (!record) return false;
}
}
return true;
}
async function syncServiceBindingsToDns(
db: Db,
cf: CloudflareClient,
@@ -1168,95 +1268,101 @@ export async function updateConfig(
let removedBindingIds: number[] = [];
if (req.domains) {
if (req.domains.length > 0) {
for (const input of req.domains) {
const fqdn = input.fqdn.trim();
if (!fqdn) continue;
const targetCname = bindingTargetCname(input);
const targetIps = bindingTargetIps(input);
if (!targetCname) {
validateTargetIpsInPool(targetIps, ips);
} else if (targetIps.length > 0) {
throw AppError.validation(
`укажите либо IP, либо CNAME для ${fqdn}`,
);
}
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
const binding =
repos.findBinding(db, id, domainId, hostname) ??
repos.insertBinding(db, domainId, id, hostname, null);
keptBindingIds.push(binding.id);
const targetIpWeights = input.target_ip_weights ?? {};
const targetIpPriorities = input.target_ip_priorities ?? {};
const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({
ip,
weight: targetIpWeights[ip] ?? 1,
priority: targetIpPriorities[ip] ?? 1,
}));
repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
repos.setBindingCnameTarget(db, binding.id, targetCname);
if (
input.lb_mode !== undefined ||
input.health_check_enabled !== undefined ||
input.health_check_type !== undefined ||
input.health_check_port !== undefined ||
input.health_check_path !== undefined ||
input.health_check_expected_status !== undefined ||
input.health_check_interval_sec !== undefined ||
input.health_check_timeout_ms !== undefined
) {
repos.updateBindingLbConfig(db, binding.id, {
lb_mode: input.lb_mode,
health_check_enabled: input.health_check_enabled,
health_check_type: input.health_check_type,
health_check_port: input.health_check_port,
health_check_path: input.health_check_path,
health_check_expected_status: input.health_check_expected_status,
health_check_interval_sec: input.health_check_interval_sec,
health_check_timeout_ms: input.health_check_timeout_ms,
});
}
if (pushDns) {
let effectiveIps = targetIps;
const refreshedBinding = repos.getBinding(db, binding.id);
if (refreshedBinding.health_check_enabled) {
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
effectiveIps = activeIps;
}
}
await syncBindingDns(
db,
cf,
binding.id,
domainId,
hostname,
effectiveIps,
targetCname,
);
}
for (const input of req.domains) {
const fqdn = input.fqdn.trim();
if (!fqdn) continue;
const targetCname = bindingTargetCname(input);
const targetIps = bindingTargetIps(input);
if (!targetCname) {
validateTargetIpsInPool(targetIps, ips);
} else if (targetIps.length > 0) {
throw AppError.validation(
`укажите либо IP, либо CNAME для ${fqdn}`,
);
}
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
removedBindingIds = removed.map((binding) => binding.id);
for (const binding of removed) {
await cleanupBindingDns(
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
const binding =
repos.findBinding(db, id, domainId, hostname) ??
repos.insertBinding(db, domainId, id, hostname, null);
keptBindingIds.push(binding.id);
const targetIpWeights = input.target_ip_weights ?? {};
const targetIpPriorities = input.target_ip_priorities ?? {};
const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({
ip,
weight: targetIpWeights[ip] ?? 1,
priority: targetIpPriorities[ip] ?? 1,
}));
repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
repos.setBindingCnameTarget(db, binding.id, targetCname);
if (
input.lb_mode !== undefined ||
input.health_check_enabled !== undefined ||
input.health_check_type !== undefined ||
input.health_check_port !== undefined ||
input.health_check_path !== undefined ||
input.health_check_expected_status !== undefined ||
input.health_check_interval_sec !== undefined ||
input.health_check_timeout_ms !== undefined ||
input.health_check_verify_tls !== undefined ||
input.health_check_provider !== undefined ||
input.health_check_providers !== undefined ||
input.health_check_aggregate !== undefined
) {
repos.updateBindingLbConfig(db, binding.id, {
lb_mode: input.lb_mode,
health_check_enabled: input.health_check_enabled,
health_check_type: input.health_check_type,
health_check_port: input.health_check_port,
health_check_path: input.health_check_path,
health_check_expected_status: input.health_check_expected_status,
health_check_interval_sec: input.health_check_interval_sec,
health_check_timeout_ms: input.health_check_timeout_ms,
health_check_verify_tls: input.health_check_verify_tls,
health_check_provider: input.health_check_provider,
health_check_providers: input.health_check_providers,
health_check_aggregate: input.health_check_aggregate,
});
}
if (pushDns) {
let effectiveIps = targetIps;
const refreshedBinding = repos.getBinding(db, binding.id);
if (refreshedBinding.health_check_enabled) {
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
effectiveIps = activeIps;
}
}
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
domainId,
hostname,
effectiveIps,
targetCname,
);
}
repos.deleteBindingsExcept(db, id, keptBindingIds);
}
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
removedBindingIds = removed.map((binding) => binding.id);
for (const binding of removed) {
await cleanupBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
);
}
repos.deleteBindingsExcept(db, id, keptBindingIds);
} else if (ipsUpdated) {
const bindings = repos.listBindingsByService(db, id);
for (const binding of bindings) {
@@ -1272,22 +1378,31 @@ export async function updateConfig(
}
service = repos.getService(db, id);
if (shouldPushDns(db, service)) {
await syncServiceBindingsToDns(db, cf, id);
await syncGroupDomainForService(db, cf, id);
} else if (
const remainingBindings = repos.listBindingsByService(db, id);
// Creating/editing with FQDN bindings should publish DNS. Previously a
// disabled service stayed off unless records already existed in Cloudflare
// (chicken-and-egg for brand-new services).
if (
req.domains &&
req.domains.length > 0 &&
!service.enabled &&
(await serviceBindingsExistInDns(db, cf, id))
!service.enabled
) {
repos.setServiceEnabled(db, id, true);
await syncServiceBindingsToDns(db, cf, id);
service = repos.getService(db, id);
}
if (shouldPushDns(db, service)) {
if (remainingBindings.length > 0) {
await syncServiceBindingsToDns(db, cf, id);
}
await syncGroupDomainForService(db, cf, id);
}
void syncServiceToVpsTracker(db, id, removedBindingIds);
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
return view!;
}
@@ -1299,7 +1414,7 @@ export async function createGroup(
): Promise<ServiceGroup> {
const groupType = body.type?.trim() || "custom";
const domain = await normalizeGroupDomain(db, cf, body.domain);
return repos.createServiceGroup(
const group = repos.createServiceGroup(
db,
body.name,
groupType,
@@ -1314,8 +1429,14 @@ export async function createGroup(
health_check_expected_status: body.health_check_expected_status,
health_check_interval_sec: body.health_check_interval_sec,
health_check_timeout_ms: body.health_check_timeout_ms,
health_check_verify_tls: body.health_check_verify_tls,
health_check_provider: body.health_check_provider,
health_check_providers: body.health_check_providers,
health_check_aggregate: body.health_check_aggregate,
},
);
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
return group;
}
export async function updateGroup(
@@ -1349,6 +1470,10 @@ export async function updateGroup(
health_check_expected_status: body.health_check_expected_status,
health_check_interval_sec: body.health_check_interval_sec,
health_check_timeout_ms: body.health_check_timeout_ms,
health_check_verify_tls: body.health_check_verify_tls,
health_check_provider: body.health_check_provider,
health_check_providers: body.health_check_providers,
health_check_aggregate: body.health_check_aggregate,
},
);
if (!domain && group.enabled) {
@@ -1356,6 +1481,7 @@ export async function updateGroup(
group = repos.getServiceGroup(db, id);
}
await syncEnabledServicesInGroup(db, cf, id);
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
return group;
}
@@ -1397,6 +1523,59 @@ export async function toggleService(
return enabledView!;
}
export async function toggleServiceIp(
db: Db,
cf: CloudflareClient,
serviceId: number,
ip: string,
enabled: boolean,
): Promise<ServiceView> {
repos.getService(db, serviceId);
const pool = repos.listServiceIps(db, serviceId);
if (!pool.includes(ip)) {
throw AppError.validation(`IP ${ip} не входит в пул адресов сервиса`);
}
repos.setServiceIpEnabled(db, serviceId, ip, enabled);
const node = repos
.listNodes(db, serviceId)
.find((entry) => entry.address === ip);
if (node) {
repos.updateNode(db, node.id, { enabled });
}
const bindings = repos.listBindingsByService(db, serviceId);
for (const binding of bindings) {
if (binding.cname_target?.trim()) continue;
const current = repos.listBindingIpsWithMeta(db, binding.id);
const hasIp = current.some((entry) => entry.ip === ip);
if (enabled && !hasIp) {
repos.replaceBindingIpsWithMeta(db, binding.id, [
...current,
{ ip, weight: 1, priority: 1 },
]);
continue;
}
if (!enabled && hasIp) {
repos.replaceBindingIpsWithMeta(
db,
binding.id,
current.filter((entry) => entry.ip !== ip),
);
}
}
const service = repos.getService(db, serviceId);
if (shouldPushDns(db, service)) {
await syncServiceBindingsToDns(db, cf, serviceId);
await syncGroupDomainForService(db, cf, serviceId);
}
void syncServiceToVpsTracker(db, serviceId);
const [view] = attachServiceHealth(db, [await buildView(db, serviceId)]);
return view!;
}
export async function toggleGroup(
db: Db,
cf: CloudflareClient,
@@ -1437,6 +1616,25 @@ export function reorderServices(
repos.reorderServices(db, groupId, serviceIds);
}
export async function applyBindingDesiredDns(
db: Db,
cf: CloudflareClient,
bindingId: number,
desiredIps: string[],
): Promise<void> {
const binding = repos.getBinding(db, bindingId);
const cnameTarget = binding.cname_target?.trim() || null;
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
desiredIps,
cnameTarget,
);
}
export async function reconcileDnsForTarget(
db: Db,
cf: CloudflareClient,
@@ -1444,26 +1642,28 @@ export async function reconcileDnsForTarget(
refId: number,
): Promise<void> {
if (scope === "binding") {
const binding = repos.getBinding(db, refId);
if (!binding.health_check_enabled) return;
const service = repos.getService(db, binding.service_id);
if (!shouldPushDns(db, service)) return;
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) return;
const ips = repos.listServiceIps(db, service.id);
const targetIps = repos.listBindingIps(db, binding.id);
validateTargetIpsInPool(targetIps, ips);
const activeIps = computeActiveIps(db, "binding", refId);
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
desiredIps,
null,
);
await withBindingLock(refId, async () => {
const binding = repos.getBinding(db, refId);
if (!binding.health_check_enabled) return;
const service = repos.getService(db, binding.service_id);
if (!shouldPushDns(db, service)) return;
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) return;
const ips = repos.listServiceIps(db, service.id);
const targetIps = repos.listBindingIps(db, binding.id);
validateTargetIpsInPool(targetIps, ips);
const activeIps = computeActiveIps(db, "binding", refId);
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
desiredIps,
null,
);
});
return;
}
+208 -16
View File
@@ -1,4 +1,6 @@
import type { CfdmBindingSyncItem } from "@cfdm/shared";
import { resolve4 } from "node:dns/promises";
import type { CfdmBindingSyncItem, ServiceBindingView } from "@cfdm/shared";
import { isIpLiteral } from "@cfdm/shared";
import type { Db } from "@cfdm/db";
import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
@@ -7,23 +9,128 @@ function fqdnToDisplay(hostname: string, zoneName: string): string {
return `${hostname}.${zoneName}`;
}
export function buildServiceSyncBindings(
function normalizeCnameHost(target: string, zoneName: string): string {
const trimmed = target.trim().toLowerCase().replace(/\.$/, "");
if (!trimmed) return "";
if (trimmed.includes(".")) return trimmed;
return `${trimmed}.${zoneName.toLowerCase()}`;
}
type BindingIpIndex = {
byFqdn: Map<string, ServiceBindingView>;
};
function buildBindingIndex(bindings: ServiceBindingView[]): BindingIpIndex {
const byFqdn = new Map<string, ServiceBindingView>();
for (const b of bindings) {
const fqdn = fqdnToDisplay(b.hostname, b.zone_name).toLowerCase();
byFqdn.set(fqdn, b);
}
return { byFqdn };
}
/** Локальная цепочка CNAME → A (по bindings в CFDM), без внешнего DNS. */
function resolveIpsLocally(
index: BindingIpIndex,
startFqdn: string,
depth = 0,
seen = new Set<string>(),
): string[] {
const key = startFqdn.toLowerCase().replace(/\.$/, "");
if (!key || depth > 8 || seen.has(key)) return [];
seen.add(key);
const binding = index.byFqdn.get(key);
if (!binding) return [];
const ips = (binding.target_ips ?? []).filter(isIpLiteral);
if (ips.length > 0) {
return ips;
}
const cname = binding.cname_target?.trim();
if (!cname) return [];
const next = normalizeCnameHost(cname, binding.zone_name);
return resolveIpsLocally(index, next, depth + 1, seen);
}
async function resolveIpsViaDns(hostname: string): Promise<string[]> {
const host = hostname.trim().toLowerCase().replace(/\.$/, "");
if (!host) return [];
try {
// resolve4 следует по CNAME до A-записей
return await resolve4(host);
} catch {
return [];
}
}
/**
* IP для матчинга в VPS Tracker:
* 1) A/AAAA binding (только литералы IP — не CNAME hostname из dns_record.content)
* 2) локальная CNAME-цепочка по другим bindings
* 3) origin A/AAAA из dns_records CFDM
* 4) IP сервиса
* 5) публичный DNS — последний и обычно вреден (CF anycast при proxied)
*/
export async function resolveBindingIpsForSync(
binding: ServiceBindingView,
serviceIps: string[],
index: BindingIpIndex,
db?: Db,
): Promise<string[]> {
const directIps = (binding.target_ips ?? []).filter(isIpLiteral);
if (directIps.length > 0) {
return [...directIps];
}
const cname = binding.cname_target?.trim();
if (cname) {
const targetFqdn = normalizeCnameHost(cname, binding.zone_name);
const local = resolveIpsLocally(index, targetFqdn).filter(isIpLiteral);
if (local.length > 0) return local;
if (db) {
const fromTable = repos.listOriginIpsForFqdn(db, targetFqdn).filter(isIpLiteral);
if (fromTable.length > 0) return fromTable;
}
}
const fromService = serviceIps.filter(isIpLiteral);
if (fromService.length > 0) return [...fromService];
if (cname) {
const targetFqdn = normalizeCnameHost(cname, binding.zone_name);
const viaDns = (await resolveIpsViaDns(targetFqdn)).filter(isIpLiteral);
if (viaDns.length > 0) return viaDns;
}
return [];
}
function cnameTargetForSync(binding: ServiceBindingView): string | undefined {
const raw = binding.cname_target?.trim();
if (!raw) return undefined;
const normalized = normalizeCnameHost(raw, binding.zone_name);
return normalized || undefined;
}
export async function buildServiceSyncBindingsAsync(
db: Db,
serviceId: number,
deletedBindingIds: number[] = [],
): CfdmBindingSyncItem[] {
): Promise<CfdmBindingSyncItem[]> {
const service = repos.getService(db, serviceId);
const serviceIps = repos.listServiceIps(db, serviceId);
const bindings = repos.listBindingsByService(db, serviceId);
const items: CfdmBindingSyncItem[] = bindings.map((binding) => {
const targetIps = repos.listBindingIps(db, binding.id);
const ips =
targetIps.length > 0
? targetIps
: serviceIps.length > 0
? serviceIps
: [];
return {
const allBindings = repos.listAllBindings(db);
const index = buildBindingIndex(allBindings);
const bindings = allBindings.filter((row) => row.service_id === serviceId);
const items: CfdmBindingSyncItem[] = [];
for (const binding of bindings) {
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
items.push({
bindingId: binding.id,
serviceId: service.id,
serviceName: service.name,
@@ -32,8 +139,9 @@ export function buildServiceSyncBindings(
zoneName: binding.zone_name,
hostname: binding.hostname,
ips,
};
});
cnameTarget: cnameTargetForSync(binding),
});
}
for (const bindingId of deletedBindingIds) {
items.push({
@@ -52,6 +160,36 @@ export function buildServiceSyncBindings(
return items;
}
export async function buildAllSyncBindings(
db: Db,
): Promise<CfdmBindingSyncItem[]> {
const bindings = repos.listAllBindings(db);
const index = buildBindingIndex(bindings);
const serviceIpCache = new Map<number, string[]>();
const items: CfdmBindingSyncItem[] = [];
for (const binding of bindings) {
let serviceIps = serviceIpCache.get(binding.service_id);
if (!serviceIps) {
serviceIps = repos.listServiceIps(db, binding.service_id);
serviceIpCache.set(binding.service_id, serviceIps);
}
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
items.push({
bindingId: binding.id,
serviceId: binding.service_id,
serviceName: binding.service_name,
serviceSlug: binding.service_slug,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
zoneName: binding.zone_name,
hostname: binding.hostname,
ips,
cnameTarget: cnameTargetForSync(binding),
});
}
return items;
}
export async function syncServiceToVpsTracker(
db: Db,
serviceId: number,
@@ -64,7 +202,11 @@ export async function syncServiceToVpsTracker(
const token = config.vpsTrackerIntegrationToken;
if (!baseUrl || !token) return;
const bindings = buildServiceSyncBindings(db, serviceId, deletedBindingIds);
const bindings = await buildServiceSyncBindingsAsync(
db,
serviceId,
deletedBindingIds,
);
if (bindings.length === 0) return;
try {
@@ -91,6 +233,56 @@ export async function syncServiceToVpsTracker(
}
}
export async function syncAllToVpsTracker(db: Db): Promise<{
ok: boolean;
count: number;
error?: string;
}> {
const config = getAppSettingsSecrets(db);
if (!config.vpsTrackerSyncEnabled) {
return { ok: false, count: 0, error: "Синхронизация выключена в CFDM" };
}
const baseUrl = config.vpsTrackerUrl.replace(/\/$/, "");
const token = config.vpsTrackerIntegrationToken;
if (!baseUrl) {
return { ok: false, count: 0, error: "Укажите URL VPS Tracker" };
}
if (!token) {
return { ok: false, count: 0, error: "Укажите integration token" };
}
const bindings = await buildAllSyncBindings(db);
try {
const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ bindings, fullSync: true }),
});
if (!res.ok) {
const detail = await res.text();
return {
ok: false,
count: 0,
error: `VPS Tracker ${baseUrl} ответил HTTP ${res.status}: ${detail}`,
};
}
touchVpsTrackerSync(db);
return { ok: true, count: bindings.length };
} catch (err) {
const raw = err instanceof Error ? err.message : "Ошибка сети";
return {
ok: false,
count: 0,
error: `Не удалось отправить данные в VPS Tracker (${baseUrl}): ${raw}`,
};
}
}
export async function pingVpsTracker(db: Db): Promise<{
ok: boolean;
error?: string;
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import * as auditPortalPush from "../src/services/audit-portal-push.js";
describe("audit log", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("GET /api/v1/audit returns local entries", async () => {
const pushSpy = vi
.spyOn(auditPortalPush, "pushAuditEvents")
.mockResolvedValue(undefined);
const app = await buildApp({
config: {
...loadConfig(),
staticDir: null,
authAuditIngestSecret: null,
},
memory: true,
});
const login = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: "admin", password: "admin" },
});
expect(login.statusCode).toBe(200);
const token = (login.json() as { token: string }).token;
const create = await app.inject({
method: "POST",
url: "/api/v1/groups",
headers: { authorization: `Bearer ${token}` },
payload: { name: "Audit Test", slug: "audit-test" },
});
expect(create.statusCode).toBe(200);
const list = await app.inject({
method: "GET",
url: "/api/v1/audit?action=group.create",
headers: { authorization: `Bearer ${token}` },
});
expect(list.statusCode).toBe(200);
const entries = list.json() as { action: string; source_app: string }[];
expect(entries.length).toBeGreaterThanOrEqual(1);
expect(entries[0]?.action).toBe("group.create");
expect(entries[0]?.source_app).toBe("cfdm");
expect(pushSpy).not.toHaveBeenCalled();
await app.close();
});
it("recordAudit pushes to portal when secret configured", async () => {
const pushSpy = vi
.spyOn(auditPortalPush, "pushAuditEvents")
.mockResolvedValue(undefined);
const app = await buildApp({
config: {
...loadConfig(),
staticDir: null,
authPortalUrl: "http://portal.test",
authAuditIngestSecret: "test-ingest-secret",
},
memory: true,
});
const login = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: "admin", password: "admin" },
});
const token = (login.json() as { token: string }).token;
await app.inject({
method: "POST",
url: "/api/v1/groups",
headers: { authorization: `Bearer ${token}` },
payload: { name: "Portal Push", slug: "portal-push" },
});
await vi.waitFor(() => {
expect(pushSpy).toHaveBeenCalledOnce();
});
const [portalUrl, secret, events] = pushSpy.mock.calls[0]!;
expect(portalUrl).toBe("http://portal.test");
expect(secret).toBe("test-ingest-secret");
expect(events[0]?.source_app).toBe("cfdm");
expect(events[0]?.action).toBe("group.create");
await app.close();
});
});
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff. Show more