Compare commits

...
32 Commits
Author SHA1 Message Date
Denozordec 927e27640a feat(docs): update speaker installation instructions and logging details
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 1m27s
quality / go (push) Successful in 1m18s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 3m43s
CD / publish (push) Successful in 3m11s
- Enhanced the speaker installation documentation to clarify the use of TCP port 179 and the logging commands for monitoring BIRD and evobgp-agent.
- Updated the speaker form dialog to include additional information about MikroTik connections and logging commands.
- Modified the BIRD configuration to include logging to stderr for better visibility during operations.
- Adjusted the Docker Compose configuration to ensure proper network settings and sysctl configurations for BGP functionality.
2026-08-21 16:11:28 +07:00
DenozordecandCursor 5255cd2d30 fix(web): regenerate OpenAPI types after speaker install schema
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 27s
quality / web (push) Successful in 1m21s
CD / quality (push) Successful in 2m2s
CD / publish (push) Successful in 6m33s
Синхронизирован api.gen.ts с docs/openapi.yaml, чтобы CI check-openapi-gen не падал после BgpSpeakerCreated и install.docker_commands.

Co-authored-by: Cursor <[email protected]>
2026-08-21 14:46:03 +07:00
DenozordecandCursor 3723ba7ed1 feat(httpapi): return replica docker install commands on speaker create
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Failing after 21s
quality / web (push) Successful in 55s
quality / go (push) Successful in 1m2s
quality / bird2 (push) Successful in 16s
CD / quality (push) Failing after 2m49s
CD / publish (push) Skipped
После создания реплики 201 отдаёт agent_secret, node_token и install.docker_commands (bird2 + agent + Traefik DNS-01). UI показывает шаг установки вместо закрытия диалога, чтобы секрет больше не терялся.

Co-authored-by: Cursor <[email protected]>
2026-08-21 13:51:06 +07:00
DenozordecandCursor c5148ac4a0 chore(reui): update ReUI agent skill to 3bdbad788a
quality / changes (push) Successful in 6s
quality / openapi (push) Skipped
quality / web (push) Skipped
CD / quality (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Successful in 33s
Обновить локальный ReUI skill и правила Cursor до версии 3bdbad788a.

Co-authored-by: Cursor <[email protected]>
2026-08-19 20:04:45 +07:00
Denozordec e6e319a275 refactor(web): improve job kind handling in schedule components
CD / quality (push) Successful in 1m49s
quality / changes (push) Successful in 9s
quality / openapi (push) Successful in 25s
quality / web (push) Successful in 1m6s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Successful in 3m2s
- Removed hardcoded job kind checks in schedule components and replaced them with a utility function `isRefreshJobKind` for better maintainability.
- Updated the `ui-labels` module to include additional job kinds and their corresponding labels.
- Adjusted the `DashboardRecentJobsGrid`, `OperationsJobsGrid`, and `ScheduleJobsGrid` components to reflect these changes, enhancing the clarity and consistency of job kind representation.
2026-08-19 09:45:31 +07:00
DenozordecandCursor 869b13cb57 fix(web): open module create in FormDrawer
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 22s
quality / web (push) Successful in 56s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 2m53s
Создание модуля перенесено в боковую Sheet, как community и редактирование. /modules/new и быстрые ссылки открывают список с ?create=true.

Co-authored-by: Cursor <[email protected]>
2026-08-18 22:09:41 +07:00
DenozordecandCursor e190785d4f feat(web): add module create form on /modules/new
quality / commitlint (push) Skipped
quality / changes (push) Successful in 6s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 22s
quality / web (push) Successful in 58s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 3m1s
Форма POST /v1/modules вместо заглушки: тип, название, расписание и DoH для DOMAINS. После 201 переход на карточку; кнопка «Создать» только при bgp:modules:write.

Co-authored-by: Cursor <[email protected]>
2026-08-18 20:17:51 +07:00
Denozordec 3fd05ff833 fix(tests): improve concurrency handling in worker tests and enhance module cloning
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / go (push) Successful in 1m14s
quality / bird2 (push) Successful in 18s
CD / quality (push) Successful in 1m47s
CD / publish (push) Successful in 2m53s
- Added environment variable `EVOBGP_JOB_MAX_CONCURRENT` to control job concurrency in tests.
- Modified worker test to ensure proper synchronization of job processing by holding workers until both jobs are enqueued.
- Updated memory store methods to return cloned module instances, preventing unintended mutations of original modules during operations.
2026-08-18 19:58:39 +07:00
Denozordec e7f24f0be4 feat(docker): update runtime configurations and documentation for evobgp components
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / go (push) Failing after 44s
quality / bird2 (push) Skipped
CD / quality (push) Failing after 55s
CD / publish (push) Skipped
- Changed the inheritance for `evobgp-deploy` and `evobgp-node` targets to use `_go-runtime-birdc`, reflecting the inclusion of the `bird` and `birdc` components.
- Updated README and Dockerfile comments to clarify the runtime environments for various evobgp components, specifying the use of `bird` and `birdc` for parse-check functionality.
- Adjusted quickstart documentation to accurately describe the `evobgp-api` image, highlighting the inclusion of `bird` and `birdc` for enhanced functionality.
2026-08-18 19:47:14 +07:00
Denozordec 1bfe460e4b feat(ci): enhance caching strategy and update workflows for pnpm and Go
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 25s
quality / web (push) Successful in 1m16s
quality / go (push) Successful in 2m46s
quality / bird2 (push) Successful in 17s
CD / quality (push) Successful in 5m0s
CD / publish (push) Successful in 4m22s
- Updated CI workflows to export cache paths for pnpm and Go, improving cache efficiency and reducing package download times.
- Replaced corepack enable step with cache path exports in multiple workflows.
- Adjusted cache paths to utilize environment variables for better flexibility and clarity.
- Enhanced README documentation to reflect changes in caching mechanisms and runner configurations.
2026-08-18 19:13:10 +07:00
Denozordec e55d2c5aba feat(docker): enable relative path support for Docker bake configurations
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 28s
quality / web (push) Successful in 1m2s
quality / go (push) Successful in 1m6s
quality / bird2 (push) Successful in 17s
CD / quality (push) Successful in 3m12s
CD / publish (push) Successful in 5m44s
- Added `BUILDX_BAKE_FILE_RELATIVE_PATHS` environment variable to CI workflows to support relative paths in Docker bake commands.
- Updated `docker-bake.hcl` comments to clarify context resolution from the current working directory.
- Revised README documentation to reflect changes in context resolution and usage of the new environment variable.
2026-08-18 18:45:51 +07:00
Denozordec f63e9b5fd0 fix(docker): update base image references to Docker Hub
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / go (push) Successful in 57s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 1m27s
CD / publish (push) Failing after 4m13s
- Changed base image references in `docker-bake.hcl`, Dockerfiles, and `mirror-base-images.sh` from public ECR to Docker Hub to avoid 429 errors and improve reliability.
- Updated README documentation to reflect the new source for base images, clarifying the mirroring process and behavior when tags already exist.
2026-08-18 18:30:22 +07:00
Denozordec 0148d4ca37 feat(docker): enhance base image mirroring process and update documentation
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 40s
quality / web (push) Successful in 1m27s
quality / go (push) Successful in 1m4s
quality / bird2 (push) Successful in 18s
CD / quality (push) Successful in 3m45s
CD / publish (push) Canceled after 10m39s
- Updated `mirror-base-images.sh` to skip existing tags in Gitea and retry on 429 errors, improving efficiency and reducing unnecessary requests.
- Modified CI workflow to utilize a temporary environment file for mirrored base images, ensuring better handling of existing images.
- Enhanced README documentation to reflect changes in the mirroring process and clarify the behavior when tags already exist.
2026-08-18 18:22:26 +07:00
Denozordec e0ecabb22f feat(dependencies): add conventional-commits-parser and update verification script
quality / changes (push) Successful in 8s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 45s
quality / web (push) Successful in 1m30s
quality / go (push) Successful in 1m7s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 3m59s
CD / publish (push) Failing after 1m2s
- Added `conventional-commits-parser` version 6.4.0 to `package.json` and `pnpm-lock.yaml` for improved commit message parsing.
- Updated the commit verification script to utilize the new parser, enhancing error handling for unparseable commit messages.
- Adjusted CI workflow to run the verification script using `pnpm exec` for consistency in package execution.
2026-08-18 18:13:44 +07:00
Denozordec da301b1a94 feat(dependencies): add @redocly/cli and update CI workflows
quality / commitlint (push) Skipped
quality / changes (push) Successful in 20s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 2m54s
quality / web (push) Successful in 1m22s
quality / go (push) Successful in 3m37s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 8m37s
CD / publish (push) Failing after 1m51s
- Added `@redocly/cli` version 1.34.5 to `package.json` and `pnpm-lock.yaml` for OpenAPI linting.
- Updated CI workflows to reflect changes in job names and processes, including adjustments to the `publish` job in the CD workflow.
- Enhanced documentation to clarify the new CI/CD processes and Docker build configurations.
2026-08-18 17:48:41 +07:00
Denozordec 653bc6cc91 fix(web): update Russian translations for various components
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 56s
CI / release (push) Successful in 4m4s
Обновлены переводы на русский язык для компонентов, включая карточки мониторинга, сетевые панели и настройки. Исправлены описания и метки для улучшения пользовательского интерфейса, а также добавлены новые элементы для поддержки локализации в компонентах, таких как DataGrid и ResourcePage.
2026-08-18 17:00:15 +07:00
Denozordec e0d695f2a4 refactor(web): unify settings navigation and update tab structure
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 55s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m6s
Refactored the settings components to unify navigation between UI and BIRD settings. Updated tab structures to streamline access and improve user experience. Adjusted routing and search parameters to reflect the new tab organization, ensuring a cohesive interface. Removed legacy tenant settings references and enhanced the settings page layout for clarity and usability.
2026-08-18 16:30:10 +07:00
Denozordec dfc33dcb99 refactor(web): streamline dashboard and tab components for improved layout
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 57s
CI / release (push) Successful in 4m24s
Refactored various dashboard components and tab structures to enhance layout and responsiveness. Adjusted class names to include 'min-w-0' for better overflow handling and improved alignment. Updated the ChartDonutMetric to include a tooltip for better data representation. Simplified the DashboardRecentJobsGrid and DashboardRecentRevisionsGrid by replacing DataGridSection with DataGridShell for consistency. Enhanced the OpsDashboard layout for better clarity and usability.
2026-08-18 16:03:52 +07:00
Denozordec 44f70443c3 refactor(web): improve layout and responsiveness of settings components
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 53s
CI / web (push) Successful in 1m16s
CI / release (push) Successful in 4m24s
Enhanced various settings components by adding 'min-w-0' class for better overflow handling and adjusting flex properties for improved alignment. Updated card and grid structures to ensure a cohesive user experience across the settings interface. Refined descriptions and layout in several settings tabs for better clarity and usability.
2026-08-18 01:19:42 +07:00
DenozordecandCursor 357ed4ce7b refactor(web): enhance layout and styling for dashboard components
CI / changes (push) Successful in 12s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 1m18s
CI / web (push) Successful in 2m0s
CI / release (push) Successful in 6m51s
Updated various dashboard components to improve layout and responsiveness. Adjusted class names to include 'min-w-0' for better handling of overflow and added flex properties to ensure proper alignment. Refined grid structures and skeleton loading states for a more cohesive user experience.

Co-authored-by: Cursor <[email protected]>
2026-08-18 00:51:10 +07:00
DenozordecandCursor 35e262b5ae fix(web): update Russian translations and improve UI components
CI / changes (push) Successful in 9s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 47s
CI / web (push) Successful in 1m4s
CI / release (push) Successful in 5m19s
Обновлены переводы на русский язык для различных компонентов, включая KPI, карточки сети и панели управления. Исправлены описания и метки для улучшения пользовательского интерфейса. Также добавлены новые элементы для поддержки локализации в компонентах, таких как DataGrid и ResourcePage.

Co-authored-by: Cursor <[email protected]>
2026-08-18 00:32:00 +07:00
DenozordecandCursor 8267141136 ci(gitea): point registry and remotes at git.shx.one
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 55s
CI / go (push) Successful in 1m4s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m17s
Перевести CI, semantic-release, bake и compose с git.shts.su на git.shx.one.

Co-authored-by: Cursor <[email protected]>
2026-08-17 16:43:39 +07:00
DenozordecandCursor 34a29ad337 ci(gitea): reuse HEAD tag when retrying a failed release
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 58s
CI / go (push) Successful in 1m6s
CI / bird2 (push) Successful in 14s
CI / release (push) Failing after 4m0s
При перезапуске job после уже созданного тега semantic-release не пишет .release-version — версию берём из v* на HEAD и публикуем образы.

Co-authored-by: Cursor <[email protected]>
2026-08-17 16:30:41 +07:00
DenozordecandCursor b04b27c215 refactor(web): align Frame KPI settings chrome and a11y
CI / changes (push) Successful in 5s
CI / openapi (push) Successful in 39s
CI / web (push) Successful in 55s
CI / go (push) Successful in 2m40s
CI / bird2 (push) Successful in 16s
CI / commitlint (push) Skipped
CI / release (push) Successful in 18s
Выровнять KPI на IconTile, вынести settings из settings-7, Sheet/AlertDialog и skip-link в общем chrome.

Co-authored-by: Cursor <[email protected]>
2026-08-17 15:36:54 +07:00
DenozordecandCursor 6a25a3d137 fix(web): parse string readiness checks on monitoring System tab
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 54s
CI / release (push) Successful in 3m59s
Исправляет ложные «Ошибки проверок»: GET /v1/ready отдаёт строки ok/memory, а не boolean. Уплотнён ReUI Frame/donut layout на вкладке Система.

Co-authored-by: Cursor <[email protected]>
2026-08-12 13:41:58 +07:00
DenozordecandCursor 32d9dc6acb feat(directories): edit communities and DoH via row actions menu
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 36s
CI / web (push) Successful in 55s
CI / release (push) Successful in 4m28s
В data-grid справочников добавлено меню «⋯» с пунктом «Редактировать»
и FormDrawer на PATCH /v1/communities/{id} и /v1/doh-profiles/{id}.

Co-authored-by: Cursor <[email protected]>
2026-08-12 12:42:01 +07:00
DenozordecandCursor fc4f234cc5 feat(directories): add create dialogs for communities and DoH profiles
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 41s
CI / web (push) Successful in 1m13s
CI / release (push) Successful in 4m35s
На странице Справочники добавлены кнопки «Добавить» и FormDrawer
для POST /v1/communities и /v1/doh-profiles. Доступ ограничен
bgp:directories:write (sessionCanWriteDirectories).

Co-authored-by: Cursor <[email protected]>
2026-08-12 12:24:10 +07:00
Denozordec 821f342476 feat(modules): enhance ModuleKpiCards and add update functionality for modules
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 51s
CI / web (push) Successful in 1m26s
CI / release (push) Successful in 4m26s
Updated the ModuleKpiCards component to improve the display of KPI metrics by introducing a new entries count prop and simplifying the logic for displaying community and DoH information. Added a new mutation hook for updating modules, which includes success and error handling with toast notifications. Enhanced the ModuleDetailComponent to support editing modules with a new dialog and integrated the entries count into the KPI display.

Additionally, introduced a new helper function for generating short labels for DoH profiles, improving the overall user experience in module management.
2026-08-12 11:32:44 +07:00
Denozordec 4634aac2d1 feat(network): add delete functionality for BGP peers with confirmation dialog
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 44s
CI / web (push) Successful in 1m7s
CI / go (push) Successful in 2m36s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m4s
Implemented a delete action for BGP peers in the NetworkPeersCard component. Added a confirmation dialog to ensure user intent before deletion, enhancing the user experience and preventing accidental removals. Updated the component's state management to handle the delete target and integrated the delete mutation for backend interaction.
2026-08-07 16:16:38 +07:00
Denozordec 7953ae6e78 chore(reui): update documentation and component references to reflect new 20 building blocks
Updated the ReUI documentation to reflect the addition of three new components, bringing the total to 20. Adjusted descriptions, examples, and rules across various files to ensure consistency with the updated component list. Enhanced the API references and installation instructions to improve clarity for users.

This change includes updates to the SKILL.md, cli.md, components.md, registry.md, and workflow.md files, ensuring all references are accurate and up-to-date.
2026-08-07 15:44:38 +07:00
Denozordec d14550cda1 refactor(lookup): replace Alert components with LookupStatusBanner in LookupWizard
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 46s
CI / web (push) Successful in 1m12s
CI / release (push) Successful in 4m11s
Updated the LookupWizard component to enhance readability and maintainability by replacing Alert components with a new LookupStatusBanner component. This change simplifies the status display logic for matched and unmatched results, improving the user experience during the lookup process.
2026-07-31 15:53:10 +07:00
Denozordec 632adaa63f refactor(lookup): simplify layout by removing Frame components
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 46s
CI / web (push) Successful in 1m23s
CI / release (push) Successful in 4m16s
Refactored the LookupAddStep, LookupMatchesGrid, LookupSearchForm, and LookupWizard components to eliminate Frame components, replacing them with simpler div structures for improved readability and maintainability. Updated comments to reflect the new layout and functionality, aligning with the wizard-2 design pattern.
2026-07-31 15:21:16 +07:00
208 changed files with 7451 additions and 2614 deletions
+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 `42d70dcc3d`.** 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)
+2 -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" } }
@@ -54,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).
+67 -17
View File
@@ -1,24 +1,23 @@
# ReUI components
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `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,9 @@ 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
@@ -107,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
@@ -312,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 `42d70dcc3d`.** 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)
+2 -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" } }
@@ -54,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).
+67 -17
View File
@@ -1,24 +1,23 @@
# ReUI components
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `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,9 @@ 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
@@ -107,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
@@ -312,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)).
+1 -1
View File
@@ -28,7 +28,7 @@ alwaysApply: true
| Библиотека | Context7 ID | Версия в проекте | Когда |
|------------|-------------|------------------|-------|
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `npx @redocly/cli lint` |
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `pnpm exec redocly lint` |
---
+4 -4
View File
@@ -86,7 +86,7 @@ alwaysApply: true
## Dependency Management
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
*Проверка:* `go.mod`, `.gitea/workflows/ci.yaml`.
*Проверка:* `go.mod`, `.gitea/workflows/quality.yaml`.
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
*Проверка:* review.
@@ -123,10 +123,10 @@ alwaysApply: true
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
*Проверка:* CI job `bird2`.
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`.
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/quality.yaml`.
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
**TEST-05** | MUST | Изменения OpenAPI — `pnpm exec redocly lint docs/openapi.yaml`.
*Проверка:* CI job `openapi`.
---
@@ -229,7 +229,7 @@ alwaysApply: true
```powershell
go vet ./...
go test ./... -race -count=1
npx @redocly/cli lint docs/openapi.yaml
pnpm exec redocly lint docs/openapi.yaml
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
# birdfmt: go test ./internal/birdfmt/... -count=1
+10 -9
View File
@@ -1,11 +1,11 @@
---
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
alwaysApply: true
---
# KPI hybrid — только kit (stats-12 DNA)
# KPI hybrid — только kit (stats-12 + IconTile)
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
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), [`web-shadcn.mdc`](web-shadcn.mdc).
@@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma
| Зона | Компонент / DNA |
|------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
| 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: `@evobgp/ui/components/*`.
Импорты UI: `@evobgp/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
- Другой size / radius / solid brand fill вместо `bg-muted`
- `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 без hybrid tile
- Голый lucide `size-4` в name-cell без IconTile
+13 -3
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 | Импорт |
@@ -39,7 +47,8 @@ alwaysApply: true
| 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.
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
@@ -66,9 +75,9 @@ 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`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
**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`, …
@@ -101,6 +110,7 @@ REUI_LICENSE_KEY=
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
- Смешивать Card и Frame на ops-экране
- Ставить shadcn/ui blocks выше ReUI PRO
- **settings-7** как target (`surface: card`) — только [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16)
## Чеклист
+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 `42d70dcc3d`.** 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)
+3
View File
@@ -101,6 +101,9 @@ pnpm --filter @evobgp/web run build
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
**WEB-23** | NEVER | **settings-7** как target ([preview](https://reui.io/preview/base/settings-7) — `surface: card`). Новые settings-экраны — [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16) (`surface: frame`). Существующий `apps/web/src/components/settings/*` на DNA settings-7 **не** копировать как эталон; миграция — отдельный CHANGE.
*Проверка:* review MCP `get_block` / cite; нет новых экранов с settings-7.
---
## Documentation Sync (Web)
+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 `42d70dcc3d`.** 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)
+2 -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" } }
@@ -54,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).
+67 -17
View File
@@ -1,24 +1,23 @@
# ReUI components
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `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,9 @@ 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
@@ -107,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
@@ -312,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)).
+49
View File
@@ -0,0 +1,49 @@
# Build context = repository root (deploy/docker/docker-bake.hcl).
.git
.gitea
.github
.cursor
.claude
.codegraph
.agents
memory-bank
.vscode
.idea
**/.DS_Store
**/Thumbs.db
**/.env
**/.env.*
!**/.env.example
!**/.env.*.example
node_modules
**/node_modules
**/dist
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
.golangci.yml
.releaserc.json
.commitlintrc.*
redocly.yaml
package-lock.json
data
*.exe
*.test
coverage
.coverage
*.out
.release-version
CHANGELOG.md
deploy/docker/docker-bake.override.hcl
deploy/compose/runtime-logs
+48 -21
View File
@@ -1,55 +1,82 @@
# Gitea Actions
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
| Job | PR | push в main |
|-----|-----|-------------|
| changes, openapi, web, go, bird2 | quality gates | quality gates |
| commitlint | да | — |
| **release** | — | semantic-release + docker push (один run) |
| 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 |
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, go, bird2, commitlint, docker-check |
Подробнее: [docs/releasing.md](../docs/releasing.md).
## CI (quality gates)
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` `go` / `openapi` и т.д. (см. `ci.yaml`).
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/api.md` / `docs/access.md` / `docs/openapi.yaml`) quality jobs не запускают.
На **pull request****commitlint** (Conventional Commits).
На **pull request****commitlint**. При изменении `deploy/docker/**` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`, `go env GOMODCACHE` / `GOCACHE`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
## Release (job в ci.yaml)
Кэшируется целиком: pnpm store + `node_modules` + corepack; Go modules + GOCACHE + `golangci-lint` в `GOBIN`. При hit: `pnpm install --offline`, `go mod download` без сети. `setup-go cache:` и `golangci-lint-action` не используем — они завязаны на `hashFiles`.
После успешных quality gates на **push в main** job **release**:
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
Runner: `ubuntu-latest`, **bird2** из apt, 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. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
3. Зеркало base-образов в `evobgp-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 `evobgp` (`cleanup: false`).
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
### Секреты
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
### Теги образов
```text
git.shts.su/<owner>/<имя>:latest
git.shts.su/<owner>/<имя>:v1.2.3
git.shts.su/<owner>/<имя>:1.2.3
git.shts.su/<owner>/<имя>:<short-sha>
git.shts.su/<owner>/<имя>:sha-<full-sha>
git.shx.one/<owner>/<имя>:latest
git.shx.one/<owner>/<имя>:v1.2.3
git.shx.one/<owner>/<имя>:1.2.3
git.shx.one/<owner>/<имя>:<short-sha>
git.shx.one/<owner>/<имя>:sha-<full-sha>
```
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
Кэш сборки: `evobgp-buildcache:{go,web,birdc}-buildcache` и `evobgp-buildcache:base-*`.
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
Пример:
```bash
docker pull git.shts.su/myuser/evobgp-api:1.2.3
docker pull git.shx.one/myuser/evobgp-api:1.2.3
```
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.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 не будет.
+148
View File
@@ -0,0 +1,148 @@
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 }}
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
packages/ui/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: evobgp
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_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
CACHE_REF_BIRDC: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:birdc-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
+16 -365
View File
@@ -1,372 +1,23 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ---------------------------------------------------------------------------
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
# ---------------------------------------------------------------------------
changes:
runs-on: ubuntu-latest
outputs:
openapi: ${{ steps.detect.outputs.openapi }}
go: ${{ steps.detect.outputs.go }}
web: ${{ steps.detect.outputs.web }}
bird_conf: ${{ steps.detect.outputs.bird_conf }}
docker_go: ${{ steps.detect.outputs.docker_go }}
docker_web: ${{ steps.detect.outputs.docker_web }}
docker_bird: ${{ steps.detect.outputs.docker_bird }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: detect
name: Detect changed paths per module
run: |
set -euo pipefail
openapi=false
go=false
web=false
bird_conf=false
docker_go=false
docker_web=false
docker_bird=false
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
set_all_flags_true() {
openapi=true
go=true
web=true
bird_conf=true
docker_go=true
docker_web=true
docker_bird=true
}
write_outputs() {
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
}
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
FILES="$(git diff --name-only "$base" "$head")"
else
before="${{ github.event.before }}"
after="${{ github.sha }}"
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; 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
# CI/CD инфраструктура — все узлы quality gates
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
full_pipeline=true
;;
docs/openapi.yaml|redocly.yaml)
openapi=true
;;
docs/api.md|docs/access.md)
openapi=true
go=true
;;
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
;;
apps/web/*|packages/ui/*|packages/shared/*)
web=true
;;
deploy/bird/*)
bird_conf=true
go=true
;;
deploy/compose/*|deploy/docker/*)
docker_go=true
docker_web=true
docker_bird=true
go=true
;;
go.mod|go.sum|go.work)
go=true
;;
migrations/*)
go=true
;;
cmd/*|internal/*|*.go)
go=true
bird_conf=true
;;
docs/*)
go=true
;;
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
full_pipeline=true
;;
*)
go=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 "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
# ---------------------------------------------------------------------------
openapi:
needs: [changes]
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Enable pnpm via corepack
run: corepack enable
- name: Lint OpenAPI (Redocly)
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
- name: Check OpenAPI→TS codegen is fresh
run: |
set -euxo pipefail
pnpm install --frozen-lockfile
chmod +x scripts/check-openapi-gen.sh
sh scripts/check-openapi-gen.sh
# ---------------------------------------------------------------------------
web:
needs: [changes]
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Enable pnpm via corepack
run: corepack enable
- name: pnpm install, typecheck, lint, test, build
run: |
set -euxo pipefail
pnpm install --frozen-lockfile
pnpm --filter @evobgp/web run typecheck
pnpm --filter @evobgp/web run lint
pnpm --filter @evobgp/web run test
pnpm --filter @evobgp/web run build
# ---------------------------------------------------------------------------
go:
needs: [changes]
if: needs.changes.outputs.go == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.24"
cache: true
cache-dependency-path: go.sum
- name: Vet
run: go vet ./...
- name: Lint httpapi (ERR-01 / ARCH-01)
run: sh scripts/lint-httpapi.sh
- name: Check migration pairs (DEP-03)
run: sh scripts/check-migrations-pair.sh
- name: Validate remote speaker compose
run: sh scripts/validate-remote-speaker-compose.sh
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.64.8
install-mode: goinstall
- name: Test
run: go test ./... -race -count=1
- name: Build all commands
run: |
set -euxo pipefail
out="${RUNNER_TEMP}/evobgp-bin"
mkdir -p "$out"
for d in cmd/*/; do
name="$(basename "$d")"
go build -o "$out/$name" "./$d"
done
# ---------------------------------------------------------------------------
bird2:
runs-on: ubuntu-latest
needs: [changes, go]
if: >-
always() &&
needs.changes.result == 'success' &&
needs.go.result != 'failure' &&
(needs.changes.outputs.go == 'true' ||
needs.changes.outputs.bird_conf == 'true' ||
needs.changes.outputs.docker_bird == 'true' ||
needs.changes.outputs.docker_go == 'true')
steps:
- uses: actions/checkout@v4
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
run: |
set -euxo pipefail
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
$SUDO apt-get update -qq
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
bird --version
- name: bird -p on all scenario bird.conf files
env:
WORKSPACE: ${{ github.workspace }}
run: |
set -euxo pipefail
WS="${WORKSPACE:-$PWD}"
cd "$WS"
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
exit 1
fi
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
echo "==> $conf"
bird -c "$WS/$conf" -p
done
# ---------------------------------------------------------------------------
commitlint:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json
- name: Lint commit messages
run: |
set -euxo pipefail
npm ci
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
# ---------------------------------------------------------------------------
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
# ---------------------------------------------------------------------------
release:
needs: [changes, openapi, web, go, bird2]
if: >-
always() &&
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
needs.changes.result == 'success' &&
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
persist-credentials: true
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json
- name: Install release tooling
run: npm ci
- name: Verify releasable commit messages
run: node scripts/commit/verify-release-commits.mjs
- name: Semantic release
run: npx semantic-release
env:
GITEA_URL: https://git.shts.su
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Detect new release
id: rel
run: |
set -euo pipefail
if [ -f .release-version ]; then
echo "version=$(tr -d '[:space:]' < .release-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@v3
- 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@v3
with:
registry: git.shts.su
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Build and push images (bake)
if: steps.rel.outputs.released == 'true'
env:
REGISTRY: git.shts.su/${{ 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_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
run: |
set -euxo pipefail
cd "${{ github.workspace }}/deploy/docker"
sh write-bake-override.sh
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
-f docker-bake.hcl -f docker-bake.override.hcl default --push
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 }}
+428
View File
@@ -0,0 +1,428 @@
# 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:
openapi: ${{ steps.detect.outputs.openapi }}
go: ${{ steps.detect.outputs.go }}
web: ${{ steps.detect.outputs.web }}
bird_conf: ${{ steps.detect.outputs.bird_conf }}
docker_go: ${{ steps.detect.outputs.docker_go }}
docker_web: ${{ steps.detect.outputs.docker_web }}
docker_bird: ${{ steps.detect.outputs.docker_bird }}
steps:
- if: ${{ inputs.is_pull_request }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- if: ${{ inputs.is_pull_request == false }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2
- 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
openapi=false
go=false
web=false
bird_conf=false
docker_go=false
docker_web=false
docker_bird=false
set_all_flags_true() {
openapi=true
go=true
web=true
bird_conf=true
docker_go=true
docker_web=true
docker_bird=true
}
write_outputs() {
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
}
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" ]; 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/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
full_pipeline=true
;;
docs/openapi.yaml|redocly.yaml)
openapi=true
;;
docs/api.md|docs/access.md)
openapi=true
go=true
;;
.cursor/*|.claude/*|.codegraph/*|memory-bank/*)
;;
*.md|AGENTS.md)
;;
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
;;
apps/web/*|packages/ui/*|packages/shared/*)
web=true
;;
deploy/bird/*)
bird_conf=true
go=true
;;
deploy/compose/*|deploy/docker/*|.dockerignore)
docker_go=true
docker_web=true
docker_bird=true
go=true
;;
go.mod|go.sum|go.work)
go=true
;;
migrations/*)
go=true
;;
cmd/*|internal/*|*.go)
go=true
bird_conf=true
;;
docs/*)
;;
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
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 "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
openapi:
needs: [changes]
if: needs.changes.outputs.openapi == 'true' || 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
packages/ui/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, Redocly, codegen check
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm exec redocly lint docs/openapi.yaml
chmod +x scripts/check-openapi-gen.sh
sh scripts/check-openapi-gen.sh
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
packages/ui/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, typecheck, 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 @evobgp/web run typecheck
pnpm --filter @evobgp/web run lint
pnpm --filter @evobgp/web run test
pnpm --filter @evobgp/web run build
go:
needs: [changes]
if: needs.changes.outputs.go == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: "1.24"
cache: false
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: go-hash
run: echo "key=$(sha256sum go.sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.GOMODCACHE }}
${{ env.GOCACHE }}
${{ env.GOBIN }}
${{ env.GOLANGCI_LINT_CACHE }}
key: go-${{ runner.os }}-1.24-gl1.64.8-${{ steps.go-hash.outputs.key }}
restore-keys: |
go-${{ runner.os }}-1.24-gl1.64.8-
go-${{ runner.os }}-1.24-
- name: Download modules
env:
GOMODCACHE: ${{ env.GOMODCACHE }}
GOCACHE: ${{ env.GOCACHE }}
run: go mod download
- name: Vet
env:
GOFLAGS: -mod=readonly
run: go vet ./...
- name: Lint httpapi (ERR-01 / ARCH-01)
run: sh scripts/lint-httpapi.sh
- name: Check migration pairs (DEP-03)
run: sh scripts/check-migrations-pair.sh
- name: Validate remote speaker compose
run: sh scripts/validate-remote-speaker-compose.sh
- name: golangci-lint
env:
GOLANGCI_LINT_VERSION: v1.64.8
run: sh scripts/ci/golangci-lint.sh
- name: Test
env:
GOFLAGS: -mod=readonly
GOMODCACHE: ${{ env.GOMODCACHE }}
GOCACHE: ${{ env.GOCACHE }}
run: go test ./... -race -count=1
- name: Build all commands
env:
GOFLAGS: -mod=readonly
GOMODCACHE: ${{ env.GOMODCACHE }}
GOCACHE: ${{ env.GOCACHE }}
run: |
set -euxo pipefail
out="${RUNNER_TEMP}/evobgp-bin"
mkdir -p "$out"
for d in cmd/*/; do
name="$(basename "$d")"
go build -o "$out/$name" "./$d"
done
bird2:
runs-on: ubuntu-latest
needs: [changes, go]
if: >-
always() &&
needs.changes.result == 'success' &&
needs.go.result != 'failure' &&
(needs.changes.outputs.go == 'true' ||
needs.changes.outputs.bird_conf == 'true' ||
needs.changes.outputs.docker_bird == 'true' ||
needs.changes.outputs.docker_go == 'true')
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
run: |
set -euxo pipefail
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
$SUDO apt-get update -qq
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
bird --version
- name: bird -p on all scenario bird.conf files
env:
WORKSPACE: ${{ github.workspace }}
run: |
set -euxo pipefail
WS="${WORKSPACE:-$PWD}"
cd "$WS"
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
exit 1
fi
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
echo "==> $conf"
bird -c "$WS/$conf" -p
done
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
packages/ui/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_go == 'true' ||
needs.changes.outputs.docker_web == 'true' ||
needs.changes.outputs.docker_bird == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
with:
name: evobgp
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"
CACHE_REF_GO: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:web-buildcache
CACHE_REF_BIRDC: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:birdc-buildcache
run: |
set -euxo pipefail
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
export CACHE_REF_GO="git.shx.one/${owner_lc}/evobgp-buildcache:go-buildcache"
export CACHE_REF_WEB="git.shx.one/${owner_lc}/evobgp-buildcache:web-buildcache"
export CACHE_REF_BIRDC="git.shx.one/${owner_lc}/evobgp-buildcache:birdc-buildcache"
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
+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 `42d70dcc3d`.** 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)
+2 -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" } }
@@ -54,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).
+67 -17
View File
@@ -1,24 +1,23 @@
# ReUI components
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `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,9 @@ 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
@@ -107,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
@@ -312,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)).
+1 -1
View File
@@ -37,7 +37,7 @@
[
"@markwylde/semantic-release-gitea",
{
"giteaUrl": "https://git.shts.su",
"giteaUrl": "https://git.shx.one",
"assets": [
{
"path": "CHANGELOG.md",
+2
View File
@@ -73,4 +73,6 @@ pnpm --filter @evobgp/web run build
**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`.
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`).
UI-задачи начинаются с MCP `user-reui` (`surface: "frame"`) + `plugin-shadcn-shadcn`, затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
+3 -3
View File
@@ -22,8 +22,8 @@ Control plane для управления префиксами, модулями
```powershell
cd deploy\compose
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shts.su/<owner>
docker login git.shts.su
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shx.one/<owner>
docker login git.shx.one
docker compose --profile microvps pull
docker compose --profile microvps up -d
```
@@ -50,6 +50,6 @@ docker compose --profile reference up -d
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
- API: `GET /version` и `GET /v1/version` (поле `version`)
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CD run**, что и релиз (job `publish`)
Лицензия и условия использования — по политике владельца репозитория.
@@ -49,7 +49,8 @@ export function AccessApiKeysCard({
<>
<FrameDataGrid
title="API-ключи"
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
description="Полный токен показывается только при создании и ротации."
className="min-w-0"
actions={
<>
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
@@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ApiKey } from '@/types/api'
@@ -34,7 +35,14 @@ export function AccessApiKeysGrid({
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name}
accent="primary"
subtitle={`${row.original.prefix}`}
/>
),
minSize: 160,
meta: { headerTitle: 'Имя' },
},
{
@@ -45,14 +53,6 @@ export function AccessApiKeysGrid({
),
meta: { headerTitle: 'Роль' },
},
{
accessorKey: 'prefix',
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}</span>
),
meta: { headerTitle: 'Префикс' },
},
{
id: 'status',
enableSorting: false,
@@ -77,11 +77,11 @@ export function AccessApiKeysGrid({
{
id: 'last_used_at',
accessorFn: (row) => row.last_used_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
cell: ({ row }) => (
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
),
meta: { headerTitle: 'Последнее использование' },
meta: { headerTitle: 'Использован' },
},
{
id: 'actions',
@@ -90,7 +90,7 @@ export function AccessApiKeysGrid({
cell: ({ row }) => {
const k = row.original
return (
<div className="flex gap-1">
<div className="flex shrink-0 gap-1">
<ConfirmDialog
trigger={
<Button
@@ -149,6 +149,7 @@ export function AccessApiKeysGrid({
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ключей"
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск API-ключей…"
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils'
@@ -40,9 +42,10 @@ export function ChartDonutMetric({
}
return (
<div className={cn('flex items-center gap-6', className)}>
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
<PieChart>
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
<Pie
data={data}
dataKey="count"
@@ -75,7 +78,7 @@ export function ChartDonutMetric({
</PieChart>
</ChartContainer>
<ul className="min-w-0 flex-1 space-y-3">
<ul className="flex w-full min-w-0 max-w-xs flex-col gap-3 sm:w-auto sm:min-w-[10rem]">
{slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
@@ -27,10 +27,10 @@ export function MonitoringHealthCard({
return (
<DonutBreakdownCard
title="Доступность системы"
description="GET /v1/health · GET /v1/ready"
description="Проверки живучести и готовности"
slices={slices}
centerLabel="Проверки"
badge={healthOk ? 'API OK' : undefined}
badge={healthOk ? 'API в норме' : undefined}
/>
)
}
@@ -40,18 +40,18 @@ export function NetworkOverviewAnalyticsCard({
/>
<SegmentedProgressCard
title="Спикеры"
description="Доступность live-агентов"
description="Доступность агентов"
primary={{
value: `${net.speakersOnline}/${net.speakersTotal}`,
label: 'Online',
label: 'В сети',
percent: speakersPct,
}}
secondary={{
value: net.speakersTotal - net.speakersOnline,
label: 'Offline',
label: 'Не в сети',
percent: 100 - speakersPct,
}}
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`}
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
/>
</div>
)
+6 -3
View File
@@ -41,14 +41,17 @@ export function BadgeTabs({
defaultValue={defaultValue}
onValueChange={onValueChange}
orientation="horizontal"
className={cn('w-full', className)}
className={cn('w-full min-w-0', className)}
>
<TabsList
variant="line"
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
className={cn(
'mb-3.5 w-full min-w-0 justify-start gap-4',
listClassName,
)}
>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2">
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
{item.icon}
{item.label}
{item.count !== undefined ? (
@@ -1,62 +1,2 @@
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from "@evobgp/ui/components/field"
interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
labelFor?: string
contentClassName?: string
titleAddon?: ReactNode
}
export function SettingRow({
title,
description,
children,
last,
labelFor,
contentClassName,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field orientation="responsive" className="gap-4 px-5 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
>
<div className="flex w-full justify-start @md/field-group:justify-end">
{children}
</div>
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}
/** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */
export { SettingRow } from '@/components/settings/setting-row'
@@ -1,53 +1,2 @@
import { type ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
interface SettingsCardProps {
title: string
description?: string
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
footerClassName?: string
}
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
export function SettingsCard({
title,
description,
children,
footer,
className,
contentClassName,
footerClassName,
}: SettingsCardProps) {
return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
<FrameHeader className="gap-0 border-b px-5 py-3">
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
<div className={cn('min-w-0', contentClassName)}>{children}</div>
{footer ? (
<FrameFooter
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
/** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */
export { SettingsCard } from '@/components/settings/settings-card'
@@ -1,37 +1,2 @@
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
interface SettingsFieldGroupProps {
legend: string
description: string
children: ReactNode
className?: string
fieldGroupClassName?: string
}
export function SettingsFieldGroup({
legend,
description,
children,
className,
fieldGroupClassName,
}: SettingsFieldGroupProps) {
return (
<FieldSet className={cn("gap-0", className)}>
<FieldLegend className="sr-only">{legend}</FieldLegend>
{/* Description */}
<FieldDescription className="sr-only">{description}</FieldDescription>
{/* List */}
<FieldGroup className={cn("gap-0", fieldGroupClassName)}>
{children}
</FieldGroup>
</FieldSet>
)
}
/** @deprecated Import from `@/components/settings/settings-field-group` — settings-7 is not a target. */
export { SettingsFieldGroup } from '@/components/settings/settings-field-group'
+52 -93
View File
@@ -1,19 +1,16 @@
import { Button } from '@evobgp/ui/components/button'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@evobgp/ui/components/drawer'
import type { ReactElement, ReactNode } from 'react'
import { useState, type ReactElement, type ReactNode } from 'react'
import {
confirmDrawerContentClassName,
DrawerActionsFooter,
} from '@/components/drawer-layout'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@evobgp/ui/components/alert-dialog'
type ConfirmDialogBaseProps = {
title: string
@@ -41,90 +38,52 @@ type ConfirmDialogControlled = ConfirmDialogBaseProps & {
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
function ConfirmDrawerBody({
title,
description,
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
destructive,
onConfirm,
confirmDisabled,
confirmLoading,
confirmLoadingLabel,
controlled,
}: ConfirmDialogBaseProps & { controlled?: boolean }) {
const confirmText =
confirmLoading && confirmLoadingLabel
? confirmLoadingLabel
: confirmLoading
? `${confirmLabel}`
: confirmLabel
return (
<>
<DrawerHeader className="shrink-0 border-b border-border pb-4">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<DrawerActionsFooter>
<DrawerClose render={<Button variant="outline" disabled={confirmLoading} />}>
{cancelLabel}
</DrawerClose>
{controlled ? (
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
onClick={onConfirm}
>
{confirmText}
</Button>
) : (
<DrawerClose
render={
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
/>
}
onClick={onConfirm}
>
{confirmText}
</DrawerClose>
)}
</DrawerActionsFooter>
</>
)
}
/** Confirm overlay — Alert Dialog. Preview: https://reui.io/preview/base/components/c-alert-dialog-11 */
export function ConfirmDialog(props: ConfirmDialogProps) {
const bodyProps: ConfirmDialogBaseProps = {
title: props.title,
description: props.description,
confirmLabel: props.confirmLabel,
cancelLabel: props.cancelLabel,
destructive: props.destructive,
onConfirm: props.onConfirm,
confirmDisabled: props.confirmDisabled,
confirmLoading: props.confirmLoading,
confirmLoadingLabel: props.confirmLoadingLabel,
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
const isControlled = props.open !== undefined
const open = isControlled ? props.open : uncontrolledOpen
function setOpen(next: boolean) {
if (!isControlled) setUncontrolledOpen(next)
if (isControlled) props.onOpenChange?.(next)
}
if (props.trigger) {
return (
<Drawer swipeDirection="right">
<DrawerTrigger render={props.trigger} />
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} />
</DrawerContent>
</Drawer>
)
const confirmText =
props.confirmLoading && props.confirmLoadingLabel
? props.confirmLoadingLabel
: props.confirmLoading
? `${props.confirmLabel ?? 'Подтвердить'}`
: (props.confirmLabel ?? 'Подтвердить')
function handleConfirm() {
props.onConfirm()
if (!props.confirmLoading && !isControlled) setOpen(false)
}
return (
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} controlled />
</DrawerContent>
</Drawer>
<AlertDialog open={open} onOpenChange={setOpen}>
{props.trigger ? <AlertDialogTrigger render={props.trigger} /> : null}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{props.title}</AlertDialogTitle>
{props.description ? (
<AlertDialogDescription>{props.description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={props.confirmLoading}>
{props.cancelLabel ?? 'Отмена'}
</AlertDialogCancel>
<AlertDialogAction
variant={props.destructive ? 'destructive' : 'default'}
disabled={props.confirmDisabled || props.confirmLoading}
onClick={handleConfirm}
>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -50,7 +50,7 @@ export function DashboardActivityTimeline({
<DashboardFramePanel
title="Недавняя активность"
description="Задачи, ревизии и сетевые события"
className="h-full"
className="h-full min-w-0"
>
{loading ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка</p>
@@ -82,7 +82,7 @@ export function DashboardActivityTimeline({
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="min-w-0 pb-1 text-foreground">
<TimelineTitle className="text-sm leading-snug font-normal">
<TimelineTitle className="text-sm leading-snug font-normal break-words">
{item.message}
</TimelineTitle>
<div className="mt-2">
@@ -82,7 +82,7 @@ function buildKpis({
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
label: 'Пиры Established',
label: 'Пиры установлены',
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${network.peersTotal} в каталоге`}
@@ -94,13 +94,13 @@ function buildKpis({
icon: <ServerCog aria-hidden />,
iconClassName: 'text-warning',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
label: 'Спикеры online',
label: 'Спикеры в сети',
footer: (
<Badge
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm"
>
{loading ? '…' : 'live-снимок'}
{loading ? '…' : 'в сети'}
</Badge>
),
},
@@ -40,7 +40,7 @@ function buildMetrics({
{
id: 'bgp',
title: 'BGP готовность',
label: 'Established / включённые',
label: 'Установлено / включено',
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
@@ -62,13 +62,13 @@ function buildMetrics({
{
id: 'speakers',
title: 'Спикеры',
label: 'Online / всего',
label: 'В сети / всего',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
delta:
loading || network.speakersTotal === 0
? '…'
: network.speakersOnline === network.speakersTotal
? 'все online'
? 'все в сети'
: 'частично',
deltaVariant:
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
@@ -11,8 +11,8 @@ import {
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
@@ -40,6 +40,7 @@ import {
InputGroupButton,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api'
import {
@@ -51,20 +52,19 @@ import {
type SortingState,
} from '@tanstack/react-table'
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at'
type ModuleSort = 'name' | 'type' | 'priority'
type EnabledFilter = 'all' | 'enabled' | 'disabled'
const sortLabels: Record<ModuleSort, string> = {
name: 'Название',
type: 'Тип',
priority: 'Приоритет',
last_refreshed_at: 'Обновлено',
}
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
function buildSorting(sortBy: ModuleSort): SortingState {
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }]
return [{ id: sortBy, desc: false }]
}
export function DashboardModulesGrid({
@@ -119,9 +119,10 @@ export function DashboardModulesGrid({
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2">
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" />
<DataGridPrimaryCell title={row.original.name} accent="primary" />
</div>
),
minSize: 180,
meta: { headerTitle: 'Модуль' },
},
{
@@ -138,30 +139,9 @@ export function DashboardModulesGrid({
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
),
size: 88,
meta: { headerTitle: 'Приоритет' },
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
meta: { headerTitle: 'Состояние' },
},
{
id: 'last_refreshed_at',
accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
sortingFn: (a, b) =>
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
meta: { headerTitle: 'Обновлено' },
},
],
[],
)
@@ -185,8 +165,9 @@ export function DashboardModulesGrid({
<DashboardFramePanel
title="Модули"
description="Поиск, сортировка и быстрый переход к настройке"
className="min-w-0"
actions={
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
<PlusIcon />
Создать
</Button>
@@ -204,14 +185,14 @@ export function DashboardModulesGrid({
columnsVisibility: false,
columnsResizable: false,
columnsMovable: false,
width: 'fixed',
width: 'auto',
}}
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
>
<div className="flex flex-col">
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<InputGroup className="w-full min-w-40 sm:max-w-xs">
<div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
<InputGroup className="w-full min-w-0 @3xl:max-w-xs">
<InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
</InputGroupAddon>
@@ -337,8 +318,8 @@ export function DashboardModulesGrid({
<div className="border-t px-4 py-3">
{filteredModules.length > 0 ? (
<DataGridPagination
{...DATA_GRID_PAGINATION_RU}
sizes={[10, 15, 20]}
info="{from}{to} из {count}"
className="py-0"
/>
) : (
@@ -57,7 +57,7 @@ export function DashboardNetworkHealth({
}
primary={{
value: loading ? '—' : `${utilization}%`,
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры online',
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
percent: loading ? 0 : utilization,
badge: (
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
@@ -67,7 +67,7 @@ export function DashboardNetworkHealth({
}}
secondary={{
value: loading ? '—' : offline,
label: mode === 'peers' ? 'Не Established' : 'Offline',
label: mode === 'peers' ? 'Не установлены' : 'Не в сети',
percent: total > 0 ? Math.round((offline / total) * 100) : 0,
}}
footer={
@@ -3,13 +3,16 @@ import { useMemo, useState } from 'react'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { PanelCard } from '@/components/panel-card'
import { Separator } from '@evobgp/ui/components/separator'
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
import type { JobRow, ModuleRow } from '@/types/api'
type FlowMode = 'jobs' | 'modules'
/** chart-13 inspired donut breakdown (Card surface). */
/**
* Donut distribution — chart-27 DNA: Frame + one legend (inside ChartDonutMetric).
* @see https://reui.io/preview/base/chart-27
* @see https://reui.io/docs/components/base/frame
*/
export function DashboardOperationsBreakdown({
jobs,
modules,
@@ -32,7 +35,7 @@ export function DashboardOperationsBreakdown({
return (
<PanelCard
title="Поток операций"
description="Распределение задач и типов модулей"
description="Доли статусов задач или типов модулей"
actions={
<AnalyticsSegmentControl
value={mode}
@@ -43,45 +46,16 @@ export function DashboardOperationsBreakdown({
]}
/>
}
className="h-full"
className="min-w-0"
contentClassName="px-5 py-4"
>
<div className="p-4">
{loading ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
Загрузка
</div>
) : (
<>
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
{slices.length > 0 ? (
<ul className="mt-4 flex min-w-0 flex-col">
{slices.map((slice, index) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key}>
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
<div className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
style={{ backgroundColor: slice.color }}
/>
<span className="truncate text-sm font-medium">{slice.label}</span>
</div>
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
{pct}%
</span>
</div>
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
</li>
)
})}
</ul>
) : null}
</>
)}
</div>
{loading ? (
<div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
)}
</PanelCard>
)
}
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
{
id: 'lookup',
title: 'Проверка IP/домена',
description: 'Membership в списках и community (entry + snapshot).',
description: 'Проверка IP в списках и BGP community.',
to: '/lookup',
icon: <Search aria-hidden />,
iconClassName: 'text-primary',
@@ -16,14 +16,15 @@ const ACTIONS: QuickActionItem[] = [
id: 'new-module',
title: 'Создать модуль',
description: 'Новый модуль маршрутизации и источники префиксов.',
to: '/modules/new',
to: '/modules',
search: { create: true },
icon: <Plus aria-hidden />,
iconClassName: 'text-primary',
},
{
id: 'communities',
title: 'BGP-сообщества',
description: 'Справочник communities для политик экспорта.',
title: 'BGP community',
description: 'Справочник BGP community для политик экспорта.',
to: '/directories',
icon: <Tags aria-hidden />,
iconClassName: 'text-info',
@@ -31,7 +32,7 @@ const ACTIONS: QuickActionItem[] = [
{
id: 'network',
title: 'Сеть',
description: 'Обзор пиров, спикеров и live-сессий BGP.',
description: 'Обзор пиров, спикеров и живых BGP-сессий.',
to: '/network',
search: { tab: 'overview' },
icon: <Network aria-hidden />,
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridShell } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
@@ -29,7 +29,6 @@ export function DashboardRecentJobsGrid({
cell: ({ row }) => (
<DataGridPrimaryCell
title={jobKindRu(row.original.kind)}
accent="mono"
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
@@ -49,7 +48,7 @@ export function DashboardRecentJobsGrid({
[nameById],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
const { table, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => {
@@ -63,16 +62,13 @@ export function DashboardRecentJobsGrid({
})
return (
<DataGridSection
<DataGridShell
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет задач"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/>
)
}
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
@@ -42,7 +42,7 @@ export function DashboardRecentRevisionsGrid({
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
const { table, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => row.id,
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
})
return (
<DataGridSection
<DataGridShell
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ревизий"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/>
)
}
+1 -1
View File
@@ -50,7 +50,7 @@ export function DataGridShell<TData extends object>({
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
tableLayout={tableLayout}
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
className={className}
className={cn('min-w-0', className)}
onRowClick={onRowClick}
>
<DataGridContainer border={false}>
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import {
useCreateCommunityMutation,
useUpdateCommunityMutation,
} from '@/queries/directories'
import type { BgpCommunity, BgpCommunityCreate, BgpCommunityPatch } from '@/types/api'
interface CommunityFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editTarget?: BgpCommunity | null
}
/** @see https://reui.io/preview/base/form-7 */
export function CommunityFormDialog({
open,
onOpenChange,
editTarget = null,
}: CommunityFormDialogProps) {
const createMutation = useCreateCommunityMutation()
const updateMutation = useUpdateCommunityMutation()
const saving = createMutation.isPending || updateMutation.isPending
const [community, setCommunity] = useState('')
const [title, setTitle] = useState('')
useEffect(() => {
if (!open) return
if (editTarget) {
setCommunity(editTarget.community ?? '')
setTitle(editTarget.title ?? '')
} else {
setCommunity('')
setTitle('')
}
}, [editTarget, open])
async function save() {
const value = community.trim()
if (!value) {
toast.error('Укажите community')
return
}
const titleTrimmed = title.trim()
try {
if (editTarget) {
const body: BgpCommunityPatch = {
community: value,
title: titleTrimmed || '',
}
await updateMutation.mutateAsync({ id: editTarget.id, body })
} else {
const body: BgpCommunityCreate = { community: value }
if (titleTrimmed) body.title = titleTrimmed
await createMutation.mutateAsync(body)
}
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={editTarget ? 'Редактировать community' : 'Новое BGP community'}
description="Тег для префиксов в фильтрах BIRD"
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={() => void save()}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="comm-value">Community</Label>
<Input
id="comm-value"
placeholder="65000:100"
value={community}
onChange={(e) => setCommunity(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="comm-title">Название (опционально)</Label>
<Input
id="comm-title"
placeholder="Отображаемое имя"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</FormDrawer>
)
}
/** @deprecated Use CommunityFormDialog */
export const CommunityCreateDialog = CommunityFormDialog
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { BgpCommunity } from '@/types/api'
@@ -11,12 +12,16 @@ import type { BgpCommunity } from '@/types/api'
export function DirectoriesCommunitiesGrid({
items,
isLoading = false,
canWrite = false,
onEdit,
}: {
items: BgpCommunity[]
isLoading?: boolean
canWrite?: boolean
onEdit?: (row: BgpCommunity) => void
}) {
const columns = useMemo<ColumnDef<BgpCommunity>[]>(
() => [
const columns = useMemo<ColumnDef<BgpCommunity>[]>(() => {
const cols: ColumnDef<BgpCommunity>[] = [
{
accessorKey: 'title',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
@@ -38,9 +43,23 @@ export function DirectoriesCommunitiesGrid({
cell: () => <CategoryBadge>community</CategoryBadge>,
meta: { headerTitle: 'Тип' },
},
],
[],
)
]
if (canWrite && onEdit) {
cols.push({
id: 'actions',
enableSorting: false,
enableHiding: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
),
meta: { headerTitle: 'Действия' },
})
}
return cols
}, [canWrite, onEdit])
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
@@ -54,10 +73,10 @@ export function DirectoriesCommunitiesGrid({
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет сообществ"
emptyMessage="Нет community"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск сообществ…"
searchPlaceholder="Поиск community…"
/>
)
}
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { DohProfile } from '@/types/api'
@@ -11,12 +12,16 @@ import type { DohProfile } from '@/types/api'
export function DirectoriesDohGrid({
items,
isLoading = false,
canWrite = false,
onEdit,
}: {
items: DohProfile[]
isLoading?: boolean
canWrite?: boolean
onEdit?: (row: DohProfile) => void
}) {
const columns = useMemo<ColumnDef<DohProfile>[]>(
() => [
const columns = useMemo<ColumnDef<DohProfile>[]>(() => {
const cols: ColumnDef<DohProfile>[] = [
{
id: 'name',
accessorFn: (row) => row.name ?? row.url,
@@ -43,9 +48,23 @@ export function DirectoriesDohGrid({
cell: () => <CategoryBadge></CategoryBadge>,
meta: { headerTitle: 'По умолчанию' },
},
],
[],
)
]
if (canWrite && onEdit) {
cols.push({
id: 'actions',
enableSorting: false,
enableHiding: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
),
meta: { headerTitle: 'Действия' },
})
}
return cols
}, [canWrite, onEdit])
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
@@ -0,0 +1,36 @@
import { MoreHorizontalIcon, PencilIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
/**
* Data-grid row actions — ⋯ menu (ReUI / shadcn DropdownMenu).
* @see https://reui.io/preview/base/components/c-dropdown-menu-12
* @see https://reui.io/docs/components/base/dropdown-menu
*/
export function DirectoriesRowActions({ onEdit }: { onEdit: () => void }) {
return (
<div className="flex justify-end">
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="ghost" size="icon-sm" aria-label="Действия">
<MoreHorizontalIcon className="size-4" aria-hidden />
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem onClick={onEdit}>
<PencilIcon aria-hidden />
Редактировать
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import {
useCreateDohProfileMutation,
useUpdateDohProfileMutation,
} from '@/queries/directories'
import type { DohProfile, DohProfileCreate, DohProfilePatch } from '@/types/api'
interface DohProfileFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editTarget?: DohProfile | null
}
/** @see https://reui.io/preview/base/form-7 */
export function DohProfileFormDialog({
open,
onOpenChange,
editTarget = null,
}: DohProfileFormDialogProps) {
const createMutation = useCreateDohProfileMutation()
const updateMutation = useUpdateDohProfileMutation()
const saving = createMutation.isPending || updateMutation.isPending
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const [timeoutMs, setTimeoutMs] = useState('')
useEffect(() => {
if (!open) return
if (editTarget) {
setName(editTarget.name ?? '')
setUrl(editTarget.url ?? '')
setTimeoutMs(
editTarget.timeout_ms === null || editTarget.timeout_ms === undefined
? ''
: String(editTarget.timeout_ms),
)
} else {
setName('')
setUrl('')
setTimeoutMs('')
}
}, [editTarget, open])
async function save() {
const trimmedUrl = url.trim()
if (!trimmedUrl) {
toast.error('Укажите URL DoH')
return
}
let urlOk = true
try {
new URL(trimmedUrl)
} catch {
urlOk = false
}
if (!urlOk) {
toast.error('Некорректный URL')
return
}
let timeout: number | null = null
if (timeoutMs.trim() !== '') {
const ms = Number(timeoutMs)
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
toast.error('Таймаут должен быть целым числом больше 0')
return
}
timeout = ms
}
const nameTrimmed = name.trim()
try {
if (editTarget) {
const body: DohProfilePatch = {
url: trimmedUrl,
name: nameTrimmed || undefined,
timeout_ms: timeout,
}
await updateMutation.mutateAsync({ id: editTarget.id, body })
} else {
const body: DohProfileCreate = { url: trimmedUrl }
if (nameTrimmed) body.name = nameTrimmed
if (timeout !== null) body.timeout_ms = timeout
await createMutation.mutateAsync(body)
}
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={editTarget ? 'Редактировать DoH-профиль' : 'Новый DoH-профиль'}
description="Резолвер DNS-over-HTTPS для доменных модулей"
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={() => void save()}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="doh-name">Имя (опционально)</Label>
<Input
id="doh-name"
placeholder="Control D / AdGuard"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="doh-url">URL</Label>
<Input
id="doh-url"
type="url"
placeholder="https://dns.example/dns-query"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="doh-timeout">Таймаут, мс (необязательно)</Label>
<Input
id="doh-timeout"
type="number"
min={1}
placeholder="5000"
value={timeoutMs}
onChange={(e) => setTimeoutMs(e.target.value)}
/>
</div>
</FormDrawer>
)
}
/** @deprecated Use DohProfileFormDialog */
export const DohProfileCreateDialog = DohProfileFormDialog
-31
View File
@@ -1,31 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import { DrawerFooter } from '@evobgp/ui/components/drawer'
/** Shared footer layout for right-side form and confirm drawers. */
export function DrawerActionsFooter({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<DrawerFooter
className={cn(
'mt-0 shrink-0 border-t border-border bg-muted/50 p-4',
className,
)}
>
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{children}
</div>
</DrawerFooter>
)
}
export const formDrawerContentClassName =
'flex h-full max-h-dvh flex-col sm:max-w-lg'
export const confirmDrawerContentClassName = 'flex h-auto max-h-dvh flex-col sm:max-w-sm'
+84 -9
View File
@@ -1,28 +1,103 @@
/**
* ReUI Empty + IconStack — Frame-friendly (empty-state-14 / empty-state-12).
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/docs/components/base/icon-stack
*/
import { InboxIcon, type LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@evobgp/ui/components/empty'
import { cn } from '@evobgp/ui/lib/utils'
interface EmptyStateProps {
icon?: LucideIcon | ReactNode
title: string
description?: string
icon?: ReactNode
action?: ReactNode
className?: string
stackedIcon?: boolean
centered?: boolean
}
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
if (typeof icon === 'function') return true
if (icon != null && typeof icon === 'object' && '$$typeof' in icon) {
if ('props' in icon) return false
return true
}
return false
}
export function EmptyState({
icon,
title,
description,
action,
className,
stackedIcon = true,
centered = true,
}: EmptyStateProps) {
const Icon = isLucideIcon(icon) ? icon : InboxIcon
const customIcon = icon && !isLucideIcon(icon) ? icon : null
const body = (
<Empty
className={cn(
'max-w-md flex-none border-0 bg-transparent p-0',
!centered && className,
)}
>
<EmptyHeader className="gap-5 text-center">
<EmptyMedia className="mb-0">
{customIcon ? (
stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
{customIcon}
</IconStack>
) : (
<div className="text-muted-foreground">{customIcon}</div>
)
) : stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
</IconStack>
) : (
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
<Icon aria-hidden="true" />
</span>
)}
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
{description ? (
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
) : null}
</div>
</EmptyHeader>
{action ? (
<EmptyContent className="mt-1 items-center justify-center">{action}</EmptyContent>
) : null}
</Empty>
)
if (!centered) return body
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
'flex w-full flex-1 items-center justify-center self-stretch py-14 sm:py-16',
className,
)}
>
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{action ? <div className="mt-2">{action}</div> : null}
{body}
</div>
)
}
+25 -24
View File
@@ -2,18 +2,13 @@ import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@evobgp/ui/components/drawer'
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
import {
DrawerActionsFooter,
formDrawerContentClassName,
} from '@/components/drawer-layout'
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evobgp/ui/components/sheet'
interface FormDrawerProps {
open: boolean
@@ -25,6 +20,7 @@ interface FormDrawerProps {
className?: string
}
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
export function FormDrawer({
open,
onOpenChange,
@@ -35,17 +31,22 @@ export function FormDrawer({
className,
}: FormDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
<DrawerContent className={cn(formDrawerContentClassName, className)}>
<DrawerHeader className="shrink-0 border-b border-border pb-4">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 px-4 py-4">{children}</div>
</ScrollArea>
<DrawerActionsFooter>{footer}</DrawerActionsFooter>
</DrawerContent>
</Drawer>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
>
<SheetHeader className="shrink-0 border-b border-border/50">
<SheetTitle>{title}</SheetTitle>
{description ? <SheetDescription>{description}</SheetDescription> : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{footer}
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
+13 -11
View File
@@ -8,7 +8,6 @@ import {
Settings,
BookText,
KeyRound,
ServerCog,
Search,
} from 'lucide-react'
@@ -47,6 +46,7 @@ import { CommandPalette, type CommandPaletteItem } from '@/components/layout/com
import { NavUser } from '@/components/layout/nav-user'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
interface NavItem {
to: string
@@ -71,7 +71,7 @@ const NAV_GROUPS: NavGroup[] = [
to: '/dashboard',
label: 'Панель',
icon: LayoutDashboard,
description: 'KPI, модули и активность',
description: 'Метрики, модули и активность',
},
],
},
@@ -81,23 +81,22 @@ const NAV_GROUPS: NavGroup[] = [
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' },
],
},
{
label: 'Операции',
items: [
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
],
},
{
label: 'Система',
items: [
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
],
},
]
@@ -161,6 +160,9 @@ export function AppShell({ children }: { children: ReactNode }) {
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
@@ -196,7 +198,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavUser />
</SidebarFooter>
</Sidebar>
<SidebarInset>
<SidebarInset id="main-content">
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
@@ -220,9 +222,9 @@ export function AppShell({ children }: { children: ReactNode }) {
<SystemMonitorPopover />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</main>
</div>
</SidebarInset>
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
</SidebarProvider>
+1 -1
View File
@@ -91,7 +91,7 @@ export function AppsMenu() {
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
render={<Link to="/settings" search={{ tab: 'ui' }} />}
className="justify-center text-sm font-medium"
>
Настройки
+3 -3
View File
@@ -136,7 +136,7 @@ export function NavUser() {
setApiToken(null)
if (typeof window !== 'undefined') {
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
window.location.assign('/settings?tab=connection&reason=token-required')
window.location.assign('/settings?tab=ui&reason=token-required')
}
}
@@ -190,10 +190,10 @@ export function NavUser() {
<DropdownMenuGroup>
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
render={<Link to="/settings" search={{ tab: 'ui' }} />}
>
<SettingsIcon aria-hidden />
Настройки UI
Настройки
</DropdownMenuItem>
{authOn ? (
<DropdownMenuItem
@@ -94,8 +94,8 @@ export function SystemMonitorPopover() {
() => [
{
id: 'api',
label: 'API health',
value: healthOk ? 'OK' : '—',
label: 'Состояние API',
value: healthOk ? 'норма' : '—',
unit: '',
percent: healthOk ? 100 : 0,
icon: <HeartPulse aria-hidden />,
@@ -12,14 +12,6 @@ import {
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { SelectMenu } from '@/components/select-field'
import { ApiError, apiMutate } from '@/lib/api-client'
import type {
@@ -30,9 +22,8 @@ import type {
} from '@/types/api'
/**
* Lookup wizard step 3 — choose module + community and create entry.
* Lookup wizard step 3 — module + community (bare content for single Frame).
* @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/docs/components/base/frame
*/
function hostPrefixFromIp(ip: string): string {
@@ -137,7 +128,7 @@ export function LookupAddStep({
<AlertTitle>Нет подходящего модуля</AlertTitle>
<AlertDescription>
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules" search={{ create: true }} />}>
Перейти к модулям
</Button>
</AlertDescription>
@@ -153,14 +144,14 @@ export function LookupAddStep({
: hostPrefixFromIp(data.normalized)
return (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Добавить в списки</FrameTitle>
<FrameDescription>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-semibold">Добавить в списки</h3>
<p className="text-muted-foreground text-sm">
«{valueLabel}» отсутствует в списках. Выберите модуль и community.
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-4">
</p>
</div>
<div className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="lookup-add-module">Модуль ({wantedType})</FieldLabel>
<SelectMenu
@@ -181,8 +172,8 @@ export function LookupAddStep({
communities={communities}
nullable={data.query_kind === 'domain'}
/>
</FramePanel>
<FrameFooter className="flex flex-wrap items-center justify-end gap-2">
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Назад
</Button>
@@ -193,7 +184,7 @@ export function LookupAddStep({
>
Добавить
</LoadingButton>
</FrameFooter>
</Frame>
</div>
</div>
)
}
@@ -4,7 +4,6 @@ import { useNavigate } from '@tanstack/react-router'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { FrameDataGrid } from '@/components/reui-kit'
import { DataGridSection } from '@/components/data-grid-shell'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
@@ -12,7 +11,8 @@ import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { LookupMatch } from '@/types/api'
/**
* Lookup matches grid — data-grid-filtering-2 pattern.
* Lookup matches grid — bare section for wizard-2 single Frame.
* @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/docs/components/base/badge
*/
@@ -113,10 +113,13 @@ export function LookupMatchesGrid({
})
return (
<FrameDataGrid
title="Совпадения"
description="Entries и snapshots · клик по строке открывает модуль"
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-px">
<h3 className="text-sm font-semibold">Совпадения</h3>
<p className="text-muted-foreground text-sm">
Entries и snapshots · клик по строке открывает модуль
</p>
</div>
<DataGridSection
table={table}
recordCount={filteredCount}
@@ -129,6 +132,6 @@ export function LookupMatchesGrid({
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
}
/>
</FrameDataGrid>
</div>
)
}
@@ -8,17 +8,10 @@ import {
InputGroupAddon,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
/**
* Lookup search form — Frame + InputGroup (form-7 pattern).
* @see https://reui.io/preview/base/form-7
* Lookup search fields — bare form for wizard-2 single-Frame surface.
* @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/docs/components/base/frame
*/
export function LookupSearchForm({
@@ -40,37 +33,27 @@ export function LookupSearchForm({
}
return (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Проверка списка</FrameTitle>
<FrameDescription>
IP, CIDR или FQDN поиск в entries и материализованных snapshots с community.
</FrameDescription>
</FrameHeader>
<FramePanel>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor="lookup-q">IP, CIDR или домен</FieldLabel>
<InputGroup>
<InputGroupAddon align="inline-start">
<Search aria-hidden />
</InputGroupAddon>
<InputGroupInput
id="lookup-q"
name="q"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="8.8.8.8, 203.0.113.0/24 или example.com"
autoComplete="off"
autoFocus
/>
</InputGroup>
</Field>
<Button type="submit" disabled={isPending || !value.trim()}>
Проверить
</Button>
</form>
</FramePanel>
</Frame>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor="lookup-q">IP, CIDR или домен</FieldLabel>
<InputGroup>
<InputGroupAddon align="inline-start">
<Search aria-hidden />
</InputGroupAddon>
<InputGroupInput
id="lookup-q"
name="q"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="8.8.8.8, 203.0.113.0/24 или example.com"
autoComplete="off"
autoFocus
/>
</InputGroup>
</Field>
<Button type="submit" disabled={isPending || !value.trim()}>
Проверить
</Button>
</form>
)
}
@@ -0,0 +1,74 @@
import { CircleAlert, CircleCheck, ShieldAlert } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
/**
* Lookup result status plaque — ReUI Alert + Badge.
* @see https://reui.io/docs/components/base/alert
* @see https://reui.io/preview/base/components/c-alert-6
* @see https://reui.io/docs/components/base/badge
*/
export function LookupStatusBanner({
status,
normalized,
matchCount = 0,
}: {
status: 'found' | 'missing' | 'missing_denied'
normalized: string
matchCount?: number
}) {
if (status === 'found') {
return (
<Alert variant="success" className="items-start py-3">
<CircleCheck aria-hidden />
<AlertTitle className="line-clamp-none flex flex-wrap items-center gap-2">
Найдено
<Badge variant="success-light" size="sm" radius="full">
в списках
</Badge>
</AlertTitle>
<AlertDescription>
«{normalized}» {matchCount} совп. в entries / snapshots.
</AlertDescription>
</Alert>
)
}
if (status === 'missing_denied') {
return (
<Alert variant="warning" className="items-start py-3">
<ShieldAlert aria-hidden />
<AlertTitle className="line-clamp-none flex flex-wrap items-center gap-2">
Не найдено
<Badge variant="warning-light" size="sm" radius="full">
нет прав
</Badge>
</AlertTitle>
<AlertDescription>
«{normalized}» отсутствует в списках. Для добавления нужно право{' '}
<span className="font-medium text-foreground">bgp:modules:write</span>.
</AlertDescription>
</Alert>
)
}
return (
<Alert variant="warning" className="items-start py-3">
<CircleAlert aria-hidden />
<AlertTitle className="line-clamp-none flex flex-wrap items-center gap-2">
Не найдено
<Badge variant="warning-light" size="sm" radius="full">
можно добавить
</Badge>
</AlertTitle>
<AlertDescription>
«{normalized}» отсутствует в entries и snapshots. Добавить запись в модуль?
</AlertDescription>
</Alert>
)
}
@@ -1,12 +1,13 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, Plus, Search, ShieldAlert } from 'lucide-react'
import { Check, Search } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { LookupAddStep } from '@/components/lookup/lookup-add-step'
import { LookupMatchesGrid } from '@/components/lookup/lookup-matches-grid'
import { LookupSearchForm } from '@/components/lookup/lookup-search-form'
import { LookupStatusBanner } from '@/components/lookup/lookup-status-banner'
import { EmptyState } from '@/components/empty-state'
import { QueryState } from '@/components/query-state'
import {
@@ -29,7 +30,6 @@ import {
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
@@ -42,8 +42,7 @@ import { lookupKeys, lookupQueryOptions } from '@/queries/lookup'
import { modulesListQueryOptions } from '@/queries/modules'
/**
* Lookup membership wizard — check → result → optional add.
* DNA: wizard-2 · surface frame · stepper · FrameFooter CTAs.
* Lookup membership wizard — one Frame (wizard-2), all content inside.
* @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/docs/components/base/stepper
* @see https://reui.io/docs/components/base/frame
@@ -55,6 +54,14 @@ const STEP_RESULT = 2
const STEP_ADD = 3
const STEP_DONE = 4
function WizardActions({ children }: { children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
{children}
</div>
)
}
export function LookupWizard({
q,
onSubmitQuery,
@@ -109,13 +116,14 @@ export function LookupWizard({
return (
<div className="flex flex-col gap-4 md:gap-6">
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Мастер проверки</FrameTitle>
<FrameDescription>
Проверка IP / CIDR / домена в списках, затем при необходимости добавление.
</FrameDescription>
</FrameHeader>
<FramePanel>
<FramePanel className="flex flex-col gap-6">
<FrameHeader className="px-0 py-0">
<FrameTitle>Мастер проверки</FrameTitle>
<FrameDescription>
Проверка IP / CIDR / домена в списках, затем при необходимости добавление.
</FrameDescription>
</FrameHeader>
<Stepper
value={step}
onValueChange={setStep}
@@ -184,21 +192,21 @@ export function LookupWizard({
<StepperPanel>
<StepperContent value={STEP_QUERY}>
<LookupSearchForm
key={`form-${trimmed}`}
initialQuery={trimmed}
isPending={lookupQ.isFetching}
onSubmit={onSubmitQuery}
/>
{!trimmed ? (
<div className="mt-4">
<div className="flex flex-col gap-6">
<LookupSearchForm
key={`form-${trimmed}`}
initialQuery={trimmed}
isPending={lookupQ.isFetching}
onSubmit={onSubmitQuery}
/>
{!trimmed ? (
<EmptyState
icon={<Search className="size-8" />}
title="Введите IP, CIDR или домен"
description="Например 8.8.8.8, 203.0.113.0/24 или example.com — проверка по entries и snapshots."
/>
</div>
) : null}
) : null}
</div>
</StepperContent>
<StepperContent value={STEP_RESULT}>
@@ -228,75 +236,59 @@ export function LookupWizard({
{result.matched ? (
<>
<Alert variant="success">
<Check className="size-4" />
<AlertTitle>Уже есть в списках</AlertTitle>
<AlertDescription>
«{result.normalized}» найден в entries и/или snapshots
({result.match_count} совпад.).
</AlertDescription>
</Alert>
<LookupStatusBanner
status="found"
normalized={result.normalized}
matchCount={result.match_count}
/>
<LookupMatchesGrid
items={result.matches}
isLoading={lookupQ.isFetching}
/>
<Frame spacing="sm" className="w-full">
<FrameFooter className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={resetToQuery}
>
Новая проверка
</Button>
</FrameFooter>
</Frame>
<WizardActions>
<Button
type="button"
variant="outline"
onClick={resetToQuery}
>
Новая проверка
</Button>
</WizardActions>
</>
) : canWrite ? (
<>
<Alert variant="info">
<Plus className="size-4" />
<AlertTitle>Не найдено в списках</AlertTitle>
<AlertDescription>
«{result.normalized}» отсутствует. Добавить запись?
</AlertDescription>
</Alert>
<Frame spacing="sm" className="w-full">
<FrameFooter className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={resetToQuery}
>
Новая проверка
</Button>
<Button type="button" onClick={() => goToAdd()}>
Добавить
</Button>
</FrameFooter>
</Frame>
<LookupStatusBanner
status="missing"
normalized={result.normalized}
/>
<WizardActions>
<Button
type="button"
variant="outline"
onClick={resetToQuery}
>
Новая проверка
</Button>
<Button type="button" onClick={() => goToAdd()}>
Добавить
</Button>
</WizardActions>
</>
) : (
<>
<Alert variant="warning">
<ShieldAlert className="size-4" />
<AlertTitle>Не найдено</AlertTitle>
<AlertDescription>
«{result.normalized}» отсутствует в списках. У вас нет
права добавлять записи (нужно bgp:modules:write).
</AlertDescription>
</Alert>
<Frame spacing="sm" className="w-full">
<FrameFooter className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={resetToQuery}
>
Новая проверка
</Button>
</FrameFooter>
</Frame>
<LookupStatusBanner
status="missing_denied"
normalized={result.normalized}
/>
<WizardActions>
<Button
type="button"
variant="outline"
onClick={resetToQuery}
>
Новая проверка
</Button>
</WizardActions>
</>
)}
</div>
@@ -329,9 +321,9 @@ export function LookupWizard({
<StepperContent value={STEP_DONE}>
<div className="flex flex-col gap-4 md:gap-6">
<Alert variant="success">
<Check className="size-4" />
<AlertTitle>Запись добавлена</AlertTitle>
<Alert variant="success" className="items-start py-3">
<Check aria-hidden />
<AlertTitle className="line-clamp-none">Запись добавлена</AlertTitle>
<AlertDescription>
Повторная проверка обновлена. Можно посмотреть совпадения или
начать новый запрос.
@@ -343,20 +335,14 @@ export function LookupWizard({
isLoading={lookupQ.isFetching}
/>
) : null}
<Frame spacing="sm" className="w-full">
<FrameFooter className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={resetToQuery}
>
Новая проверка
</Button>
<Button type="button" onClick={() => setStep(STEP_RESULT)}>
К результату
</Button>
</FrameFooter>
</Frame>
<WizardActions>
<Button type="button" variant="outline" onClick={resetToQuery}>
Новая проверка
</Button>
<Button type="button" onClick={() => setStep(STEP_RESULT)}>
К результату
</Button>
</WizardActions>
</div>
</StepperContent>
</StepperPanel>
@@ -14,8 +14,8 @@ import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
const CDN_KIND_ITEMS = [
{ value: 'plaintext', label: 'plaintext' },
{ value: 'json', label: 'json' },
{ value: 'plaintext', label: 'Текст' },
{ value: 'json', label: 'JSON' },
] as const
interface ModuleCdnSourceDialogProps {
@@ -0,0 +1,289 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import { dohProfileShortLabel } from '@/lib/modules/helpers'
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
import { useCreateModuleMutation } from '@/queries/modules'
import type {
BgpCommunity,
DohProfile,
DohResolverPolicy,
ModuleCreate,
ModuleRow,
ModuleType,
} from '@/types/api'
interface ModuleCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
communities: BgpCommunity[]
dohProfiles: DohProfile[]
onCreated?: (mod: ModuleRow) => void
}
/** @see https://reui.io/preview/base/form-7 */
/** @see https://reui.io/preview/base/sheet-8 */
const MODULE_TYPE_ITEMS: { value: ModuleType; label: string }[] = [
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') },
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
]
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
{ value: 'failover', label: dohPolicyRu('failover') },
{ value: 'union', label: dohPolicyRu('union') },
]
export function ModuleCreateDialog({
open,
onOpenChange,
communities,
dohProfiles,
onCreated,
}: ModuleCreateDialogProps) {
const createMutation = useCreateModuleMutation()
const [type, setType] = useState<ModuleType>('IP_RANGES')
const [name, setName] = useState('')
const [enabled, setEnabled] = useState(true)
const [priority, setPriority] = useState('0')
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
const [cronExpr, setCronExpr] = useState('')
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
const isDomains = type === 'DOMAINS'
useEffect(() => {
if (!open) return
setType('IP_RANGES')
setName('')
setEnabled(true)
setPriority('0')
setRefreshIntervalSec('')
setCronExpr('')
setDefaultCommunityId(null)
setDohResolverPolicy('primary_only')
setDohProfileIds([])
}, [open])
function toggleDohProfile(id: string, checked: boolean) {
setDohProfileIds((prev) => {
if (checked) {
if (prev.includes(id)) return prev
return [...prev, id]
}
return prev.filter((x) => x !== id)
})
}
async function save() {
const trimmedName = name.trim()
if (!trimmedName) {
toast.error('Укажите название модуля')
return
}
const priorityNum = Number(priority)
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
toast.error('Приоритет должен быть целым числом')
return
}
let refresh: number | undefined
if (refreshIntervalSec.trim() !== '') {
const n = Number(refreshIntervalSec)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
toast.error('Интервал обновления должен быть целым числом ≥ 0')
return
}
refresh = n
}
const body: ModuleCreate = {
type,
name: trimmedName,
enabled,
priority: priorityNum,
}
if (refresh !== undefined) {
body.refresh_interval_sec = refresh
}
const cron = cronExpr.trim()
if (cron) {
body.cron_expr = cron
}
if (defaultCommunityId) {
body.default_community_id = defaultCommunityId
}
if (isDomains) {
body.doh_resolver_policy = dohResolverPolicy
body.doh_profile_ids = dohProfileIds
}
try {
const created = await createMutation.mutateAsync(body)
onOpenChange(false)
onCreated?.(created)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новый модуль"
description="Тип задаётся один раз. Записи добавляются на карточке модуля."
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
Создать
</LoadingButton>
</>
}
>
<SelectField
id="mod-create-type"
label="Тип"
items={MODULE_TYPE_ITEMS}
value={type}
onValueChange={(v) => {
if (v) setType(v)
}}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-name">Название</Label>
<Input
id="mod-create-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Имя модуля"
/>
</div>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="mod-create-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
Выключенный модуль не участвует в обновлении и применении.
</p>
</div>
<Checkbox
id="mod-create-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-priority">Приоритет</Label>
<Input
id="mod-create-priority"
type="number"
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
<Input
id="mod-create-interval"
type="number"
min={0}
placeholder="пусто = по умолчанию"
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
<Input
id="mod-create-cron"
placeholder="0 * * * *"
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
/>
</div>
<CommunitySelect
id="mod-create-community"
label="Community по умолчанию"
value={defaultCommunityId}
onValueChange={setDefaultCommunityId}
communities={communities}
nullable
/>
{isDomains ? (
<>
<SelectField
id="mod-create-doh-policy"
label="Политика DoH"
items={DOH_POLICY_ITEMS}
value={dohResolverPolicy}
onValueChange={(v) => {
if (v) setDohResolverPolicy(v)
}}
/>
<div className="flex flex-col gap-2">
<Label>DoH профили</Label>
{dohProfiles.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
) : (
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
{dohProfiles.map((p) => {
const checked = dohProfileIds.includes(p.id)
return (
<label
key={p.id}
htmlFor={`mod-create-doh-${p.id}`}
className="flex cursor-pointer items-start gap-3"
>
<Checkbox
id={`mod-create-doh-${p.id}`}
checked={checked}
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
className="mt-0.5"
/>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">
{dohProfileShortLabel(p.id, dohProfiles)}
</span>
<span className="text-muted-foreground truncate text-xs" title={p.url}>
{p.url}
</span>
</span>
</label>
)
})}
</div>
)}
</div>
</>
) : null}
</FormDrawer>
)
}
@@ -0,0 +1,260 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import {
dohProfileShortLabel,
moduleDohProfileIds,
} from '@/lib/modules/helpers'
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
import { useUpdateModuleMutation } from '@/queries/modules'
import type {
BgpCommunity,
DohProfile,
DohResolverPolicy,
ModulePatch,
ModuleRow,
} from '@/types/api'
interface ModuleEditDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
mod: ModuleRow
communities: BgpCommunity[]
dohProfiles: DohProfile[]
}
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
{ value: 'failover', label: dohPolicyRu('failover') },
{ value: 'union', label: dohPolicyRu('union') },
]
export function ModuleEditDialog({
open,
onOpenChange,
mod,
communities,
dohProfiles,
}: ModuleEditDialogProps) {
const updateMutation = useUpdateModuleMutation()
const isDomains = mod.type === 'DOMAINS'
const [name, setName] = useState('')
const [enabled, setEnabled] = useState(true)
const [priority, setPriority] = useState('0')
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
const [cronExpr, setCronExpr] = useState('')
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
useEffect(() => {
if (!open) return
setName(mod.name ?? '')
setEnabled(mod.enabled !== false)
setPriority(String(mod.priority ?? 0))
setRefreshIntervalSec(
mod.refresh_interval_sec === null || mod.refresh_interval_sec === undefined
? ''
: String(mod.refresh_interval_sec),
)
setCronExpr(mod.cron_expr ?? '')
setDefaultCommunityId(mod.default_community_id ?? null)
setDohResolverPolicy(mod.doh_resolver_policy ?? 'primary_only')
setDohProfileIds(moduleDohProfileIds(mod))
}, [mod, open])
function toggleDohProfile(id: string, checked: boolean) {
setDohProfileIds((prev) => {
if (checked) {
if (prev.includes(id)) return prev
return [...prev, id]
}
return prev.filter((x) => x !== id)
})
}
async function save() {
const trimmedName = name.trim()
if (!trimmedName) {
toast.error('Укажите название модуля')
return
}
const priorityNum = Number(priority)
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
toast.error('Приоритет должен быть целым числом')
return
}
let refresh: number | null = null
if (refreshIntervalSec.trim() !== '') {
const n = Number(refreshIntervalSec)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
toast.error('Интервал обновления должен быть целым числом ≥ 0')
return
}
refresh = n
}
const body: ModulePatch = {
name: trimmedName,
enabled,
priority: priorityNum,
refresh_interval_sec: refresh,
cron_expr: cronExpr.trim() || null,
default_community_id: defaultCommunityId,
}
if (isDomains) {
body.doh_resolver_policy = dohResolverPolicy
body.doh_profile_ids = dohProfileIds
}
try {
await updateMutation.mutateAsync({ id: mod.id, body })
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Редактировать модуль"
description={`${moduleTypeRu(mod.type)} · ${mod.type}`}
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={updateMutation.isPending} onClick={() => void save()}>
Сохранить
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-name">Название</Label>
<Input
id="mod-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Имя модуля"
/>
</div>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="mod-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
Выключенный модуль не участвует в обновлении и применении.
</p>
</div>
<Checkbox
id="mod-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-priority">Приоритет</Label>
<Input
id="mod-priority"
type="number"
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-interval">Интервал обновления (сек)</Label>
<Input
id="mod-interval"
type="number"
min={0}
placeholder="пусто = по умолчанию"
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-cron">Cron (опционально)</Label>
<Input
id="mod-cron"
placeholder="0 * * * *"
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
/>
</div>
<CommunitySelect
id="mod-community"
label="Community по умолчанию"
value={defaultCommunityId}
onValueChange={setDefaultCommunityId}
communities={communities}
nullable
/>
{isDomains ? (
<>
<SelectField
id="mod-doh-policy"
label="Политика DoH"
items={DOH_POLICY_ITEMS}
value={dohResolverPolicy}
onValueChange={(v) => setDohResolverPolicy(v as DohResolverPolicy)}
/>
<div className="flex flex-col gap-2">
<Label>DoH профили</Label>
{dohProfiles.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
) : (
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
{dohProfiles.map((p) => {
const checked = dohProfileIds.includes(p.id)
return (
<label
key={p.id}
htmlFor={`mod-doh-${p.id}`}
className="flex cursor-pointer items-start gap-3"
>
<Checkbox
id={`mod-doh-${p.id}`}
checked={checked}
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
className="mt-0.5"
/>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">{dohProfileShortLabel(p.id, dohProfiles)}</span>
<span className="text-muted-foreground truncate text-xs" title={p.url}>
{p.url}
</span>
</span>
</label>
)
})}
</div>
)}
</div>
</>
) : null}
</FormDrawer>
)
}
@@ -10,6 +10,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatDateTime } from '@/lib/modules/display'
import { communityLabel } from '@/lib/modules/helpers'
import { cdnSourceKindRu } from '@/lib/ui-labels'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type {
AsEntry,
@@ -145,7 +146,7 @@ export function ModuleEntriesGrid({
accessorKey: 'source_kind',
header: 'Тип',
cell: ({ row }: { row: { original: CdnSource } }) => (
<CategoryBadge>{row.original.source_kind}</CategoryBadge>
<CategoryBadge>{cdnSourceKindRu(row.original.source_kind)}</CategoryBadge>
),
},
{
@@ -2,29 +2,25 @@ import { Clock, Gauge, Globe, Tags } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { Badge } from '@/components/reui/badge'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
import {
communityLabel,
dohProfileLabel,
moduleDohProfileIds,
} from '@/lib/modules/helpers'
import { dohPolicyRu } from '@/lib/ui-labels'
import { KpiStatGridSkeleton } from '@/components/skeletons'
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
import { communityLabel, moduleDohProfileIds } from '@/lib/modules/helpers'
import { dohPolicyRu } from '@/lib/ui-labels'
import type { AsEntry, BgpCommunity, ModuleRow } from '@/types/api'
interface ModuleKpiCardsProps {
mod: ModuleRow | null
communities: BgpCommunity[]
dohProfiles: DohProfile[]
asEntries: AsEntry[]
entriesCount?: number
loading?: boolean
}
export function ModuleKpiCards({
mod,
communities,
dohProfiles,
asEntries,
entriesCount = 0,
loading = false,
}: ModuleKpiCardsProps) {
if (loading || !mod) {
@@ -33,6 +29,49 @@ export function ModuleKpiCards({
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
const dohIds = moduleDohProfileIds(mod)
const isDomains = mod.type === 'DOMAINS'
const isAsPrefixes = mod.type === 'AS_PREFIXES'
const community = communityLabel(mod.default_community_id, communities)
const entriesFooter = isAsPrefixes ? (
<Badge variant="success-light" size="sm">
{asEntries.length} AS · в модуле
</Badge>
) : isDomains ? (
<Badge variant="outline" size="sm">
{mod.default_community_id ? community : 'без community'}
</Badge>
) : (
<Badge variant="success-light" size="sm">
{entriesCount} · в модуле
</Badge>
)
const policyItem: KpiStatItem = isDomains
? {
id: 'doh',
icon: <Globe aria-hidden />,
iconClassName: 'text-primary',
value: dohIds.length > 0 ? String(dohIds.length) : '—',
label: 'DoH',
footer: (
<Badge variant={dohIds.length > 0 ? 'info-light' : 'outline'} size="sm">
{dohIds.length > 0 ? dohPolicyRu(mod.doh_resolver_policy) : 'без DoH'}
</Badge>
),
}
: {
id: 'community',
icon: <Globe aria-hidden />,
iconClassName: 'text-primary',
value: community,
label: 'Community',
footer: (
<Badge variant="outline" size="sm">
по умолчанию
</Badge>
),
}
const items: KpiStatItem[] = [
{
@@ -63,29 +102,11 @@ export function ModuleKpiCards({
id: 'prefixes',
icon: <Tags aria-hidden />,
iconClassName: 'text-success',
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
label: mod.type === 'AS_PREFIXES' ? 'Префиксы AS' : 'Записи модуля',
footer: (
<Badge variant="success-light" size="sm">
{asEntries.length} AS · в модуле
</Badge>
),
},
{
id: 'policy',
icon: <Globe aria-hidden />,
iconClassName: 'text-primary',
value: dohIds.length > 0 ? String(dohIds.length) : '—',
label: communityLabel(mod.default_community_id, communities),
footer: (
<Badge variant="info-light" size="sm">
{dohPolicyRu(mod.doh_resolver_policy)}
{dohIds.length > 0
? ` · ${dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')}`
: ' · без DoH'}
</Badge>
),
value: isAsPrefixes ? String(asPrefixTotal) : String(entriesCount),
label: isAsPrefixes ? 'Префиксы AS' : 'Записи модуля',
footer: entriesFooter,
},
policyItem,
]
return <KpiStatGrid items={items} aria-label="KPI модуля" />
@@ -7,12 +7,20 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { jobStatusRu, readyCheckRu } from '@/lib/ui-labels'
import {
isReadyCheckOk,
isSystemReady,
readyCheckStatusLabel,
} from '@/lib/metrics'
import { readyCheckRu } from '@/lib/ui-labels'
import type { ReadyStatus } from '@/queries/monitoring'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { cn } from '@evobgp/ui/lib/utils'
const READY_CHECK_ICONS: Record<string, typeof Database> = {
postgres: Database,
store: HardDrive,
store_backend: HardDrive,
jobs: ListTodo,
}
@@ -21,6 +29,7 @@ interface ReadyCheckRow {
label: string
subtitle?: string
icon: typeof Database
iconClassName?: string
status: string
statusLabel: string
}
@@ -34,12 +43,14 @@ export function MonitoringReadyGrid({
}) {
const data = useMemo<ReadyCheckRow[]>(() => {
const checks = ready.checks ?? {}
const systemReady = isSystemReady(ready.status)
const rows: ReadyCheckRow[] = [
{
id: 'liveness',
label: 'Живучесть',
subtitle: '/v1/health',
icon: HeartPulse,
iconClassName: health?.ok ? 'text-success' : 'text-destructive',
status: health?.ok ? 'ok' : 'error',
statusLabel: health?.ok ? 'В норме' : 'Ошибка',
},
@@ -48,19 +59,21 @@ export function MonitoringReadyGrid({
label: 'Готовность',
subtitle: '/v1/ready',
icon: ShieldCheck,
status: ready.status === 'ok' ? 'ok' : 'warning',
statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'),
iconClassName: systemReady ? 'text-success' : 'text-warning',
status: systemReady ? 'ok' : 'warning',
statusLabel: systemReady ? 'Готов' : 'Не готов',
},
]
for (const key of Object.keys(checks)) {
const value = checks[key]
const ok = typeof value === 'boolean' ? value : value?.ok !== false
const ok = isReadyCheckOk(value)
rows.push({
id: key,
label: readyCheckRu(key),
icon: READY_CHECK_ICONS[key] ?? ListTodo,
iconClassName: ok ? 'text-success' : 'text-destructive',
status: ok ? 'ok' : 'error',
statusLabel: ok ? 'В норме' : 'Ошибка',
statusLabel: readyCheckStatusLabel(value, ok),
})
}
return rows
@@ -74,8 +87,17 @@ export function MonitoringReadyGrid({
cell: ({ row }) => {
const Icon = row.original.icon
return (
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 items-center gap-2.5 py-0.5">
<Item
className={cn(
'border-background bg-muted flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-3.5',
row.original.iconClassName,
)}
>
<ItemMedia variant="icon" className="size-auto">
<Icon />
</ItemMedia>
</Item>
<DataGridPrimaryCell
title={row.original.label}
subtitle={row.original.subtitle}
@@ -90,7 +112,9 @@ export function MonitoringReadyGrid({
enableSorting: false,
header: 'Статус',
cell: ({ row }) => (
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
<div className="flex justify-start">
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
</div>
),
meta: { headerTitle: 'Статус' },
},
@@ -35,7 +35,7 @@ import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)'
return host ? `Плоскость · ${host}` : 'Плоскость управления'
}
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
}
@@ -51,7 +51,7 @@ const filterFields: FilterFieldConfig[] = [
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'IP или Neighbor ID…',
placeholder: 'IP или ID соседа…',
},
]
@@ -94,7 +94,7 @@ export function NetworkDiscoveredPeersCard({
id: 'neighbor_id',
accessorFn: (row) => row.neighbor_id || row.neighbor,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Neighbor ID" />
<DataGridColumnHeader column={column} title="ID соседа" />
),
cell: ({ row }) => (
<DataGridPrimaryCell
@@ -103,7 +103,7 @@ export function NetworkDiscoveredPeersCard({
accent="mono"
/>
),
meta: { headerTitle: 'Neighbor ID' },
meta: { headerTitle: 'ID соседа' },
},
{
accessorKey: 'remote_asn',
@@ -212,7 +212,7 @@ export function NetworkDiscoveredPeersCard({
title="Одобрить пира"
description={
approveTarget
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
? `ID соседа ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
: undefined
}
className="sm:max-w-sm"
@@ -30,7 +30,7 @@ export function NetworkKpi({
id: 'peers-established',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
label: 'Пиры Established',
label: 'Пиры установлены',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
footer: (
<Badge variant="success-light" size="sm">
@@ -44,7 +44,7 @@ export function NetworkKpi({
id: 'speakers-online',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-info',
label: 'Спикеры online',
label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
footer: (
<Badge
@@ -55,7 +55,7 @@ export function NetworkKpi({
}
size="sm"
>
{loading ? '…' : 'live'}
{loading ? '…' : 'в сети'}
</Badge>
),
to: '/network',
@@ -1,8 +1,10 @@
import { useMemo, useState } from 'react'
import { Plus, SearchIcon, ActivityIcon } from 'lucide-react'
import type { ColumnDef } from '@tanstack/react-table'
import { Plus, SearchIcon, ActivityIcon, Trash2 } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
import {
getPeerFilterFieldValue,
@@ -15,27 +17,33 @@ import {
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
import { useDeletePeerMutation } from '@/queries/network'
import type { PeerRow, SpeakerRow } from '@/types/api'
/**
* BGP peers list — ResourcePage (Frame + tabs + Filters + DataGrid).
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/docs/components/base/data-grid
*/
function peerDeleteLabel(p: PeerRow): string {
return p.name?.trim() || p.neighbor || p.id
}
const PEER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'established', label: 'Established' },
{ id: 'established', label: 'Установлены' },
{ id: 'pending', label: 'Ожидание' },
{ id: 'disabled', label: 'Выключены' },
]
const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Established' },
{ value: 'Idle', label: 'Idle' },
{ value: 'Active', label: 'Active' },
{ value: 'Connect', label: 'Connect' },
{ value: 'OpenSent', label: 'OpenSent' },
{ value: 'OpenConfirm', label: 'OpenConfirm' },
{ value: 'Established', label: 'Установлена' },
{ value: 'Idle', label: 'Простой' },
{ value: 'Active', label: 'Поиск' },
{ value: 'Connect', label: 'Соединение' },
{ value: 'OpenSent', label: 'Open отправлен' },
{ value: 'OpenConfirm', label: 'Open подтверждён' },
]
function createDefaultPeerFilters(): Filter[] {
@@ -89,7 +97,9 @@ export function NetworkPeersCard({
error,
onRetry,
}: NetworkPeersCardProps) {
const deleteMutation = useDeletePeerMutation()
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<PeerRow | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
const addButton = useMemo(
@@ -102,6 +112,33 @@ export function NetworkPeersCard({
[],
)
const columns = useMemo<ColumnDef<PeerRow, unknown>[]>(
() => [
...peerColumns,
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="icon-sm"
type="button"
className="text-destructive"
aria-label="Удалить пира"
title="Удалить"
onClick={() => setDeleteTarget(row.original)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
),
},
],
[],
)
return (
<>
<ResourcePage
@@ -114,7 +151,7 @@ export function NetworkPeersCard({
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultPeerFilters())}
getFilterFieldValue={getPeerFilterFieldValue}
columns={peerColumns}
columns={columns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
@@ -129,7 +166,34 @@ export function NetworkPeersCard({
}}
/>
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
<PeerFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
speakers={speakers}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
title="Удалить пира?"
description={
deleteTarget
? `${peerDeleteLabel(deleteTarget)} будет удалён. Запустится reconcile пиров и apply на спикеры.`
: undefined
}
confirmLabel="Удалить"
confirmLoadingLabel="Удаление…"
destructive
confirmLoading={deleteMutation.isPending}
onConfirm={() => {
if (!deleteTarget) return
void deleteMutation
.mutateAsync(deleteTarget.id)
.then(() => setDeleteTarget(null))
}}
/>
</>
)
}
@@ -31,14 +31,13 @@ function speakerDeleteLabel(s: SpeakerRow): string {
const SPEAKER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'online', label: 'Online' },
{ id: 'offline', label: 'Offline' },
{ id: 'online', label: 'В сети' },
{ id: 'offline', label: 'Не в сети' },
]
const ROLE_OPTIONS = [
{ value: 'primary', label: 'primary' },
{ value: 'secondary', label: 'secondary' },
{ value: 'speaker', label: 'speaker' },
{ value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер' },
]
function createDefaultSpeakerFilters(): Filter[] {
@@ -52,7 +51,7 @@ const speakerFilterFields: FilterFieldConfig[] = [
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'endpoint…',
placeholder: 'Адрес агента…',
},
{
key: 'role',
@@ -128,7 +127,7 @@ export function NetworkSpeakersCard({
<>
<ResourcePage
title="Спикеры"
description="BIRD-агенты на нодах tenant"
description="BIRD-агенты на нодах арендатора"
tabs={SPEAKER_TABS}
tabFilter={speakerTabFilter}
filterFields={speakerFilterFields}
@@ -5,7 +5,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { speakerOnlineLabel } from '@/lib/ui-labels'
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api'
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
@@ -20,7 +20,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
cell: ({ row }) => <CategoryBadge>{speakerRoleRu(row.original.role)}</CategoryBadge>,
meta: { headerTitle: 'Роль' },
},
{
@@ -22,7 +22,7 @@ interface PeerFormDialogProps {
function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)'
return host ? `Плоскость · ${host}` : 'Плоскость управления'
}
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
}
@@ -72,7 +72,7 @@ export function PeerFormDialog({
}
const asn = Number(remoteAsn)
if (!asn || asn <= 0) {
toast.error('Remote ASN должен быть больше 0')
toast.error('ASN соседа должен быть больше 0')
return
}
const body: BgpPeerCreate = {
@@ -132,7 +132,7 @@ export function PeerFormDialog({
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-asn">Remote ASN</Label>
<Label htmlFor="peer-asn">ASN соседа</Label>
<Input
id="peer-asn"
type="number"
@@ -1,15 +1,19 @@
import { useEffect, useState } from 'react'
import { Copy, TriangleAlert } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Textarea } from '@evobgp/ui/components/textarea'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useCreateSpeakerMutation } from '@/queries/network'
import type { BgpSpeakerCreate } from '@/types/api'
import type { BgpSpeakerCreate, SpeakerRow } from '@/types/api'
interface SpeakerFormDialogProps {
open: boolean
@@ -35,8 +39,20 @@ function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string)
return JSON.stringify(meta)
}
function tlsIncomplete(
agentDomain: string,
letsencryptEmail: string,
cfToken: string,
panelIP: string,
): boolean {
return !agentDomain.trim() || !letsencryptEmail.trim() || !cfToken.trim() || !panelIP.trim()
}
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
const createMutation = useCreateSpeakerMutation()
const { isCopied, copyToClipboard } = useCopyToClipboard({
onCopy: () => toast.success('Команда скопирована'),
})
const [endpoint, setEndpoint] = useState('')
const [role, setRole] = useState('replica')
@@ -44,6 +60,13 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
const [nodeIpv4, setNodeIpv4] = useState('')
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
const [bgpSourceManual, setBgpSourceManual] = useState(false)
const [letsencryptEmail, setLetsencryptEmail] = useState('')
const [cfDnsToken, setCfDnsToken] = useState('')
const [panelIP, setPanelIP] = useState('')
const [created, setCreated] = useState<SpeakerRow | null>(null)
const isReplica = role === 'replica'
const installCommands = created?.install?.docker_commands ?? ''
useEffect(() => {
if (!open) return
@@ -53,6 +76,10 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
setNodeIpv4('')
setBgpSourceIpv4('')
setBgpSourceManual(false)
setLetsencryptEmail('')
setCfDnsToken('')
setPanelIP('')
setCreated(null)
}, [open])
function handleEndpointChange(value: string) {
@@ -74,89 +101,218 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
const ep =
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
if (!ep) {
toast.error('Укажите endpoint или agent domain')
toast.error('Укажите конечную точку или домен агента')
return
}
const body: BgpSpeakerCreate = {
endpoint: ep,
role: role.trim() || 'replica',
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
control_plane_url: window.location.origin,
}
if (isReplica) {
if (letsencryptEmail.trim()) body.letsencrypt_email = letsencryptEmail.trim()
if (cfDnsToken.trim()) body.cf_dns_api_token = cfDnsToken.trim()
if (panelIP.trim()) body.panel_ip_whitelist = panelIP.trim()
}
try {
await createMutation.mutateAsync(body)
const row = await createMutation.mutateAsync(body)
if (row.install?.docker_commands) {
setCreated(row)
return
}
onOpenChange(false)
} catch {
// toast in mutation
}
}
const showingInstall = created !== null && Boolean(installCommands)
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новый спикер"
description="BIRD-агент на ноде реплики или control plane"
className="sm:max-w-md"
title={showingInstall ? 'Установка на ноду' : 'Новый спикер'}
description={
showingInstall
? 'Секреты показываются один раз. Скопируйте команду на VPS реплики.'
: 'BIRD-агент на ноде реплики или плоскости управления'
}
className={showingInstall ? 'sm:max-w-2xl' : 'sm:max-w-md'}
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
Создать
</LoadingButton>
</>
showingInstall ? (
<>
<Button
variant="outline"
type="button"
onClick={() => copyToClipboard(installCommands)}
>
<Copy />
{isCopied ? 'Скопировано' : 'Копировать команду'}
</Button>
<Button type="button" onClick={() => onOpenChange(false)}>
Готово
</Button>
</>
) : (
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
Создать
</LoadingButton>
</>
)
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
<Input
id="speaker-endpoint"
placeholder="https://node.example.com:8443"
value={endpoint}
onChange={(e) => handleEndpointChange(e.target.value)}
/>
</div>
<SelectField
id="speaker-role"
label="Роль"
items={[
{ value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер (CP)' },
]}
value={role}
onValueChange={(v) => setRole(v ?? 'replica')}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
<Input
id="speaker-agent-domain"
placeholder="bird-agent.example.com"
value={agentDomain}
onChange={(e) => setAgentDomain(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
<Input
id="speaker-node-ipv4"
placeholder="203.0.113.10"
value={nodeIpv4}
onChange={(e) => handleNodeIpv4Change(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
<Input
id="speaker-bgp-source"
placeholder="203.0.113.10"
value={bgpSourceIpv4}
onChange={(e) => {
setBgpSourceManual(true)
setBgpSourceIpv4(e.target.value)
}}
/>
</div>
{created && installCommands ? (
<SpeakerInstallStep created={created} commands={installCommands} />
) : (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
<Input
id="speaker-endpoint"
placeholder="https://node.example.com"
value={endpoint}
onChange={(e) => handleEndpointChange(e.target.value)}
/>
</div>
<SelectField
id="speaker-role"
label="Роль"
items={[
{ value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер (плоскость)' },
]}
value={role}
onValueChange={(v) => setRole(v ?? 'replica')}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
<Input
id="speaker-agent-domain"
placeholder="bird-agent.example.com"
value={agentDomain}
onChange={(e) => setAgentDomain(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
<Input
id="speaker-node-ipv4"
placeholder="203.0.113.10"
value={nodeIpv4}
onChange={(e) => handleNodeIpv4Change(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
<Input
id="speaker-bgp-source"
placeholder="203.0.113.10"
value={bgpSourceIpv4}
onChange={(e) => {
setBgpSourceManual(true)
setBgpSourceIpv4(e.target.value)
}}
/>
</div>
{isReplica ? (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-le-email">Email Let's Encrypt</Label>
<Input
id="speaker-le-email"
type="email"
placeholder="[email protected]"
value={letsencryptEmail}
onChange={(e) => setLetsencryptEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-cf-token">Cloudflare DNS API token</Label>
<Input
id="speaker-cf-token"
type="password"
autoComplete="off"
placeholder="Zone:DNS:Edit"
value={cfDnsToken}
onChange={(e) => setCfDnsToken(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-panel-ip">IP панели (whitelist)</Label>
<Input
id="speaker-panel-ip"
placeholder="203.0.113.1/32"
value={panelIP}
onChange={(e) => setPanelIP(e.target.value)}
/>
</div>
{tlsIncomplete(agentDomain, letsencryptEmail, cfDnsToken, panelIP) ? (
<Alert variant="warning">
<TriangleAlert />
<AlertTitle>Traefik не выпустит сертификат</AlertTitle>
<AlertDescription>
Нужны домен агента, email LE, Cloudflare token и IP панели. Иначе в
команде останутся плейсхолдеры CHANGE_ME_*.
</AlertDescription>
</Alert>
) : null}
</>
) : null}
</>
)}
</FormDrawer>
)
}
function SpeakerInstallStep({
created,
commands,
}: {
created: SpeakerRow
commands: string
}) {
return (
<>
<Alert variant="warning">
<TriangleAlert />
<AlertTitle>Сохраните сейчас</AlertTitle>
<AlertDescription>
agent_secret и node_token больше не будут показаны. Traefik на ноде выпускает
сертификат через DNS-01 (Cloudflare). MikroTik стучится на IP ноды:179; 80/443
только агент панели. Логи: docker compose logs -f bird2 evobgp-agent.
</AlertDescription>
</Alert>
<div className="flex flex-col gap-1">
<Label>ID спикера</Label>
<code className="break-all font-mono text-xs">{created.id}</code>
</div>
{created.agent_secret ? (
<div className="flex flex-col gap-1">
<Label>agent_secret</Label>
<code className="break-all font-mono text-xs">{created.agent_secret}</code>
</div>
) : null}
{created.node_token ? (
<div className="flex flex-col gap-1">
<Label>node_token</Label>
<code className="break-all font-mono text-xs">{created.node_token}</code>
</div>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-docker-commands">Docker-команды</Label>
<Textarea
id="speaker-docker-commands"
readOnly
value={commands}
className="min-h-64 font-mono text-xs"
/>
</div>
</>
)
}
@@ -43,7 +43,6 @@ export function OperationsJobsGrid({
cell: ({ row }) => (
<DataGridPrimaryCell
title={jobKindRu(row.original.kind)}
accent="mono"
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ??
@@ -77,7 +77,7 @@ export function OperationsRevisionsGrid({
</Button>
}
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
confirmLabel="Откатить"
destructive
onConfirm={() => rollbackMutation.mutate(row.original.id)}
+8 -4
View File
@@ -8,12 +8,16 @@ interface PageHeaderProps {
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-1">
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
{description ? (
<p className="text-muted-foreground max-w-prose text-sm break-words">{description}</p>
) : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</div>
)
}
+1 -1
View File
@@ -54,7 +54,7 @@ export function PanelCard({
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, className)}
className={cn(panelCardClassName, 'min-w-0', className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
@@ -8,7 +8,7 @@ import {
} from '@evobgp/ui/components/chart'
import type { BreakdownSlice } from '@/lib/metrics'
/** chart-27 / chart-13 inspired donut in PanelCard. */
/** chart-27 / chart-13 inspired donut in PanelCard (Frame surface). */
export function DonutBreakdownCard({
title,
description,
@@ -30,13 +30,24 @@ export function DonutBreakdownCard({
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
return (
<PanelCard title={title} description={description} className="h-full">
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center">
<PanelCard
title={title}
description={description}
className="h-full"
actions={
badge ? (
<Badge variant="success-light" className="hidden sm:inline-flex">
{badge}
</Badge>
) : undefined
}
>
<div className="flex flex-col items-center justify-start gap-4 p-4 sm:flex-row sm:items-center sm:justify-start sm:gap-6">
{total === 0 ? (
<p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p>
) : (
<>
<div className="relative mx-auto size-36 shrink-0">
<div className="relative mx-auto size-36 shrink-0 sm:mx-0">
<ChartContainer config={chartConfig} className="aspect-square size-36">
<PieChart>
<Pie
@@ -59,11 +70,11 @@ export function DonutBreakdownCard({
<span className="text-lg font-semibold tabular-nums">{total}</span>
</div>
</div>
<ul className="min-w-0 flex-1 space-y-2">
<ul className="flex w-full min-w-0 max-w-xs flex-col gap-2 sm:w-auto sm:min-w-[10rem]">
{slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key} className="flex items-center justify-between gap-2 text-sm">
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
<span className="flex min-w-0 items-center gap-2">
<span
className="size-2.5 shrink-0 rounded-full"
@@ -81,11 +92,6 @@ export function DonutBreakdownCard({
</ul>
</>
)}
{badge ? (
<Badge variant="success-light" className="absolute top-4 right-4 hidden sm:flex">
{badge}
</Badge>
) : null}
</div>
</PanelCard>
)
@@ -2,7 +2,10 @@ import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack'
import { Card, CardContent } from '@evobgp/ui/components/card'
import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import {
Empty,
EmptyDescription,
@@ -11,7 +14,7 @@ import {
EmptyTitle,
} from '@evobgp/ui/components/empty'
/** empty-state-11 pattern adapted to Card surface. */
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
export function IllustratedEmptyState({
title,
description,
@@ -24,8 +27,8 @@ export function IllustratedEmptyState({
action?: ReactNode
}) {
return (
<Card className="bg-muted min-h-[240px] p-0 shadow-none">
<CardContent className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
<Frame dense spacing="sm" className="w-full">
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
{action ? <div className="w-full self-end">{action}</div> : null}
<Empty className="max-w-md gap-5 bg-transparent p-0">
<EmptyHeader className="items-center gap-5 text-center">
@@ -40,7 +43,7 @@ export function IllustratedEmptyState({
</div>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
</FramePanel>
</Frame>
)
}
@@ -4,7 +4,7 @@ import { Badge } from '@/components/reui/badge'
import { CardDotField } from '@/components/dashboard/card-dot-field'
import { PanelCorners } from '@/components/patterns/panel-corners'
import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { Frame, FramePanel } from '@/components/reui/frame'
export type KpiSparklineMetric = {
id: string
@@ -44,21 +44,21 @@ function Sparkline({ values, color }: { values: readonly number[]; color: string
)
}
/** KPI card with sparkline (dashboard-5 / chart-15 pattern, Card surface). */
/** KPI sparkline tile — chart-15 Frame DNA. Preview: https://reui.io/preview/base/chart-15 */
export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
const tone = toneStyles[metric.tone]
return (
<Card className="relative overflow-hidden p-0">
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
<PanelCorners />
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
<div className="flex min-w-0 flex-col gap-0.5">
<Frame dense spacing="sm" className="relative overflow-hidden">
<FramePanel className="relative isolate flex min-h-[7.25rem] flex-col justify-between gap-5 overflow-hidden p-4">
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
<PanelCorners />
<div className="relative z-10 flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate text-sm leading-4 font-semibold">{metric.title}</span>
<span className="text-muted-foreground truncate text-xs leading-4">{metric.label}</span>
</div>
<div className="flex items-end justify-between gap-4">
<div className="min-w-0 space-y-2.5">
<div className="relative z-10 flex items-end justify-between gap-4">
<div className="flex min-w-0 flex-col gap-2.5">
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight tabular-nums">
{metric.value}
</div>
@@ -71,7 +71,7 @@ export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
</div>
<Sparkline values={metric.sparkline} color={tone.stroke} />
</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
)
}
@@ -1,21 +1,28 @@
import { Link } from '@tanstack/react-router'
import { Boxes, Plus } from 'lucide-react'
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
import { Button } from '@evobgp/ui/components/button'
/** empty-state-3 pattern for first module. */
export function ProjectsEmptyState() {
export function ProjectsEmptyState({
canCreate = true,
onCreate,
}: {
canCreate?: boolean
onCreate?: () => void
}) {
return (
<IllustratedEmptyState
icon={Boxes}
title="Создайте первый модуль"
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
action={
<Button size="sm" render={<Link to="/modules/new" />}>
<Plus />
Новый модуль
</Button>
canCreate && onCreate ? (
<Button size="sm" onClick={onCreate}>
<Plus />
Новый модуль
</Button>
) : undefined
}
/>
)
+20 -14
View File
@@ -34,23 +34,29 @@ export function QueryState<T>({
children,
}: QueryStateProps<T>) {
if (isLoading) {
return <>{skeleton ?? <DefaultSkeleton />}</>
return (
<div role="status" aria-live="polite" aria-busy="true">
{skeleton ?? <DefaultSkeleton />}
</div>
)
}
if (isError) {
return (
<EmptyState
icon={<AlertCircle className="size-8" />}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
<div role="alert">
<EmptyState
icon={AlertCircle}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
</div>
)
}
if (empty || data == null) {
@@ -29,10 +29,10 @@ export function FrameDataGrid({
}) {
const hasHeader = Boolean(title || description || actions)
return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3 border-b">
<FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
+1 -1
View File
@@ -24,4 +24,4 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { FrameDataGrid } from './frame-data-grid'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export { SettingsShell } from './settings-shell'
+7 -4
View File
@@ -1,10 +1,13 @@
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
/**
* Shared grid column classes for hybrid KPI / Quick Actions tiles.
* Container queries only — never 56 columns on laptop width (tiles overlap).
* @see https://reui.io/preview/base/stats-12
*/
export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
if (count <= 6) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4'
}
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { IconTile } from '@/components/reui/icon-tile'
import { Skeleton } from '@evobgp/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{item.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
item.iconClassName ?? DEFAULT_ICON_CLASS,
)}
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
<ItemMedia variant="icon" className="size-auto">
{item.icon}
</ItemMedia>
</Item>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
@@ -308,7 +305,7 @@ export function KpiStatGrid({
}
return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
<Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
@@ -1,11 +1,4 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
@@ -27,7 +20,7 @@ interface OpsDashboardProps {
}
const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
'text-foreground @container flex w-full min-w-0 flex-col gap-4 md:gap-6'
function OpsDashboardSkeleton() {
return (
@@ -37,7 +30,7 @@ function OpsDashboardSkeleton() {
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
</header>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="grid gap-2 @3xl:grid-cols-2">
<div className="flex min-w-0 flex-col gap-4">
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
@@ -47,9 +40,11 @@ function OpsDashboardSkeleton() {
}
/**
* Ops dashboard: KPI → optional Quick Actions → charts → attention queue.
* Ops dashboard: KPI → optional Quick Actions → charts → queue.
* Queue is a sibling Frame grid — never wrap FrameDataGrid inside another Frame.
* @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/docs/components/base/frame
*/
export function OpsDashboard({
title = 'Панель управления',
@@ -77,21 +72,18 @@ export function OpsDashboard({
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
<section
aria-label="Аналитика"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
{charts}
</section>
<section aria-label={queueTitle}>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>{queueTitle}</FrameTitle>
<FrameDescription>{queueDescription}</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
</Frame>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
{queueDescription ? (
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
) : null}
</div>
{queue}
</section>
</div>
)
@@ -8,7 +8,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@evobgp/ui/lib/utils'
import { kpiCols } from './kpi-cols'
@@ -54,16 +54,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
action.iconClassName ?? DEFAULT_ICON_CLASS,
)}
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
>
<ItemMedia variant="icon" className="size-auto">
{action.icon}
</ItemMedia>
</Item>
{action.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
@@ -39,6 +39,8 @@ import {
AlertTitle,
} from '@/components/reui/alert'
import { EmptyState } from '@/components/empty-state'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { FILTERS_I18N_RU } from '@/lib/filters-i18n'
import { applyFiltersToData } from './filter-utils'
export interface ResourcePageTab {
@@ -322,6 +324,7 @@ export function ResourcePage<T extends object>({
fields={filterFields}
onChange={handleFiltersChange}
size="default"
i18n={FILTERS_I18N_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
@@ -355,11 +358,8 @@ export function ResourcePage<T extends object>({
<FrameFooter>
<DataGridPagination
{...DATA_GRID_PAGINATION_RU}
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</FramePanel>

Some files were not shown because too many files have changed in this diff Show More