Compare commits

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 15:36:56 +07:00
Denozordec d2de8d3188 fix(ui): улучшить функциональность панели данных и заменить устаревшие компоненты
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 2m7s
Docker images / updater-image (push) Successful in 41s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-06-30 22:36:00 +07:00
215 changed files with 23649 additions and 4350 deletions
+67
View File
@@ -0,0 +1,67 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 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 `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 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
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 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)
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
+43
View File
@@ -0,0 +1,43 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
+60
View File
@@ -0,0 +1,60 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 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" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the 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).
+408
View File
@@ -0,0 +1,408 @@
# ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
+45
View File
@@ -0,0 +1,45 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
+39
View File
@@ -0,0 +1,39 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
+22
View File
@@ -0,0 +1,22 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
+35
View File
@@ -0,0 +1,35 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 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).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **Free, no key:** the 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
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+26
View File
@@ -0,0 +1,26 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
+54
View File
@@ -0,0 +1,54 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 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`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+58
View File
@@ -0,0 +1,58 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp
+1 -1
View File
@@ -11,7 +11,7 @@ const outputDir = process.env.RELEASE_OUTPUT_DIR
? join(repoRoot, process.env.RELEASE_OUTPUT_DIR)
: join(repoRoot, ".ci", "release")
const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shts.su").replace(/\/$/, "")
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shx.one").replace(/\/$/, "")
const MANIFEST_COMMIT_LIMIT = Number(process.env.RELEASE_MANIFEST_COMMIT_LIMIT ?? 6)
const CONVENTIONAL_RE =
+6 -3
View File
@@ -12,9 +12,9 @@
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
"WebFetch(domain:git.shts.su)",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"WebFetch(domain:git.shx.one)",
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"Bash(node -e ' *)",
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
@@ -63,5 +63,8 @@
"Bash(xargs grep *)",
"Bash(npx shadcn@latest add popover --yes)"
]
},
"env": {
"REUI_LICENSE_KEY": "REUI-EAF4-EA40-6C5C-5D86"
}
}
+67
View File
@@ -0,0 +1,67 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 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 `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 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
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 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)
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
+43
View File
@@ -0,0 +1,43 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
+60
View File
@@ -0,0 +1,60 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 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" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the 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).
+408
View File
@@ -0,0 +1,408 @@
# ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
+45
View File
@@ -0,0 +1,45 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
+39
View File
@@ -0,0 +1,39 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
+22
View File
@@ -0,0 +1,22 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
+35
View File
@@ -0,0 +1,35 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 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).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **Free, no key:** the 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
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+26
View File
@@ -0,0 +1,26 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
+54
View File
@@ -0,0 +1,54 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 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`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+58
View File
@@ -0,0 +1,58 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp
+1 -1
View File
@@ -18,7 +18,7 @@ alwaysApply: false
## Эталон UI
- **`app/(main)/servers`** — главный эталон layout, сетки, типографики, композиции и взаимодействий. Не вводить новый UI-паттерн, если на `/servers` уже есть эквивалент.
- **`app/(main)/servers`** — эталон **поведения** CRUD. Оболочка ops: **Frame + DataGrid**, не Card-shell. Не копировать `DataPageCard` на новые экраны.
## Качество и совместимость
+15 -15
View File
@@ -1,11 +1,11 @@
---
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI только с эталоном /servers"
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI с CRUD /servers и оболочкой Frame+DataGrid"
alwaysApply: false
---
# Агент: UI GUARDIAN (хранитель UI)
Ты отвечаешь **только** за **визуальную и UX-согласованность** с эталоном **`/servers`**.
Ты отвечаешь **только** за **визуальную и UX-согласованность**.
## Язык
@@ -13,31 +13,31 @@ alwaysApply: false
## Эталон
- Каталог **`app/(main)/servers`** и связанные компоненты страницы — **единственный** эталон для:
- структуры layout (области страницы, карточки, секции);
- сетки и отступов;
- типографики (заголовки, подписи, плотность текста);
- композиции shadcn-компонентов;
- паттернов взаимодействия (кнопки, таблицы, диалоги, формы — как на `/servers`).
Два слоя (не смешивать):
1. **Поведение CRUD** — каталог **`app/(main)/servers`**: фильтры, таблица, диалоги, формы, плотность, паттерны кнопок.
2. **Оболочка ops** — **ReUI Frame + DataGrid**, не shadcn `Card` как page shell. KPI — `IconTile` `variant="elevated"` `className="size-10.5"`. Preview: [frame](https://reui.io/docs/components/base/frame) · [data-grid](https://reui.io/docs/components/base/data-grid) · [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile).
`DataPageCard` — ops list shell на ReUI Frame; новые экраны не копируют Card-shell.
## Обязанности
- Сравнить **каждое** спорное UI-решение в изменениях с тем, как сделано на `/servers`.
- Требовать **переиспользование** существующих компонентов/обёрток из этой зоны, если они покрывают задачу.
- Сравнить **каждое** спорное UI-решение: CRUD — с `/servers`; оболочка — с Frame+DataGrid.
- Требовать **переиспользование** существующих компонентов/обёрток, если они покрывают задачу.
## Правила
- **Нельзя** принимать UI, который **системно расходится** с `/servers` без явной технической необходимости (которую нужно назвать).
- **Нельзя** принимать новый Card page-shell без явной технической необходимости (назвать её).
- **Нельзя** предлагать «новый стиль» ради вариации — только выравнивание с эталоном или расширение существующих примитивов.
## Формат вывода (строго)
1. **Несоответствия** эталону (конкретно: что именно отличается).
1. **Несоответствия** эталону (конкретно: CRUD vs Frame-shell).
2. **Обязательные правки** (что поменять).
3. **Что переиспользовать** с `/servers`: файлы/компоненты и паттерн.
3. **Что переиспользовать**: файлы/компоненты и паттерн.
Если расхождений нет — явно: **«Расхождений с эталоном /servers не выявлено»** и кратко перечисли проверенные аспекты.
Если расхождений нет — явно: **«Расхождений с эталоном CRUD /servers и Frame+DataGrid не выявлено»** и кратко перечисли проверенные аспекты.
## Граница ответственности
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности с /servers** и **reuse** UI.
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности** и **reuse** UI.
+8 -7
View File
@@ -16,11 +16,12 @@ alwaysApply: true
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
- Не выдумывать API и «недокументированные» паттерны; предпочитать стабильные, описанные в доках решения.
## 2. Эталон UI/UX: страница `/servers` (критично)
## 2. Эталон UI/UX: `/servers` (CRUD) + Frame+DataGrid (оболочка)
- **`app/(main)/servers`** (и связанные компоненты) — **главный эталон** дизайна и поведения.
- Выравнивать: layout, отступы, сетку, типографику, структуру компонентов, паттерны взаимодействия.
- Переиспользовать оттуда же компоненты и паттерны; **не вводить новый UI-паттерн**, если эквивалент уже есть на `/servers`.
- **`app/(main)/servers`** — эталон **поведения** CRUD: фильтры, сортировка, пагинация, диалоги, формы, плотность.
- **Оболочка ops-списков / dashboard / KPI:** **ReUI Frame + DataGrid**, не shadcn `Card` как page shell. Preview: [frame](https://reui.io/docs/components/base/frame) · [data-grid](https://reui.io/docs/components/base/data-grid) · [stats-12](https://reui.io/preview/base/stats-12) · [icon-tile](https://reui.io/docs/components/base/icon-tile).
- `DataPageCard` — ops list shell на ReUI Frame (`components/data-page-card.tsx`). Внутренние панели — `OpsPanel` / Frame, не shadcn Card как page shell.
- Переиспользовать паттерны `/servers` для CRUD; **не** вводить Card-grid как новый SoT.
## 3. Большой репозиторий (критично)
@@ -50,7 +51,7 @@ alwaysApply: true
В ответе явно указать:
- **Почему** это согласуется с Next.js, shadcn/ui и эталоном **`/servers`**.
- **Почему** это согласуется с Next.js, shadcn/ui, CRUD `/servers` и оболочкой Frame+DataGrid.
- **Server vs Client Component** и обоснование.
- Что **переиспользовано** из проекта (паттерны/компоненты).
- Какие **файлы проанализированы**.
@@ -68,7 +69,7 @@ alwaysApply: true
1. **Шаг 0:** список проанализированных файлов.
2. **Шаг 1:** что изменено.
3. **Шаг 2:** почему (Next.js + shadcn/ui + `/servers` + паттерны кодовой базы).
3. **Шаг 2:** почему (Next.js + shadcn/ui + CRUD `/servers` + Frame+DataGrid + паттерны кодовой базы).
4. **Шаг 3:** анализ влияния (что ещё затронуто).
5. **Шаг 4:** код (диффы или несколько файлов).
6. **Шаг 5:** проверка dev/консоли (ошибки, предупреждения, исправления).
@@ -80,4 +81,4 @@ alwaysApply: true
## Цель
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, выравнивание с `/servers`, безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, CRUD как на `/servers`, оболочка Frame+DataGrid (не Card-shell), безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
+73
View File
@@ -0,0 +1,73 @@
---
description: Use the ReUI registry (blocks, primitives, icons) correctly
globs: ["**/*.tsx","**/*.ts"]
alwaysApply: false
---
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 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 `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 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
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 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)
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
+67
View File
@@ -0,0 +1,67 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 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 `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 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
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 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)
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
+43
View File
@@ -0,0 +1,43 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
+60
View File
@@ -0,0 +1,60 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 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" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the 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).
+408
View File
@@ -0,0 +1,408 @@
# ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
+45
View File
@@ -0,0 +1,45 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
+39
View File
@@ -0,0 +1,39 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
+22
View File
@@ -0,0 +1,22 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
+35
View File
@@ -0,0 +1,35 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 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).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **Free, no key:** the 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
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+26
View File
@@ -0,0 +1,26 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
+54
View File
@@ -0,0 +1,54 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 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`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+58
View File
@@ -0,0 +1,58 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp
+17 -6
View File
@@ -7,8 +7,8 @@ on:
workflow_dispatch:
env:
REGISTRY: git.shts.su
GITEA_SERVER_URL: https://git.shts.su
REGISTRY: git.shx.one
GITEA_SERVER_URL: https://git.shx.one
jobs:
prepare-release:
@@ -61,7 +61,16 @@ jobs:
STAGING=".ci/docker/backend"
rm -rf "$STAGING"
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
cp package.json package-lock.json "$STAGING/"
cp package-lock.json "$STAGING/"
node <<'NODE'
const fs = require("node:fs")
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
pkg.workspaces = ["packages/*", "backend"]
pkg.dependencies = {}
pkg.devDependencies = {}
delete pkg.scripts
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
NODE
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
cp -R packages/contracts/src "$STAGING/packages/contracts/"
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
@@ -70,13 +79,14 @@ jobs:
cp -R backend/drizzle "$STAGING/backend/"
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
@@ -161,7 +171,7 @@ jobs:
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
@@ -177,6 +187,7 @@ jobs:
NEXT_PUBLIC_BACKEND_URL=same-origin
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
tags: |
@@ -214,7 +225,7 @@ jobs:
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
+6
View File
@@ -49,3 +49,9 @@ Thumbs.db
tmp/
temp/
.ci/docker/
.claude/settings.local.json
# Local MCP configs (may contain REUI license Bearer)
.cursor/mcp.json
.mcp.json
opencode.json
+67
View File
@@ -0,0 +1,67 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 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 `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 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
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 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)
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
+43
View File
@@ -0,0 +1,43 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
+60
View File
@@ -0,0 +1,60 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 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" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the 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).
+408
View File
@@ -0,0 +1,408 @@
# ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
+45
View File
@@ -0,0 +1,45 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
+39
View File
@@ -0,0 +1,39 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
+22
View File
@@ -0,0 +1,22 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
+35
View File
@@ -0,0 +1,35 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 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).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **Free, no key:** the 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
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+26
View File
@@ -0,0 +1,26 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
+54
View File
@@ -0,0 +1,54 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 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`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+58
View File
@@ -0,0 +1,58 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp
+6
View File
@@ -14,3 +14,9 @@
## Локальный запуск
Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
## ReUI
Skill: `.claude/skills/reui` / `.cursor/skills/reui` — версия `668fb463eb` (20 free components).
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) · [Cursor](https://reui.io/docs/cursor).
Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
+2
View File
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
ARG NEXT_PUBLIC_APP_VERSION=dev
ARG NEXT_PUBLIC_RELEASE_URL=
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL
COPY packages/contracts packages/contracts
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
COPY app app
+82 -26
View File
@@ -23,13 +23,13 @@
| Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend |
| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
| CI | `.gitea/workflows/docker.yml` | Gitea Actions, Buildx | — | push в `git.shts.su` |
| CI | `.gitea/workflows/docker.yml` | Gitea Actions, Buildx | — | push в `git.shx.one` |
Имена образов в registry для репозитория `denozord/MikrotikManager-3`:
- `git.shts.su/denozord/mikrotikmanager-backend`
- `git.shts.su/denozord/mikrotikmanager-frontend`
- `git.shts.su/denozord/mikrotikmanager-updater`
- `git.shx.one/denozord/mikrotikmanager-backend`
- `git.shx.one/denozord/mikrotikmanager-frontend`
- `git.shx.one/denozord/mikrotikmanager-updater`
Для другого owner/repo подставьте имя по правилу CI (см. [CI/CD](#cicd-gitea-actions)).
@@ -54,7 +54,7 @@ flowchart TB
subgraph ci [Gitea Actions]
WF[".gitea/workflows/docker.yml"]
WF --> Reg["git.shts.su registry"]
WF --> Reg["git.shx.one registry"]
end
subgraph prod [Прод-сервер]
@@ -135,6 +135,7 @@ sequenceDiagram
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
### Запуск
@@ -192,9 +193,9 @@ npm --prefix backend run db:studio
| Job | Build context | Dockerfile | Имя образа |
|-----|---------------|------------|------------|
| `backend-image` | `.ci/docker/backend` (staging в CI) | `backend/Dockerfile` | `git.shts.su/<owner>/<stem>-backend` |
| `frontend-image` | `.ci/docker/frontend` | `Dockerfile.frontend` | `git.shts.su/<owner>/<stem>-frontend` |
| `updater-image` | `deploy/updater` | `deploy/updater/Dockerfile` | `git.shts.su/<owner>/<stem>-updater` |
| `backend-image` | `.ci/docker/backend` (staging в CI) | `backend/Dockerfile` | `git.shx.one/<owner>/<stem>-backend` |
| `frontend-image` | `.ci/docker/frontend` | `Dockerfile.frontend` | `git.shx.one/<owner>/<stem>-frontend` |
| `updater-image` | `deploy/updater` | `deploy/updater/Dockerfile` | `git.shx.one/<owner>/<stem>-updater` |
- `<owner>` — первая часть `gitea.repository`, lower case.
- `<stem>` — имя репозитория lower case без суффикса `-<цифры>` в конце (например `MikrotikManager-3``mikrotikmanager`).
@@ -212,22 +213,77 @@ npm --prefix backend run db:studio
- **`feat` / `feat!` / `BREAKING CHANGE`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
- Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`.
- Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`).
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shts.su` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта).
- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump.
- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc); subject и body — **на русском**, префикс Conventional Commits — на английском.
## Прод-развёртывание Docker
Эталон: `deploy/docker-compose.yml`. Рабочий каталог для команд compose — `deploy/` (или укажите `-f deploy/docker-compose.yml` из корня репозитория).
Эталон без reverse-proxy: `deploy/docker-compose.yml` (порты `3000` / `8000` на хост).
Стек с Traefik + HTTPS (Let's Encrypt DNS-01 / Cloudflare), по аналогии с CDNManager: [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) + [`deploy/env.traefik.example`](deploy/env.traefik.example). На сервере публикуются только `:80`/`:443`; frontend получает HTTPS, `/api` и `/health` проксируются на backend внутри сети `mmapp`. Домен по умолчанию: `mm.shnt.top`.
### CDN Manager + MikrotikManager (один Traefik)
Полный стек: Traefik + `cdn.shnt.top` + `mm.shnt.top` в одном Compose.
| Файл | Назначение |
|------|------------|
| [`deploy/docker-compose.cdn-mm.yml`](deploy/docker-compose.cdn-mm.yml) | Traefik + CDN Manager + MM backend/frontend/updater |
| [`deploy/env.cdn-mm.example`](deploy/env.cdn-mm.example) | общий `.env` |
```bash
mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
cp deploy/env.cdn-mm.example /opt/cdn-mm/.env
cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
# заполнить CF_DNS_API_TOKEN, CLOUDFLARE_API_TOKEN, AUTH_JWT_SECRET, CORS_ORIGIN, …
docker login git.shx.one
cd /opt/cdn-mm && docker compose pull && docker compose up -d
curl -fsS https://cdn.shnt.top/health
curl -fsS https://mm.shnt.top/health
```
Данные: `./data/cdn` (CDN), `./data/mm` (MM). Сеть Traefik: `edge`. Не запускайте параллельно standalone `docker-compose.traefik.yml` CDNManager или MM на тех же 80/443.
**Если на сервере уже крутится CDNManager Traefik** (`cdnmanager-traefik`, сеть `cdnmanager`) — **не** поднимайте второй Traefik. Варианты:
| Способ | Файл |
|--------|------|
| Compose без своего Traefik | [`deploy/docker-compose.traefik-cdn.yml`](deploy/docker-compose.traefik-cdn.yml) |
| Plain `docker` CLI (скрипт) | [`deploy/run-beside-cdn-traefik.sh`](deploy/run-beside-cdn-traefik.sh) |
Frontend вешается в сеть `cdnmanager` с Traefik-labels; backend/updater остаются в `mmapp`. Сертификат для `MM_DOMAIN` выпускает уже работающий Traefik CDNManager (тот же `letsencrypt` / Cloudflare DNS-01).
```bash
# Compose (рекомендуется)
mkdir -p /opt/mmapp/{data,state,updater}
cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
cp deploy/env.traefik.example /opt/mmapp/.env # MM_DOMAIN + CORS_ORIGIN
cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
cd /opt/mmapp && docker compose pull && docker compose up -d
# Или одной CLI-командой (скрипт сам сделает network/pull/run/connect):
curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
chmod +x /tmp/run-beside-cdn-traefik.sh
sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-traefik.sh
```
DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`.
SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`).
Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня).
### Прод-контейнеры
| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
|--------|------------------|----------------|----------------------|------|-----------|
| backend | `mmapp-backend` | `git.shts.su/denozord/mikrotikmanager-backend:latest` | `8000:8000` | `backend-data``/app/data` | `unless-stopped` |
| frontend | `mmapp-frontend` | `git.shts.su/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
| updater | `mmapp-updater` | `git.shts.su/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state``/state`, `targets.json``/etc/updater/targets.json:ro` | `unless-stopped` |
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | `backend-data``/app/data` | `unless-stopped` |
| frontend | `mmapp-frontend` | `git.shx.one/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
| updater | `mmapp-updater` | `git.shx.one/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state``/state`, `targets.json``/etc/updater/targets.json:ro` | `unless-stopped` |
Метки для updater на backend и frontend:
@@ -258,14 +314,14 @@ npm --prefix backend run db:studio
| `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса |
| `HEALTH_TIMEOUT_SECONDS` | `120` | ожидание HTTP health |
| `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` |
| `REGISTRY` | `git.shts.su` | registry для `docker login` |
| `REGISTRY` | `git.shx.one` | registry для `docker login` |
| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
| `REGISTRY_PASSWORD` | пусто | пароль registry |
### Docker Compose
```bash
docker login git.shts.su
docker login git.shx.one
export CORS_ORIGIN=http://<хост>:3000
export REGISTRY_USERNAME=<user>
export REGISTRY_PASSWORD=<token>
@@ -307,8 +363,8 @@ docker run -d \
-v backend-data:/app/data \
--label mmapp.updater.managed=true \
--label mmapp.updater.target=backend \
--label mmapp.updater.image=git.shts.su/denozord/mikrotikmanager-backend:latest \
git.shts.su/denozord/mikrotikmanager-backend:latest
--label mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-backend:latest \
git.shx.one/denozord/mikrotikmanager-backend:latest
```
**Frontend:**
@@ -320,8 +376,8 @@ docker run -d \
-p 3000:3000 \
--label mmapp.updater.managed=true \
--label mmapp.updater.target=frontend \
--label mmapp.updater.image=git.shts.su/denozord/mikrotikmanager-frontend:latest \
git.shts.su/denozord/mikrotikmanager-frontend:latest
--label mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:latest \
git.shx.one/denozord/mikrotikmanager-frontend:latest
```
**Updater:**
@@ -331,7 +387,7 @@ docker volume create updater-state
docker run -d \
--name mmapp-updater \
--restart unless-stopped \
-e REGISTRY=git.shts.su \
-e REGISTRY=git.shx.one \
-e REGISTRY_USERNAME=<user> \
-e REGISTRY_PASSWORD=<token> \
-e POLL_INTERVAL_SECONDS=300 \
@@ -340,7 +396,7 @@ docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-v updater-state:/state \
-v /path/to/targets.json:/etc/updater/targets.json:ro \
git.shts.su/denozord/mikrotikmanager-updater:latest
git.shx.one/denozord/mikrotikmanager-updater:latest
```
### Проверка после деплоя
@@ -399,7 +455,7 @@ bash deploy/updater/validate.sh
## Первичная настройка сервера
Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shts.su`.
Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shx.one`.
1. Установить Docker Engine и плагин Compose (официальная документация Docker для вашего дистрибутива).
2. Проверить:
@@ -413,7 +469,7 @@ docker compose version
4. Войти в registry:
```bash
docker login git.shts.su
docker login git.shx.one
```
5. Получить файлы деплоя: клонировать репозиторий или скопировать каталог `deploy/` и подготовить `deploy/updater/targets.json`.
@@ -443,8 +499,8 @@ Updater опрашивает registry с интервалом `POLL_INTERVAL_SEC
### Ручное обновление
```bash
docker pull git.shts.su/denozord/mikrotikmanager-backend:latest
docker pull git.shts.su/denozord/mikrotikmanager-frontend:latest
docker pull git.shx.one/denozord/mikrotikmanager-backend:latest
docker pull git.shx.one/denozord/mikrotikmanager-frontend:latest
```
Через compose (пересоздание при смене образа):
@@ -485,7 +541,7 @@ docker start mmapp-backend
1. Установить Docker Engine и Compose plugin; проверить `docker version` и `docker compose version`.
2. Открыть порты 3000 и 8000 (или обеспечить доступ клиентов к UI и API).
3. Выполнить `docker login git.shts.su`.
3. Выполнить `docker login git.shx.one`.
4. Склонировать репозиторий или скопировать `deploy/`.
5. Задать `CORS_ORIGIN` (origin фронтенда для браузера).
6. Задать `REGISTRY_USERNAME` и `REGISTRY_PASSWORD` для updater при приватном registry.
+71 -50
View File
@@ -4,11 +4,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useDataSource } from "@/lib/data-source"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { OpsPanel } from "@/components/ops-panel"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Separator } from "@/components/ui/separator"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
} from "@/components/ui/sheet"
@@ -841,12 +851,9 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
}
return (
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-sm flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="text-base"></span> Telegram
</span>
<OpsPanel
title={<span className="flex items-center gap-2"><span className="text-base"></span> Telegram</span>}
headerRight={
<span className={cn(
"flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
cfg.connected
@@ -856,9 +863,9 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
<span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
{cfg.connected ? "Подключён" : "Не настроен"}
</span>
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-3">
}
contentClassName="px-4 pb-4 flex flex-col gap-3"
>
{/* bot token */}
<div>
@@ -943,8 +950,7 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
: testResult === "fail" ? "Ошибка отправки"
: "Отправить тестовое сообщение"}
</Button>
</CardContent>
</Card>
</OpsPanel>
)
}
@@ -952,11 +958,7 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
return (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm">Журнал срабатываний</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<OpsPanel title="Журнал срабатываний" contentClassName="px-4 pb-4">
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
) : (
@@ -983,8 +985,7 @@ function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
))}
</div>
)}
</CardContent>
</Card>
</OpsPanel>
)
}
@@ -2363,6 +2364,7 @@ export default function AlertsPage() {
const [meta, setMeta] = useState<AlertsMeta | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [pendingPresetReplaceId, setPendingPresetReplaceId] = useState<string | null>(null)
const [sheetSnap, setSheetSnap] = useState<RuleFormSnapshot | null>(null)
const [sheetEditId, setSheetEditId] = useState<string | null>(null)
const [sheetKey, setSheetKey] = useState(0)
@@ -2661,9 +2663,6 @@ export default function AlertsPage() {
const applyPresetReplace = useCallback(
(presetId: string) => {
if (typeof window !== "undefined" && !window.confirm("Заменить все текущие правила выбранным пресетом?")) {
return
}
const def = ALERT_PRESETS.find((p) => p.id === presetId)
if (!def) return
const built = def.build(serverNamesForPresets)
@@ -2947,11 +2946,7 @@ export default function AlertsPage() {
<CollapsibleContent>
<div className="border-t border-border/60 px-4 pb-4 pt-3">
<div className="grid gap-5 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm">Пресеты</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 flex flex-col gap-3">
<OpsPanel title="Пресеты" contentClassName="px-4 pb-4 flex flex-col gap-3">
<p className="text-[10px] text-muted-foreground leading-snug">
Быстро добавить набор правил из каталога серверов (демо или live meta).
</p>
@@ -2979,19 +2974,18 @@ export default function AlertsPage() {
variant="outline"
className="h-7 text-xs"
disabled={rulesSaveBusy || (isLive && backendStatus === false)}
onClick={() => applyPresetReplace(p.id)}
onClick={() => setPendingPresetReplaceId(p.id)}
>
Заменить все
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-2 pt-4 px-4">
<CardTitle className="text-sm">Группы ANY / ALL</CardTitle>
<OpsPanel
title="Группы ANY / ALL"
headerRight={
<Button
type="button"
size="sm"
@@ -3002,8 +2996,9 @@ export default function AlertsPage() {
>
<PlusIcon className="size-3.5" />Группа
</Button>
</CardHeader>
<CardContent className="px-4 pb-4 flex flex-col gap-3">
}
contentClassName="px-4 pb-4 flex flex-col gap-3"
>
{groups.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
Групп пока нет. Создайте группу и назначьте правилам её в форме правила движок отправит одно сообщение по логике ANY или ALL.
@@ -3074,8 +3069,7 @@ export default function AlertsPage() {
</div>
))
)}
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
</CollapsibleContent>
@@ -3085,13 +3079,14 @@ export default function AlertsPage() {
<div className="grid grid-cols-1 xl:grid-cols-[1fr_300px] gap-5 items-start">
{/* ── rules card ── */}
<Card>
<CardHeader className="pb-0 pt-4 px-4">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm flex items-center gap-2">
Правила оповещения
{rulesSaveBusy && <LoaderCircleIcon className="size-3.5 animate-spin text-muted-foreground" aria-hidden />}
</CardTitle>
<OpsPanel
title={
<span className="flex items-center gap-2">
Правила оповещения
{rulesSaveBusy && <LoaderCircleIcon className="size-3.5 animate-spin text-muted-foreground" aria-hidden />}
</span>
}
headerRight={
<Input
placeholder="Поиск…"
value={search}
@@ -3100,10 +3095,9 @@ export default function AlertsPage() {
name="alert-rules-search"
className="h-7 text-xs max-w-[200px]"
/>
</div>
</CardHeader>
<CardContent className="px-0 pb-0 pt-2">
}
contentClassName="px-0 pb-0 pt-2"
>
{filteredRules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/40">
<BellOffIcon className="size-8 mb-2 opacity-40" />
@@ -3138,8 +3132,7 @@ export default function AlertsPage() {
<PlusIcon className="size-3" />Добавить правило
</button>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* ── right sidebar ── */}
<div className="flex flex-col gap-5">
@@ -3172,6 +3165,34 @@ export default function AlertsPage() {
onTestRuleTelegram={isLive ? testRuleTelegramFromForm : undefined}
testTelegramDisabled={isLive && backendStatus === false}
/>
<AlertDialog
open={pendingPresetReplaceId != null}
onOpenChange={(open) => {
if (!open) setPendingPresetReplaceId(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Заменить все правила?</AlertDialogTitle>
<AlertDialogDescription>
Текущие правила будут заменены выбранным пресетом. Это нельзя отменить.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
if (pendingPresetReplaceId) applyPresetReplace(pendingPresetReplaceId)
setPendingPresetReplaceId(null)
}}
>
Заменить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+50 -40
View File
@@ -5,10 +5,11 @@ import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { StatusBadge } from "@/components/status-badge"
import type { Backup, Server } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -26,6 +27,7 @@ import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers"
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
import { requestBlob } from "@/shared/api/http-client"
import { toast } from "sonner"
import {
Stepper,
@@ -274,8 +276,7 @@ export default function BackupsPage() {
}
async function handleDownload(id: string, fallbackFilename: string) {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`)
if (!res.ok) throw new Error("Не удалось скачать файл")
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
@@ -341,25 +342,39 @@ export default function BackupsPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего бэкапов", value: backupList.length, icon: <HardDriveIcon className="size-4" /> },
{ label: "Авто", value: autoCount, icon: <ClockIcon className="size-4" /> },
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<span className="text-muted-foreground/40">{s.icon}</span>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка бэкапов"
items={[
{
id: "all",
label: "Всего бэкапов",
value: backupList.length,
icon: <HardDriveIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "auto",
label: "Авто",
value: autoCount,
icon: <ClockIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "manual",
label: "Вручную",
value: manualCount,
icon: <PlusIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "servers",
label: "Серверов охвачено",
value: serverCount,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Info bar */}
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
@@ -427,9 +442,7 @@ export default function BackupsPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Расписание */}
<Card>
<CardContent className="px-5 py-5 flex flex-col gap-5">
<SectionTitle icon={<ClockIcon className="size-3.5" />}>Расписание</SectionTitle>
<OpsPanel title="Расписание" contentClassName="px-5 py-5 flex flex-col gap-5">
<div className="flex items-center justify-between">
<div>
@@ -520,13 +533,10 @@ export default function BackupsPage() {
</FormField>
</div>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* Хранилище */}
<Card>
<CardContent className="px-5 py-5 flex flex-col gap-5">
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
<OpsPanel title="Хранилище" contentClassName="px-5 py-5 flex flex-col gap-5">
<FormField label="Тип хранилища">
<SegmentedControl
@@ -613,14 +623,13 @@ export default function BackupsPage() {
<p className="mt-1">Файлы: <span className="font-mono text-foreground">{storage.localPath || "/var/backup/mikrotik"}/{"<server-name>"}_{"{date}"}.{schedule.format}</span></p>
)}
</div>
</CardContent>
</Card>
</OpsPanel>
{/* Серверы */}
<Card className="lg:col-span-2">
<CardContent className="px-5 py-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<SectionTitle icon={<ServerIcon className="size-3.5" />}>Серверы для бэкапа</SectionTitle>
<OpsPanel
className="lg:col-span-2"
title="Серверы для бэкапа"
headerRight={
<div className="flex items-center gap-2 shrink-0">
<button type="button" onClick={() => setSelectedServers(new Set(liveServers.map(s => s.id)))}
className="text-xs text-primary hover:underline">Выбрать все</button>
@@ -628,7 +637,9 @@ export default function BackupsPage() {
<button type="button" onClick={() => setSelectedServers(new Set())}
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
</div>
</div>
}
contentClassName="px-5 py-5 flex flex-col gap-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{liveServers.map((s) => {
@@ -662,8 +673,7 @@ export default function BackupsPage() {
<p className="text-xs text-muted-foreground">
Выбрано {selectedServers.size} из {liveServers.length} серверов
</p>
</CardContent>
</Card>
</OpsPanel>
{/* Save button */}
<div className="lg:col-span-2 flex items-center gap-3">
+83 -56
View File
@@ -9,7 +9,9 @@ import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
import { BGP_AS_NAMES } from "@/lib/bgp/types"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -19,9 +21,10 @@ import {
RefreshCwIcon, DownloadIcon, SearchIcon,
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
XIcon, AlertCircleIcon,
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -359,7 +362,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0)
return (
<Card key={router.id} className="overflow-hidden gap-0 py-0">
<Frame key={router.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* header */}
<div className="flex items-center gap-3 px-4 py-3 border-b">
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
@@ -424,7 +428,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
</span>
</div>
)}
</Card>
</FramePanel>
</Frame>
)
})}
</div>
@@ -459,28 +464,43 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
return (
<div className="flex flex-col gap-5">
{/* summary row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" },
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" },
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка префиксов BGP"
items={[
{
id: "rx",
label: "Всего префиксов",
value: fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "active",
label: "Активных маршрутов",
value: fmtNum(totalActive),
icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "ebgp",
label: "eBGP сессий",
value: ebgpSessions,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ibgp",
label: "iBGP сессий",
value: ibgpSessions,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
{/* prefixes by peer — horizontal bar chart */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-4">Топ-8 пиров по полученным префиксам</p>
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
<div className="flex flex-col gap-3">
{topPeers.map((s, i) => {
const pct = (s.prefixesRx / maxRx) * 100
@@ -516,15 +536,12 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p>
)}
</div>
</CardContent>
</Card>
</OpsPanel>
{/* right column */}
<div className="flex flex-col gap-4">
{/* state distribution */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-3">Распределение состояний</p>
<OpsPanel title="Распределение состояний" contentClassName="pt-4 pb-4 px-5">
{stateCounts.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
@@ -545,13 +562,10 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
})}
</div>
)}
</CardContent>
</Card>
</OpsPanel>
{/* eBGP vs iBGP */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-3">eBGP vs iBGP</p>
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
{sessions.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
@@ -592,8 +606,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
</div>
</>
)}
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
</div>
@@ -627,11 +640,7 @@ export default function BgpPage() {
if (cancelled) return
setLoading(true)
setLiveError(null)
fetch(`${backendUrl}/api/bgp/sessions`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json() as Promise<BackendBgpSession[]>
})
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
.then(data => {
if (cancelled) return
setLiveSessions(data.map(backendToFrontend))
@@ -731,22 +740,40 @@ export default function BgpPage() {
</span>
)}
{/* KPI strip */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: "Сессий всего", value: sessions.length, color: "" },
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" },
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка BGP"
items={[
{
id: "sessions",
label: "Сессий всего",
value: sessions.length,
icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "established",
label: "Established",
value: established,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "not-estab",
label: "Не установлено",
value: notEstab,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
variant: notEstab > 0 ? "warning" : "default",
},
{
id: "prefixes",
label: "Получено префиксов",
value: fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />,
iconClassName: "text-info",
},
]}
/>
{/* alert: not-established sessions */}
{notEstab > 0 && (
+128 -131
View File
@@ -7,9 +7,13 @@ import { FileImportDialog } from "@/components/file-import-dialog"
import { routerCertificates, servers as mockServers } from "@/lib/data"
import type { CertStatus, Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates"
import { Card, CardContent } from "@/components/ui/card"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -36,7 +40,6 @@ import {
} from "@/shared/api/certificates"
import { toast } from "sonner"
import {
SearchIcon,
ShieldCheckIcon,
BadgeCheckIcon,
AlertTriangleIcon,
@@ -117,40 +120,41 @@ function CertPartKpi({
expired: CertificateDto[]
}) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
<KpiStatGrid
aria-label="Сводка сертификатов"
items={[
{
id: "all",
label: "Всего",
value: displayCerts.length,
icon: <ShieldCheckIcon className="size-4 text-muted-foreground" />,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "valid",
label: "Действующих",
value: displayCerts.filter((c) => c.status === "valid").length,
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
icon: <BadgeCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "expiring",
label: "Истекают",
value: expiring.length,
icon: <AlertTriangleIcon className="size-4 text-amber-500" />,
icon: <AlertTriangleIcon className="size-4" />,
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
variant: expiring.length > 0 ? "warning" : "default",
},
{
id: "expired",
label: "Истёкших",
value: expired.length,
icon: <AlertCircleIcon className="size-4 text-red-500" />,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
variant: expired.length > 0 ? "destructive" : "default",
},
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
]}
/>
)
}
@@ -178,12 +182,11 @@ function CertPartAcmeSettings({
onSave: () => void
}) {
return (
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-3">
<p className="text-sm font-medium">ACME · Cloudflare DNS-01</p>
<p className="text-xs text-muted-foreground">
Публичные Let&apos;s Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+.
</p>
<OpsPanel
title="ACME · Cloudflare DNS-01"
description="Публичные Let's Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+."
contentClassName="px-5 py-4 flex flex-col gap-3"
>
<form
className="grid gap-3 md:grid-cols-2"
autoComplete="off"
@@ -232,71 +235,17 @@ function CertPartAcmeSettings({
Сохранить настройки
</Button>
</div>
</CardContent>
</Card>
)
}
function CertPartTableToolbar({
search,
setSearch,
statusFilter,
setStatusFilter,
filteredCount,
}: {
search: string
setSearch: (v: string) => void
statusFilter: CertStatus | "all"
setStatusFilter: (v: CertStatus | "all") => void
filteredCount: number
}) {
return (
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, CN, эмитенту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{(["all", "valid", "expired", "revoked"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setStatusFilter(s)}
className={cn(
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
statusFilter === s
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{s === "all"
? "Все"
: s === "valid"
? "Действующие"
: s === "expired"
? "Истёкшие"
: "Отозванные"}
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filteredCount} сертификатов</span>
</div>
</OpsPanel>
)
}
function CertPartReference() {
return (
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-1">
RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве.
</p>
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7 · /certificate справка CLI</p>
<OpsPanel
title="RouterOS 7 · /certificate — справка CLI"
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве."
contentClassName="px-5 py-4"
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
@@ -336,8 +285,7 @@ function CertPartReference() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
)
}
@@ -489,6 +437,8 @@ export default function CertificatesPage() {
const [issueTrustWww, setIssueTrustWww] = useState(true)
const [issueTrustApi, setIssueTrustApi] = useState(true)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
"https://acme-v02.api.letsencrypt.org/directory",
)
@@ -503,6 +453,27 @@ export default function CertificatesPage() {
return routerCertificates.map(mockToDto)
}, [prefsHydrated, isLive, certificates])
const displayServers = isLive ? serverList : mockServers
const scopedCerts = useMemo(() => {
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
return displayCerts.filter((c) => c.serverId === selectedServerId)
}, [displayCerts, selectedServerId])
const certRailItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(displayCerts.filter((c) => c.serverId === s.id).length),
}))
), [displayServers, displayCerts])
const serverById = useMemo(() => {
const map = new Map<string, Server>()
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
@@ -567,13 +538,13 @@ export default function CertificatesPage() {
}, [isLive, loadLive, loadAcmeSettings])
const expiring = useMemo(
() => displayCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
[displayCerts],
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
[scopedCerts],
)
const expired = useMemo(() => displayCerts.filter((c) => c.status === "expired"), [displayCerts])
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
const filtered = useMemo(() => {
return displayCerts.filter((c) => {
return scopedCerts.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true
const q = search.toLowerCase()
@@ -584,7 +555,7 @@ export default function CertificatesPage() {
c.sans.some((s) => s.includes(q))
)
})
}, [displayCerts, search, statusFilter])
}, [scopedCerts, search, statusFilter])
async function handleRefresh() {
if (!liveReady) return
@@ -677,35 +648,52 @@ export default function CertificatesPage() {
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
actions={
<>
<Button
variant="outline"
size="sm"
disabled={!liveReady || loadState === "loading"}
onClick={() => {
void handleRefresh()
}}
>
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
<PlusIcon className="size-4" />
Выпустить сертификат
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<>
<ServerRailLayout
items={certRailItems}
selectedId={selectedServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayServers.length}
loading={isLive && loadState === "loading" && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
disabled={!liveReady || loadState === "loading"}
onClick={() => {
void handleRefresh()
}}
>
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button
size="sm"
disabled={!liveReady || issueBusy}
onClick={() => {
setIssueStep(1)
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
setIssueOpen(true)
}}
>
<PlusIcon className="size-4" />
Выпустить сертификат
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5">
{isLive && backendStatus === false && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
@@ -725,7 +713,7 @@ export default function CertificatesPage() {
</div>
)}
<CertPartKpi displayCerts={displayCerts} expiring={expiring} expired={expired} />
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
{liveReady && (
<CertPartAcmeSettings
@@ -747,12 +735,21 @@ export default function CertificatesPage() {
)}
<DataPageCard>
<CertPartTableToolbar
<DataPageToolbar
search={search}
setSearch={setSearch}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
filteredCount={filtered.length}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, CN, эмитенту…"
segmented={{
value: statusFilter,
onChange: setStatusFilter,
options: [
{ value: "all", label: "Все" },
{ value: "valid", label: "Действующие" },
{ value: "expired", label: "Истёкшие" },
{ value: "revoked", label: "Отозванные" },
],
}}
countLabel={`${filtered.length} сертификатов`}
/>
<CertificatesDataGrid
certificates={filtered}
@@ -763,7 +760,7 @@ export default function CertificatesPage() {
<CertPartReference />
</div>
</div>
</ServerRailLayout>
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
@@ -796,7 +793,7 @@ export default function CertificatesPage() {
<StepperContent key={s} value={s}>
<CertPartIssueForm
step={s as 1 | 2 | 3 | 4}
serverList={serverList}
serverList={displayServers}
issueServerId={issueServerId}
setIssueServerId={setIssueServerId}
issueCertName={issueCertName}
@@ -855,6 +852,6 @@ export default function CertificatesPage() {
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
}}
/>
</div>
</>
)
}
+58 -44
View File
@@ -10,9 +10,9 @@ import {
ACTION_LABELS,
ACTION_COLOR,
} from "@/components/data-grids/communities-data-grid"
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -161,22 +161,39 @@ export default function CommunitiesPage() {
</p>
)}
{/* ── summary ── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "Всего communities", value: String(listData.length) },
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) },
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
].map(({ label, value }) => (
<Card key={label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка communities"
items={[
{
id: "all",
label: "Всего communities",
value: String(listData.length),
icon: <TagIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "enabled",
label: "Активных",
value: String(listData.filter((c) => c.enabled).length),
icon: <CheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "standard",
label: "Стандартных",
value: String(listData.filter((c) => c.type === "standard").length),
icon: <TagIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "filters",
label: "Использует фильтры",
value: String(new Set(listData.flatMap((c) => c.filterIds)).size),
icon: <FilterIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
<div className="grid grid-cols-[1fr_320px] gap-5">
{/* ── main table ── */}
@@ -211,30 +228,28 @@ export default function CommunitiesPage() {
{/* ── detail panel ── */}
{selected ? (
<Card className="h-fit sticky top-0">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
selected.enabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-muted text-muted-foreground"
)}>
{selected.enabled ? "Активен" : "Выключен"}
</span>
</div>
<CardTitle className="text-sm font-mono">{selected.value}</CardTitle>
<CardDescription className="text-xs mt-0.5">{selected.name}</CardDescription>
</div>
<OpsPanel
className="h-fit sticky top-0"
title={<span className="font-mono">{selected.value}</span>}
description={selected.name}
headerRight={
<div className="flex gap-1 shrink-0">
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
</div>
}
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
>
<div className="flex items-center gap-2">
<span className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
selected.enabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-muted text-muted-foreground"
)}>
{selected.enabled ? "Активен" : "Выключен"}
</span>
</div>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-xs text-muted-foreground">{selected.description}</p>
<div className="space-y-2">
@@ -272,15 +287,14 @@ export default function CommunitiesPage() {
Скопировать значение
</Button>
</div>
</CardContent>
</Card>
</OpsPanel>
) : (
<Card className="h-fit">
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<Frame dense className="w-full h-fit">
<FramePanel className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<TagIcon className="size-8 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
</CardContent>
</Card>
</FramePanel>
</Frame>
)}
</div>
</div>
+61 -80
View File
@@ -5,7 +5,9 @@ import { PageHeader } from "@/components/page-header"
import { routerContainers, servers } from "@/lib/data"
import type { RouterContainer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
@@ -15,13 +17,14 @@ import {
import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon,
CodeXmlIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon,
} from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ─── helpers ──────────────────────────────────────────────────────────────────
@@ -102,57 +105,23 @@ function generateContainerRsc(c: RouterContainer): string {
function ExportSheet({ open, container, onClose }: {
open: boolean; container: RouterContainer | null; onClose: () => void
}) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт Container</SheetTitle>
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<CodeExportSheet
open={open}
onClose={onClose}
title="Экспорт Container"
description="RouterOS 7.4+ · /container · /interface/veth"
formats={[
{
id: "rsc",
label: "MikroTik .rsc",
filename: `${container?.name ?? "container"}.rsc`,
code,
},
]}
/>
)
}
@@ -169,7 +138,8 @@ function ContainerCard({
const cfg = statusConfig(container.status)
return (
<Card className="overflow-hidden">
<Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
<div className="flex items-center gap-2 min-w-0">
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
@@ -201,7 +171,7 @@ function ContainerCard({
</DropdownMenu>
</div>
<CardContent className="px-4 py-3 flex flex-col gap-3">
<div className="px-4 py-3 flex flex-col gap-3">
{/* image */}
<div className="flex items-center gap-2">
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
@@ -273,8 +243,9 @@ function ContainerCard({
{container.comment && (
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
)}
</CardContent>
</Card>
</div>
</FramePanel>
</Frame>
)
}
@@ -315,25 +286,40 @@ export default function ContainersPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка контейнеров"
items={[
{
id: "all",
label: "Всего",
value: routerContainers.length,
icon: <BoxIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "running",
label: "Running",
value: running,
icon: <PlayIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "stopped",
label: "Stopped",
value: stopped,
icon: <StopCircleIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "errors",
label: "Ошибок",
value: errors,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: "text-destructive",
variant: errors > 0 ? "destructive" : "default",
},
]}
/>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
@@ -397,11 +383,7 @@ export default function ContainersPage() {
)}
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7.4+ · /container быстрые команды
</p>
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
@@ -457,8 +439,7 @@ export default function ContainersPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
+99 -142
View File
@@ -4,10 +4,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"
import { usePathname } from "next/navigation"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { OpsPanel } from "@/components/ops-panel"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { StatusDot } from "@/components/status-dot"
import { StatusBadge } from "@/components/status-badge"
import { Sparkline } from "@/components/sparkline"
import { LatencyChart } from "@/components/dashboard/latency-chart"
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
import { InternetPathMapCard } from "@/components/dashboard/internet-path-map"
@@ -22,7 +22,7 @@ import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
import type { GreTunnel } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, DownloadIcon } from "lucide-react"
import { AlertCircleIcon, AlertTriangleIcon, BellIcon, FilterIcon, GitMergeIcon, InfoIcon, DownloadIcon, ServerIcon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { DataPageCard } from "@/components/data-page-card"
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
@@ -45,35 +45,6 @@ function makeApiFetch(backendUrl: string) {
}
}
function StatCard({
label, value, unit, delta, deltaDir, spark, sparkColor,
}: {
label: string; value: string; unit?: string; delta?: string
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
}) {
return (
<Card className="relative overflow-hidden">
<CardContent className="pt-5 pb-4 px-5">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<div className="flex items-baseline gap-1.5 mt-1">
<span className="text-3xl font-semibold tracking-tight tabular-nums">{value}</span>
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
</div>
{delta && (
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
{delta}
</p>
)}
{spark && spark.length > 1 && (
<div className="absolute right-4 bottom-4 opacity-60">
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
</div>
)}
</CardContent>
</Card>
)
}
function fmtIntRu(n: number): string {
return n.toLocaleString("ru-RU")
}
@@ -709,63 +680,67 @@ export default function DashboardPage() {
<div className="flex-1 overflow-y-auto">
<div className="p-6 flex flex-col gap-6">
{/* KPI row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Серверы онлайн"
value={dashboardKpi.servers.value}
unit={dashboardKpi.servers.unit}
delta={dashboardKpi.servers.delta}
deltaDir={dashboardKpi.servers.deltaDir}
spark={dashboardKpi.servers.spark}
sparkColor={dashboardKpi.servers.sparkColor}
/>
<StatCard
label="Активные фильтры"
value={dashboardKpi.filters.value}
unit={dashboardKpi.filters.unit}
delta={dashboardKpi.filters.delta}
deltaDir={dashboardKpi.filters.deltaDir}
spark={dashboardKpi.filters.spark}
sparkColor={dashboardKpi.filters.sparkColor}
/>
<StatCard
label="BGP-префиксы"
value={dashboardKpi.bgp.value}
unit={dashboardKpi.bgp.unit}
delta={dashboardKpi.bgp.delta}
deltaDir={dashboardKpi.bgp.deltaDir}
spark={dashboardKpi.bgp.spark}
sparkColor={dashboardKpi.bgp.sparkColor}
/>
<StatCard
label="Активные алерты"
value={dashboardKpi.alerts.value}
unit={dashboardKpi.alerts.unit}
delta={dashboardKpi.alerts.delta}
deltaDir={dashboardKpi.alerts.deltaDir}
spark={dashboardKpi.alerts.spark}
sparkColor={dashboardKpi.alerts.sparkColor}
/>
</div>
<KpiStatGrid
aria-label="Сводка дашборда"
items={[
{
id: "servers",
label: "Серверы онлайн",
value: dashboardKpi.servers.unit
? `${dashboardKpi.servers.value} ${dashboardKpi.servers.unit}`
: dashboardKpi.servers.value,
hint: dashboardKpi.servers.delta,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
variant: dashboardKpi.servers.deltaDir === "down" ? "warning" : "default",
},
{
id: "filters",
label: "Активные фильтры",
value: dashboardKpi.filters.unit
? `${dashboardKpi.filters.value} ${dashboardKpi.filters.unit}`
: dashboardKpi.filters.value,
hint: dashboardKpi.filters.delta,
icon: <FilterIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "bgp",
label: "BGP-префиксы",
value: dashboardKpi.bgp.unit
? `${dashboardKpi.bgp.value} ${dashboardKpi.bgp.unit}`
: dashboardKpi.bgp.value,
hint: dashboardKpi.bgp.delta,
icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-primary",
},
{
id: "alerts",
label: "Активные алерты",
value: dashboardKpi.alerts.unit
? `${dashboardKpi.alerts.value} ${dashboardKpi.alerts.unit}`
: dashboardKpi.alerts.value,
hint: dashboardKpi.alerts.delta,
icon: <BellIcon className="size-4" />,
iconClassName: dashboardKpi.alerts.deltaDir === "down" ? "text-destructive" : "text-muted-foreground",
variant: dashboardKpi.alerts.deltaDir === "down" ? "destructive" : "default",
},
]}
/>
{/* Latency chart + Events */}
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Задержка до серверов</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">
{latencyChartBlock.kind === "mock" && latencyChartBlock.subtitle}
{latencyChartBlock.kind === "live" && latencyChartBlock.subtitle}
{latencyChartBlock.kind === "loading" && "Загрузка…"}
{latencyChartBlock.kind === "empty" && "Нет серии RTT для графика"}
</p>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 px-3">
<OpsPanel
title="Задержка до серверов"
description={
latencyChartBlock.kind === "mock" || latencyChartBlock.kind === "live"
? latencyChartBlock.subtitle
: latencyChartBlock.kind === "loading"
? "Загрузка…"
: "Нет серии RTT для графика"
}
contentClassName="px-3 pb-3"
>
{latencyChartBlock.kind === "loading" && (
<div
className="w-full rounded-md bg-muted/50 animate-pulse"
@@ -783,23 +758,20 @@ export default function DashboardPage() {
labels={latencyChartBlock.labels}
/>
)}
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Последние события</CardTitle>
<OpsPanel
title="Последние события"
description="Система и BGP-активность"
headerRight={
<Link
href="/alerts"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-xs h-7")}
>
Все
</Link>
</div>
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
</CardHeader>
<CardContent className="pt-0 px-0">
}
>
<div className="divide-y divide-border">
{eventsLoading && recentEvents.length === 0 && (
<div className="px-5 py-6 text-sm text-muted-foreground">Загрузка событий...</div>
@@ -825,48 +797,41 @@ export default function DashboardPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
{/* Bandwidth + Server status */}
<div className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Пропускная способность</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">Суммарный RX / TX по всем серверам</p>
</div>
<OpsPanel
title="Пропускная способность"
description="Суммарный RX / TX по всем серверам"
headerRight={
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-foreground/80 rounded inline-block" />RX 318 Мбит/с</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-[var(--chart-tx)] rounded inline-block" />TX 244 Мбит/с</span>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 px-3">
}
contentClassName="px-3 pb-3"
>
<BandwidthChart rx={traffic.rx} tx={traffic.tx} />
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<CardTitle className="text-base">Состояние серверов</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5 truncate" title={serverStatusModel.subtitle}>
{serverStatusModel.subtitle}
</p>
</div>
<OpsPanel
title="Состояние серверов"
description={
<span className="truncate" title={serverStatusModel.subtitle}>
{serverStatusModel.subtitle}
</span>
}
headerRight={
<Link
href="/servers"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs shrink-0")}
>
Управление
</Link>
</div>
</CardHeader>
<CardContent className="pt-0 px-0">
}
>
<div className="divide-y divide-border">
{serverStatusModel.kind === "loading" && (
<>
@@ -916,8 +881,7 @@ export default function DashboardPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
<div className="grid grid-cols-1 gap-4">
@@ -933,28 +897,22 @@ export default function DashboardPage() {
</div>
{/* Ping probes table */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Активные пробы</CardTitle>
<p className={cn(
"text-sm mt-0.5",
probesError && isLive ? "text-destructive" : "text-muted-foreground",
)}
>
{probesSubtitle}
</p>
</div>
<OpsPanel
title="Активные пробы"
description={
<span className={probesError && isLive ? "text-destructive" : undefined}>
{probesSubtitle}
</span>
}
headerRight={
<Link
href="/uptime"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}
>
Открыть монитор
</Link>
</div>
</CardHeader>
<CardContent className="pt-0 px-0">
}
>
<DataPageCard className="rounded-none border-0 shadow-none">
<DashboardActiveProbesDataGrid
probes={activeProbesTable}
@@ -967,8 +925,7 @@ export default function DashboardPage() {
}
/>
</DataPageCard>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
+93 -89
View File
@@ -4,9 +4,10 @@ import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge"
import { Badge } from "@/components/reui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import {
Collapsible,
@@ -53,6 +54,7 @@ import {
type SchedulerJobGridRow,
} from "@/components/data-grids/data-collection-scheduler-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
import { cn } from "@/lib/utils"
import {
AlertCircleIcon,
@@ -102,7 +104,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "traffic") {
const t = snap as TrafficRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{t.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
) : null}
@@ -124,7 +126,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_resources") {
const u = snap as ResourcesRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{u.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
) : null}
@@ -146,7 +148,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "servers_rest_ping") {
const s = snap as ServersRestPingRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{s.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
) : null}
@@ -169,7 +171,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_ping") {
const p = snap as PingRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{p.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
) : null}
@@ -194,7 +196,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_speed") {
const s = snap as SpeedScheduledRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> по очереди для каждой включённой пробы
</p>
@@ -207,7 +209,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "gre_bgp") {
const g = snap as GreBgpSnapshotRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{g.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
) : null}
@@ -233,7 +235,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
</dl>
{g.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{g.errors.map((e, i) => (
<p key={i} className="break-words">
{e}
@@ -247,7 +249,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "internet_path") {
const p = snap as InternetPathRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Снимок internet-path на{" "}
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
@@ -274,7 +276,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "certificates_renew") {
const c = snap as CertificatesRenewRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{c.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
) : null}
@@ -297,7 +299,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
</dl>
{c.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{c.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
@@ -309,7 +311,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "backups") {
const b = snap as BackupsRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Слот расписания</dt>
@@ -329,7 +331,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
</dl>
{b.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{b.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
@@ -341,7 +343,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Снимок на{" "}
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
@@ -368,7 +370,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
{a.errors?.length ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="space-y-1 text-xs">
<AlertDescription className="flex flex-col gap-1 text-xs">
{a.errors.map((e, i) => (
<p key={i} className="break-words">
{e}
@@ -378,7 +380,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</Alert>
) : null}
{a.ruleDiag && a.ruleDiag.length > 0 ? (
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 flex flex-col gap-2">
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
<DataPageCard className="border-0 shadow-none bg-transparent">
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
@@ -396,39 +398,39 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
return (
<div className="space-y-4 text-sm">
<div className="flex flex-col gap-4 text-sm">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
<dd className="font-mono text-xs">{r.jobKey}</dd>
</div>
<div className="sm:col-span-2 space-y-1">
<div className="sm:col-span-2 flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
<dd>
<span className="font-medium">{jobTitle}</span>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
<dd className="tabular-nums">
<span className="font-mono">{r.durationMs}</span> мс
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
<dd>
<Badge
@@ -445,7 +447,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
</div>
</dl>
{r.error ? (
<div className="space-y-1.5">
<div className="flex flex-col gap-1.5">
<p className="text-xs font-medium text-destructive">Текст ошибки</p>
<pre
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
@@ -459,7 +461,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
<Separator />
<div className="space-y-2">
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
{snapshot ? (
<SnapshotTables snap={snapshot} />
@@ -965,13 +967,13 @@ export default function DataCollectionPage() {
sub: uptimeCollector?.scheduler?.jobs?.length
? "По сохранённым задачам планировщика"
: "По переключателям на этой странице",
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
icon: <CalendarClockIcon className="size-4" />,
},
{
label: "Сейчас выполняется",
value: String(runningJobsCount),
sub: "Фоновые прогоны планировщика",
icon: <LoaderCircleIcon className="size-4 text-amber-500" />,
icon: <LoaderCircleIcon className="size-4" />,
},
{
label: "Трафик — последний сбор",
@@ -980,16 +982,16 @@ export default function DataCollectionPage() {
: "—",
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
icon: trafficCollector?.lastError ? (
<XCircleIcon className="size-4 text-destructive" />
<XCircleIcon className="size-4" />
) : (
<CheckCircleIcon className="size-4 text-emerald-500" />
<CheckCircleIcon className="size-4" />
),
},
{
label: "Журнал (в списке)",
value: String(schedulerRuns.length),
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
icon: <DatabaseIcon className="size-4 text-sky-500" />,
icon: <DatabaseIcon className="size-4" />,
},
],
[
@@ -1032,30 +1034,24 @@ export default function DataCollectionPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
{!prefsHydrated && (
<Card>
<CardHeader>
<CardTitle className="text-base">Загрузка настроек подключения</CardTitle>
<CardDescription className="text-xs">
Читаем режим данных и адрес API из локальных настроек.
</CardDescription>
</CardHeader>
</Card>
<OpsPanel
title="Загрузка настроек подключения"
description="Читаем режим данных и адрес API из локальных настроек."
>
<div />
</OpsPanel>
)}
{prefsHydrated && !isLive && (
<Card>
<CardHeader>
<CardTitle className="text-base">Нужен live-режим</CardTitle>
<CardDescription className="text-xs">
Планировщик и журнал читаются только с бекенда. Включите «Живые» данные и проверьте URL бекенда в настройках.
</CardDescription>
</CardHeader>
<CardContent className="px-5 pb-5">
<OpsPanel
title="Нужен live-режим"
description="Планировщик и журнал читаются только с бекенда. Включите «Живые» данные и проверьте URL бекенда в настройках."
contentClassName="px-5 pb-5"
>
<Link href="/settings" className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-8")}>
Открыть настройки
</Link>
</CardContent>
</Card>
</OpsPanel>
)}
{isLive && collectorError && (
@@ -1068,32 +1064,43 @@ export default function DataCollectionPage() {
{isLive && (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-xl font-semibold tabular-nums mt-0.5 truncate">{s.value}</p>
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
</div>
<div className="shrink-0 mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка сбора данных"
items={stats.map((s, i) => ({
id: `dc-${i}`,
label: s.label,
value: s.value,
hint: s.sub,
icon: s.icon,
iconClassName:
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
? "text-destructive"
: s.label === "Сейчас выполняется" && runningJobsCount > 0
? "text-warning"
: s.label === "Журнал (в списке)" && errorRunsInView
? "text-destructive"
: s.label === "Трафик — последний сбор"
? "text-success"
: "text-muted-foreground",
variant:
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
? "destructive" as const
: s.label === "Журнал (в списке)" && errorRunsInView
? "warning" as const
: "default" as const,
}))}
/>
<Card>
<CardHeader className="border-b border-border pb-4">
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
<CardDescription className="text-xs">
<DataPageCard>
<div className="border-b border-border px-5 py-4">
<p className="text-base font-medium">Планировщик сбора данных</p>
<p className="text-xs text-muted-foreground mt-1">
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже интервалы и срок хранения.
</CardDescription>
</CardHeader>
<CardContent className="px-0 pb-0">
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
<Separator />
<div className="space-y-3 px-5 py-4">
</p>
</div>
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
<Separator />
<div className="flex flex-col gap-3 px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
@@ -1145,7 +1152,7 @@ export default function DataCollectionPage() {
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
Сохранить настройки планировщика
</Button>
<div className="text-xs text-muted-foreground space-y-0.5 pt-1 border-t border-border/60">
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
<p>
Трафик последний сбор:{" "}
{trafficCollector?.lastCollectedAt
@@ -1202,17 +1209,14 @@ export default function DataCollectionPage() {
</p>
</div>
</div>
</CardContent>
</Card>
</DataPageCard>
<Card>
<CardHeader className="border-b border-border flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<CardTitle className="text-base">Журнал прогонов</CardTitle>
<CardDescription className="text-xs">
SQLite `scheduler_runs` до 80 записей; раскройте строку для полей и текста ошибки.
</CardDescription>
</div>
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
<OpsPanel
title="Журнал прогонов"
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
headerRight={
<div className="flex items-center gap-2 shrink-0">
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
Задача
@@ -1231,8 +1235,9 @@ export default function DataCollectionPage() {
))}
</select>
</div>
</CardHeader>
<CardContent className="px-0 pb-0">
}
contentClassName="px-0 pb-0"
>
{schedulerRuns.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p>
) : (
@@ -1289,8 +1294,7 @@ export default function DataCollectionPage() {
))}
</div>
)}
</CardContent>
</Card>
</OpsPanel>
</>
)}
</div>
+155 -229
View File
@@ -10,7 +10,6 @@ import {
type RecursiveRouteLite,
} from "@/components/data-grids/filters-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
@@ -36,6 +35,9 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { toast } from "sonner"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail"
// ── helpers ────────────────────────────────────────────────────────────────────
@@ -755,13 +757,11 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
tunnelsList: GreTunnel[]
recRoutesByServer: Record<string, RecursiveRouteLite[]>
}) {
const [copied, setCopied] = useState(false)
const singleRuleset = useMemo(
() => rulesets.filter(r => r.serverId === serverId),
[rulesets, serverId],
)
const server = serversList.find(s => s.id === serverId)
const server = serversList.find(s => s.id === serverId)
const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
const config = useMemo(
@@ -772,93 +772,25 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
)
const handleCopy = () => {
navigator.clipboard.writeText(config).catch(() => {})
setCopied(true); setTimeout(() => setCopied(false), 2000)
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-2xl mx-4 bg-card rounded-xl border shadow-2xl flex flex-col max-h-[85vh]">
{/* header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b shrink-0">
<div className="flex items-center gap-2.5">
<FileCodeIcon className="size-4 text-muted-foreground" />
<span className="text-sm font-semibold">RouterOS config</span>
{server && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
<Flag code={server.country} size={11} />
<span className="font-mono">{server.name}</span>
<span>· {totalRules} правил</span>
</span>
)}
</div>
<Button variant="ghost" size="icon-sm" onClick={onClose}><XIcon className="size-4" /></Button>
</div>
{/* code */}
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<pre className="text-xs font-mono bg-[#0d1117] rounded-lg p-4 leading-[1.6] whitespace-pre overflow-x-auto">
{config.split("\n").map((line, i) => {
const trimmed = line.trimStart()
const cls =
// section dividers
trimmed.startsWith("# ═") || trimmed.startsWith("# ─")
? "text-[#444c56]" :
// inline comments inside rule body
trimmed.startsWith("# ")
? "text-[#8b949e]" :
// RouterOS scripting keywords
trimmed.startsWith(":local") || trimmed.startsWith(":log") || trimmed.startsWith(":foreach")
? "text-[#d2a8ff]" :
// :if identity branch / closing brace
trimmed.startsWith(":if") || (trimmed === "}" && line.length < 3)
? "text-[#ff7b72] font-semibold" :
// filter rule add command
trimmed.startsWith("/routing filter rule")
? "text-[#79c0ff]" :
// rule body: if/else if branches
trimmed.startsWith("if (") || trimmed.startsWith("} else if")
? "text-[#ff7b72]" :
// rule body: blackhole action
trimmed.startsWith("set type blackhole")
? "text-[#ff7b72] font-semibold" :
// rule body: set actions
trimmed.startsWith("set gw") || trimmed.startsWith("set gateway") || trimmed.startsWith("set out-interface")
? "text-[#a5d6ff]" :
// rule body: accept / rule close
trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
? "text-[#79c0ff]" :
// named params
trimmed.match(/^(chain|comment|bgp-communities)=/)
? "text-[#a5d6ff]" :
"text-[#c9d1d9]"
return <span key={i} className={cn("block", cls)}>{line || " "}</span>
})}
</pre>
</div>
{/* footer */}
<div className="px-5 py-4 border-t bg-muted/30 shrink-0 space-y-3">
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs text-muted-foreground">
<p>1. Скопируйте скрипт в буфер обмена</p>
<p>3. Вставьте в терминал и нажмите Enter</p>
<p>2. Подключитесь к любому MikroTik (SSH / Winbox)</p>
<p>4. Скрипт сам определит свои правила по identity</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Закрыть</Button>
<Button onClick={handleCopy} className="flex-1">
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано!" : "Скопировать"}
</Button>
</div>
</div>
</div>
</div>
<CodeExportSheet
open={open}
onClose={onClose}
title="RouterOS config"
description={
server
? `${server.name} · ${totalRules} правил`
: "Экспорт правил фильтрации"
}
formats={[
{
id: "rsc",
label: "RouterOS",
filename: `filters-${server?.name ?? "server"}.rsc`,
code: config,
},
]}
/>
)
}
@@ -1471,6 +1403,25 @@ export default function FiltersPage() {
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
const filterRailItems = useMemo<ServerTileItem[]>(() => (
allServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(rulesets.find((r) => r.serverId === s.id)?.rules.length ?? 0),
}))
), [allServers, rulesets])
const handleSelectServer = useCallback((id: string) => {
setSelectedServerId(id)
setSearch("")
}, [])
const currentRules = useMemo(
() => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [],
[rulesets, selectedServerId],
@@ -1636,142 +1587,117 @@ export default function FiltersPage() {
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
actions={
<>
{isLive && (
<>
<Button
variant="outline"
size="sm"
onClick={syncFromRouter}
disabled={syncBusy !== null}
title="Синхронизация Router → БД"
>
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
</Button>
<Button
variant="outline"
size="sm"
onClick={syncToRouter}
disabled={syncBusy !== null}
title="Синхронизация БД → Router"
>
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void fetchRouterCompare()}
disabled={syncBusy !== null || routerCompareLoading}
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
className="gap-1.5"
>
{routerCompareLoading ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
Сверить
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
<FileCodeIcon className="size-4" />RouterOS
</Button>
<Button
variant="outline" size="sm"
onClick={() => setCopyOpen(true)}
disabled={currentRules.length === 0}
title="Копировать правила на другой сервер"
>
<CopyIcon className="size-4" />Копировать
</Button>
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />Новое правило
</Button>
</>
}
/>
{isLive && liveLoadState === "error" && (
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
<AlertCircleIcon className="size-3.5 shrink-0" />
Бекенд недоступен показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
</div>
)}
{/* ── summary bar + server chips (same pattern as monitoring) ── */}
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего правил</span>
<span className="font-semibold tabular-nums">{totalRules}</span>
</div>
{(() => {
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
if (bhTotal === 0) return null
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
{bhTotal} blackhole
</span>
)
})()}
<div className="w-px h-4 bg-border mx-1 shrink-0" />
{allServers.map(s => {
const count = rulesets.find(r => r.serverId === s.id)?.rules.length ?? 0
const active = selectedServerId === s.id
return (
<button key={s.id}
onClick={() => { setSelectedServerId(s.id); setSearch("") }}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
!s.enabled && !active && "opacity-40",
)}>
<StatusDot status={s.status} />
<Flag code={s.country} size={12} />
<span className="font-mono">{s.name}</span>
<TypeChip type={s.type} />
<span className={cn(
"tabular-nums font-semibold",
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
)}>{count}</span>
</button>
)
})}
</div>
{/* ── toolbar ── */}
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
value={search} onChange={e => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRules.length !== currentRules.length
? `${filteredRules.length} из ${currentRules.length} правил`
: `${currentRules.length} правил`
<>
<ServerRailLayout
items={filterRailItems}
selectedId={selectedServerId}
onSelect={handleSelectServer}
showAll={false}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
actions={
<>
<ServerRailMobileButton />
{isLive && (
<>
<Button
variant="outline"
size="sm"
onClick={syncFromRouter}
disabled={syncBusy !== null}
title="Синхронизация Router → БД"
>
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
</Button>
<Button
variant="outline"
size="sm"
onClick={syncToRouter}
disabled={syncBusy !== null}
title="Синхронизация БД → Router"
>
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void fetchRouterCompare()}
disabled={syncBusy !== null || routerCompareLoading}
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
className="gap-1.5"
>
{routerCompareLoading ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
Сверить
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
<FileCodeIcon className="size-4" />RouterOS
</Button>
<Button
variant="outline" size="sm"
onClick={() => setCopyOpen(true)}
disabled={currentRules.length === 0}
title="Копировать правила на другой сервер"
>
<CopyIcon className="size-4" />Копировать
</Button>
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />Новое правило
</Button>
</>
}
</p>
</div>
{/* ── main content ── */}
<div className="flex-1 overflow-y-auto p-6">
/>
}
banner={
<>
{isLive && liveLoadState === "error" && (
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
<AlertCircleIcon className="size-3.5 shrink-0" />
Бекенд недоступен показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
</div>
)}
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
value={search} onChange={e => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего правил</span>
<span className="font-semibold tabular-nums">{totalRules}</span>
</div>
{(() => {
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
if (bhTotal === 0) return null
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
{bhTotal} blackhole
</span>
)
})()}
<p className="text-xs text-muted-foreground ml-auto">
{filteredRules.length !== currentRules.length
? `${filteredRules.length} из ${currentRules.length} правил`
: `${currentRules.length} правил`
}
</p>
</div>
</>
}
>
<div className="flex flex-col gap-4">
{/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */}
@@ -1805,7 +1731,7 @@ export default function FiltersPage() {
<DataPageCard>
{/* selected server header */}
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
<StatusDot status={selectedServer.status} pulse={selectedServer.status === "online"} />
<Flag code={selectedServer.country} size={16} />
<span className="font-mono text-sm font-semibold">{selectedServer.name}</span>
@@ -1880,14 +1806,14 @@ export default function FiltersPage() {
{/* add rule shortcut */}
<button onClick={openCreate}
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" />
Добавить правило для {selectedServer.name}
</button>
</DataPageCard>
</div>
</div>
</ServerRailLayout>
<RuleSheet
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
@@ -1928,6 +1854,6 @@ export default function FiltersPage() {
recRoutesByServer={recRoutesByServer}
ensureRecursiveFor={ensureRecursiveRoutes}
/>
</div>
</>
)
}
File diff suppressed because it is too large Load Diff
+180 -173
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar, DataPageToolbarFrame } from "@/components/data-page-toolbar"
import { GreTunnelsDataGrid } from "@/components/data-grids/gre-tunnels-data-grid"
import { GrePoolsDataGrid } from "@/components/data-grids/gre-pools-data-grid"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
@@ -12,7 +13,9 @@ import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -23,14 +26,16 @@ import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
import { Flag } from "@/components/flag"
import {
PlusIcon, SearchIcon, RefreshCwIcon, MoreHorizontalIcon,
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
DatabaseIcon,
} from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── label maps ─────────────────────────────────────────────────────────────
@@ -237,11 +242,11 @@ export default function GrePage() {
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
const [search, setSearch] = useState("")
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [tunnelOpen, setTunnelOpen] = useState(false)
const [poolOpen, setPoolOpen] = useState(false)
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
const [copied, setCopied] = useState(false)
const [tForm, setTForm] = useState(defaultTunnelForm)
const [pForm, setPForm] = useState(defaultPoolForm)
@@ -290,6 +295,25 @@ export default function GrePage() {
[isLive, displayTunnels],
)
const greRailItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(displayTunnels.filter((t) => t.serverId === s.id).length),
}))
), [displayServers, displayTunnels])
const scopedTunnels = useMemo(() => {
if (selectedServerId === ALL_SERVERS_ID) return displayTunnels
return displayTunnels.filter((t) => t.serverId === selectedServerId)
}, [displayTunnels, selectedServerId])
const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
@@ -338,7 +362,7 @@ export default function GrePage() {
}, [dataError])
const filtered = useMemo(() => {
return displayTunnels.filter((t) => {
return scopedTunnels.filter((t) => {
if (tabFilter === "up" && t.status !== "up") return false
if (tabFilter === "ipsec" && !t.ipsec) return false
if (tabFilter === "plain" && t.ipsec) return false
@@ -351,60 +375,68 @@ export default function GrePage() {
serverById[t.serverId]?.name.toLowerCase().includes(q)
)
})
}, [tabFilter, search, displayTunnels, serverById])
}, [tabFilter, search, scopedTunnels, serverById])
const upCount = displayTunnels.filter((t) => t.status === "up").length
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
const scopedUpCount = scopedTunnels.filter((t) => t.status === "up").length
const scopedIpsecCount = scopedTunnels.filter((t) => t.ipsec).length
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
{ value: "all", label: "Все", count: displayTunnels.length },
{ value: "up", label: "Активные", count: upCount },
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
{ value: "all", label: "Все", count: scopedTunnels.length },
{ value: "up", label: "Активные", count: scopedUpCount },
{ value: "ipsec", label: "С IPsec", count: scopedIpsecCount },
{ value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount },
]
function handleCopy(code: string) {
navigator.clipboard.writeText(code).then(() => {
setCopied(true)
toast.success("Команды скопированы")
setTimeout(() => setCopied(false), 2000)
})
}
const greExportCode = useMemo(
() => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""),
[codePreviewTunnel, serverById],
)
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
actions={
<>
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || dataLoading}
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
>
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
size="sm"
onClick={() => { void syncJhToDb() }}
disabled={!isLive || syncJhBusy || dataLoading}
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
>
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
JH БД
</Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<>
<ServerRailLayout
items={greRailItems}
selectedId={selectedServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayServers.length}
loading={isLive && dataLoading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || dataLoading}
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
>
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
size="sm"
onClick={() => { void syncJhToDb() }}
disabled={!isLive || syncJhBusy || dataLoading}
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
>
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
JH БД
</Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5">
{/* Legacy banner */}
@@ -421,25 +453,39 @@ export default function GrePage() {
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка GRE"
items={[
{
id: "tunnels",
label: "Всего туннелей",
value: displayTunnels.length,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активно",
value: upCount,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ipsec",
label: "Защищены IPsec",
value: ipsecCount,
icon: <LockIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "pools",
label: "IP-пулов",
value: displayPools.length,
icon: <DatabaseIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Page tabs */}
<div className="flex items-center gap-1 border-b">
@@ -454,23 +500,21 @@ export default function GrePage() {
{/* ── Tunnels ── */}
{pageTab === "tunnels" && (
<DataPageCard>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{tunnelTabs.map((t) => (
<button key={t.value} onClick={() => setTabFilter(t.value)}
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${tabFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{t.label}
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
</button>
))}
</div>
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, IP…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
</div>
<DataPageToolbar
segmented={{
value: tabFilter,
onChange: setTabFilter,
options: tunnelTabs.map((t) => ({
value: t.value,
label: t.label,
count: t.count,
})),
}}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, IP…"
countLabel={`${filtered.length} туннелей`}
/>
<GreTunnelsDataGrid
tunnels={filtered}
@@ -484,12 +528,13 @@ export default function GrePage() {
{/* ── IP Pools ── */}
{pageTab === "pools" && (
<DataPageCard>
<div className="flex items-center justify-between px-5 py-3 border-b">
<DataPageToolbarFrame className="justify-between">
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
<PlusIcon className="size-4" />Добавить пул
<PlusIcon className="size-4" />
Добавить пул
</Button>
</div>
</DataPageToolbarFrame>
<GrePoolsDataGrid pools={displayPools} />
<div className="border-t px-5 py-4">
@@ -519,9 +564,7 @@ export default function GrePage() {
)}
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7.20+ параметры GRE-интерфейса</p>
<OpsPanel title="RouterOS 7.20+ — параметры GRE-интерфейса" contentClassName="px-5 py-4">
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-xs font-mono">
{[
["/interface gre add", ""],
@@ -544,88 +587,52 @@ export default function GrePage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
</ServerRailLayout>
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
{codePreviewTunnel && (() => {
const code = generateRosCommands(codePreviewTunnel, serverById)
return (
<>
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle>
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription>
</div>
<Button
variant="outline" size="sm"
className="shrink-0 gap-1.5"
onClick={() => handleCopy(code)}
>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</>
: <><CopyIcon className="size-3.5" /> Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
{/* meta strip */}
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs">
<span className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
{STATUS_MAP[codePreviewTunnel.status].label}
</span>
<span className="text-muted-foreground">·</span>
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
<span className="text-muted-foreground">·</span>
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} {codePreviewTunnel.remoteAddress}</span>
{codePreviewTunnel.ipsec && (
<>
<span className="text-muted-foreground">·</span>
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span>
</>
)}
</div>
{/* code block */}
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isSection = isComment && line.includes("──")
const isKey = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isSection ? "text-muted-foreground/60"
: isComment ? "text-muted-foreground"
: isKey ? "text-sky-400/90"
: "text-foreground"
}>
{line}
{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать команды"}
</Button>
</SheetFooter>
</>
)
})()}
</SheetContent>
</Sheet>
<CodeExportSheet
open={!!codePreviewTunnel}
onClose={() => setCodePreviewTunnel(null)}
title={codePreviewTunnel?.name ?? "GRE"}
description="Команды RouterOS 7.20+ для создания туннеля"
formats={[
{
id: "rsc",
label: "RouterOS",
filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`,
code: greExportCode,
},
]}
beforeCode={
codePreviewTunnel ? (
<div className="flex flex-wrap gap-3 text-xs shrink-0">
<span className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
{STATUS_MAP[codePreviewTunnel.status].label}
</span>
<span className="text-muted-foreground">·</span>
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
<span className="text-muted-foreground">·</span>
<span className="font-mono">
{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
{" → "}
{codePreviewTunnel.remoteAddress}
</span>
{codePreviewTunnel.ipsec ? (
<>
<span className="text-muted-foreground">·</span>
<span className="flex items-center gap-1 text-success">
<LockIcon className="size-3" />
IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}
</span>
</>
) : null}
</div>
) : null
}
/>
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
@@ -838,6 +845,6 @@ export default function GrePage() {
</SheetFooter>
</SheetContent>
</Sheet>
</div>
</>
)
}
+25 -11
View File
@@ -1,21 +1,35 @@
import type { CSSProperties, ReactNode } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { AuthGuard } from "@/components/auth-guard"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { CommandPalette } from "@/components/command-palette"
import { ReleaseNotesModal } from "@/components/release-notes-modal"
import { DataSourceProvider } from "@/lib/data-source"
import { EvoBGPProvider } from "@/lib/evobgp-context"
export default function MainLayout({ children }: { children: React.ReactNode }) {
const SKIP_TO_CONTENT_CLASS =
"bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2"
export default function MainLayout({ children }: { children: ReactNode }) {
return (
<DataSourceProvider>
<EvoBGPProvider>
<SidebarProvider>
<AppSidebar />
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
</EvoBGPProvider>
</DataSourceProvider>
<AuthGuard>
<DataSourceProvider>
<EvoBGPProvider>
<SidebarProvider
style={{ "--sidebar-width": "240px" } as CSSProperties}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content" className="h-svh overflow-hidden">
{children}
</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
</EvoBGPProvider>
</DataSourceProvider>
</AuthGuard>
)
}
+165 -145
View File
@@ -6,17 +6,21 @@ import { DataPageCard } from "@/components/data-page-card"
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { toast } from "sonner"
import {
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -706,7 +710,7 @@ function InterfacesTab({
}, [grouped, isLive])
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
const canOptimizeLive = isLive && filterServerId !== "all"
const canOptimizeLive = isLive && filterServerId !== ALL_SERVERS_ID
const uniqueLiveFallbackOpt = useMemo(() => {
const out: Record<string, number> = {}
const byRouter: Record<string, OspfItem[]> = {}
@@ -733,13 +737,14 @@ function InterfacesTab({
const ra = readStoredRouteOptimizerSettings()
setOptimizing(true)
try {
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pingWeight: ra.pingWeight }),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json() as BackendOspfOptimizeResponse
const data = await requestJson<BackendOspfOptimizeResponse>(
backendUrl,
`/api/servers/${filterServerId}/ospf/optimize`,
{
method: "POST",
body: JSON.stringify({ pingWeight: ra.pingWeight }),
},
)
const byKey: Record<string, number> = {}
data.interfaces.forEach((row) => {
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
@@ -809,7 +814,8 @@ function InterfacesTab({
{grouped.map(router => {
const totalIfaces = router.areas.reduce((s, a) => s + a.items.length, 0)
return (
<Card key={router.routerKey} className="overflow-hidden gap-0 py-0">
<Frame key={router.routerKey} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
<div className="flex items-center gap-3 px-4 py-3 border-b">
<NetworkIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
@@ -873,7 +879,8 @@ function InterfacesTab({
</div>
</div>
))}
</Card>
</FramePanel>
</Frame>
)
})}
</div>
@@ -917,20 +924,33 @@ function NeighborsTab({
return (
<div className="flex flex-col gap-5">
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Всего соседей", value: neighbors.length, color: "" },
{ label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" },
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums", s.color)}>{s.value}</p>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка соседей OSPF"
items={[
{
id: "neighbors",
label: "Всего соседей",
value: neighbors.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "full",
label: "Full",
value: fullCount,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "not-full",
label: "Не Full",
value: neighbors.length - fullCount,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground",
variant: neighbors.length - fullCount > 0 ? "warning" : "default",
},
]}
/>
{graphNodes.length > 0 && (
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
@@ -1040,21 +1060,41 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "Сессий BFD", value: sessions.length, color: "" },
{ label: "Up", value: upCount, color: "text-[var(--status-online-fg)]" },
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums", s.color)}>{s.value}</p>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка BFD"
items={[
{
id: "sessions",
label: "Сессий BFD",
value: sessions.length,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Up",
value: upCount,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "down",
label: "Down / Admin",
value: downCount,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground",
variant: downCount > 0 ? "destructive" : "default",
},
{
id: "init",
label: "Init / другие",
value: initCount,
icon: <LayersIcon className="size-4" />,
iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground",
variant: initCount > 0 ? "warning" : "default",
},
]}
/>
{sessions.length === 0 && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
@@ -1092,7 +1132,7 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
export default function OspfPage() {
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
const [filterServerId, setFilterServerId] = useState<string>("all")
const [filterServerId, setFilterServerId] = useState<string>(ALL_SERVERS_ID)
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
@@ -1118,8 +1158,7 @@ export default function OspfPage() {
if (cancelled) return
setLoading(true)
setLiveError(null)
fetch(`${backendUrl}/api/ospf/all`)
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
.then(data => {
if (cancelled) return
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
@@ -1215,9 +1254,26 @@ export default function OspfPage() {
}, [items, neighbors, bfdSessions])
// ── filtered display data ─────────────────────────────────────────────────────
const displayItems = filterServerId === "all" ? items : items.filter(i => i.routerKey === filterServerId)
const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const ospfRailItems = useMemo<ServerTileItem[]>(() => (
ospfServers.map((s) => {
const counts = serverCounts[s.id]
const host = s.label.replace(/^mt-/, "")
return {
id: s.id,
name: host,
host,
site: s.site,
country: s.country || undefined,
meta: counts
? `${counts.neighbors}n · ${counts.ifaces}i${counts.bfd > 0 ? ` · ${counts.bfd}b` : ""}`
: undefined,
}
})
), [ospfServers, serverCounts])
// Graph always shows full topology (highlight is handled by node click inside tab)
// KPIs reflect the current filter
@@ -1226,91 +1282,45 @@ export default function OspfPage() {
const totalAreas = new Set(displayItems.map(i => i.area)).size
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
actions={
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
}
/>
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
</button>
))}
</div>
</div>
{/* ── server filter chips (same pattern as Filters page) ─────────────── */}
{ospfServers.length > 0 && (
<div className="border-b bg-muted/20 px-6 py-2.5 flex items-center gap-2 flex-wrap shrink-0">
{/* "All" chip */}
<button
onClick={() => setFilterServerId("all")}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
filterServerId === "all"
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}>
Все серверы
<span className={cn(
"tabular-nums font-semibold",
filterServerId === "all" ? "" : "text-foreground/60",
)}>{items.length}</span>
</button>
<div className="w-px h-4 bg-border shrink-0" />
{ospfServers.map(s => {
const counts = serverCounts[s.id]
const active = filterServerId === s.id
return (
<button key={s.id} onClick={() => setFilterServerId(s.id)}
<ServerRailLayout
items={ospfRailItems}
selectedId={filterServerId}
onSelect={setFilterServerId}
showAll
allCount={ospfServers.length}
loading={isLive && loading && ospfServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
actions={
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
</>
}
/>
}
banner={
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{s.country ? <Flag code={s.country} size={12} /> : null}
{s.site && (
<span className={cn(
"inline-block px-1 py-0 rounded text-[9px] font-bold leading-4",
active
? "bg-white/20"
: "bg-muted-foreground/15 text-foreground/70",
)}>{s.site}</span>
)}
<span className="font-mono">{s.label.replace(/^mt-/, "")}</span>
{counts && (
<span className={cn(
"tabular-nums text-[10px]",
active ? "opacity-80" : "text-foreground/50",
)}>
{counts.neighbors}n · {counts.ifaces}i
{counts.bfd > 0 && ` · ${counts.bfd}b`}
</span>
)}
{t.icon}{t.label}
</button>
)
})}
))}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-6">
}
>
<div className="flex flex-col gap-5">
{/* data source banner */}
@@ -1343,21 +1353,32 @@ export default function OspfPage() {
</span>
)}
{/* KPI strip */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Роутеров", value: totalRouters },
{ label: "Интерфейсов", value: totalInterfaces },
{ label: "Зон (Area)", value: totalAreas },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums">{s.value}</p>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка OSPF"
items={[
{
id: "routers",
label: "Роутеров",
value: totalRouters,
icon: <RouterIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ifaces",
label: "Интерфейсов",
value: totalInterfaces,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "areas",
label: "Зон (Area)",
value: totalAreas,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{activeTab === "interfaces" && (
<InterfacesTab
@@ -1381,7 +1402,6 @@ export default function OspfPage() {
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
</div>
</div>
</div>
</ServerRailLayout>
)
}
+65 -41
View File
@@ -3,7 +3,8 @@
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import {
ProbesScheduleDataGrid,
@@ -16,6 +17,8 @@ import {
} from "@/components/data-grids/probes-speed-probes-data-grid"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail"
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import {
@@ -479,19 +482,25 @@ function ScheduleTab({
setRules,
serverOptions,
tunnelsForServer,
defaultSrc,
}: {
rules: SchedRule[]
setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
serverOptions: Server[]
tunnelsForServer: (serverId: string) => GreTunnel[]
defaultSrc?: string
}) {
const [showAdd, setShowAdd] = useState(false)
const [addSrc, setAddSrc] = useState(serverOptions[0]?.id ?? "srv1")
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
const [addTun, setAddTun] = useState("")
const [addType, setAddType] = useState<SchedType>("ping")
const [addMin, setAddMin] = useState(10)
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
useEffect(() => {
if (defaultSrc) setAddSrc(defaultSrc)
}, [defaultSrc])
useEffect(() => {
const list = tunnelsForServer(addSrc)
if (list.length && !list.some(t => t.id === addTun)) {
@@ -516,7 +525,8 @@ function ScheduleTab({
/>
</DataPageCard>
{showAdd ? (
<Card className="overflow-hidden">
<Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
<PlusIcon className="size-4 text-muted-foreground" />Новое правило
</div>
@@ -552,7 +562,8 @@ function ScheduleTab({
</Button>
<Button variant="outline" size="sm" onClick={() => setShowAdd(false)}>Отмена</Button>
</div>
</Card>
</FramePanel>
</Frame>
) : (
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
<PlusIcon className="size-4" />Добавить правило
@@ -627,6 +638,20 @@ export default function ProbesPage() {
return liveServers
}, [isLive, liveServers])
const probeRailItems = useMemo<ServerTileItem[]>(() => (
allServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
selectable: s.enabled,
}))
), [allServers])
useEffect(() => {
if (!isLive) {
setRosSrcV4(undefined)
@@ -884,24 +909,33 @@ export default function ProbesPage() {
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
actions={
running.length > 0
? <Button variant="outline" size="sm" onClick={() => {
liveProbeRunRef.current?.ctrl.abort()
setTests(p => p.map(t => (t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length }
: t)))
}}>
<SquareIcon className="size-4" />Остановить все
</Button>
: undefined
}
/>
<div className="flex-1 overflow-y-auto p-6">
<ServerRailLayout
items={probeRailItems}
selectedId={srcId}
onSelect={setSrcId}
showAll={false}
loading={isLive && liveLoad === "loading" && allServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
actions={
<>
<ServerRailMobileButton />
{running.length > 0
? <Button variant="outline" size="sm" onClick={() => {
liveProbeRunRef.current?.ctrl.abort()
setTests(p => p.map(t => (t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length }
: t)))
}}>
<SquareIcon className="size-4" />Остановить все
</Button>
: null}
</>
}
/>
}
>
<div className="flex flex-col gap-4">
{isLive && liveLoad === "error" && (
@@ -913,13 +947,12 @@ export default function ProbesPage() {
{isLive && (
<p className="text-xs text-muted-foreground">
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для «Источника» поле src-address только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS резолвер приложения, не MikroTik.
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для источника поле src-address только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS резолвер приложения, не MikroTik.
</p>
)}
{/* ── tool selector + config ── */}
<Card>
<CardContent className="pt-4 pb-4 px-4 flex flex-col gap-4">
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4">
{/* tool chips */}
<div className="flex gap-2 flex-wrap">
@@ -943,16 +976,6 @@ export default function ProbesPage() {
{/* main config row */}
<div className="flex flex-wrap items-end gap-3">
{/* source server */}
<div>
<OptionLabel>Источник</OptionLabel>
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
{allServers.filter(s => s.enabled).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</NativeSelect>
</div>
{/* target — all tools except bandwidth */}
{tool !== "bandwidth" && (
<div className="flex-1 min-w-[140px]">
@@ -1062,8 +1085,7 @@ export default function ProbesPage() {
<CopyIcon className="size-3" />
</button>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* ── tabs ── */}
<div>
@@ -1102,7 +1124,8 @@ export default function ProbesPage() {
{tests.map(test => {
const { Icon, color, label } = TOOL_META[test.tool]
return (
<Card key={test.id} className="overflow-hidden">
<Frame key={test.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* header */}
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20">
<div className="flex items-center gap-2 min-w-0">
@@ -1149,7 +1172,8 @@ export default function ProbesPage() {
<div className="p-3">
<TerminalOutput test={test} />
</div>
</Card>
</FramePanel>
</Frame>
)
})}
</div>
@@ -1166,13 +1190,13 @@ export default function ProbesPage() {
setRules={setRules}
serverOptions={allServers}
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
defaultSrc={srcId}
/>
)
)}
</div>
</div>
</div>
</div>
</div>
</ServerRailLayout>
)
}
+83 -84
View File
@@ -8,7 +8,8 @@ import {
inferCountry,
} from "@/components/data-grids/recursive-routes-data-grid"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Card } from "@/components/ui/card"
import { DataPageCard } from "@/components/data-page-card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
@@ -19,6 +20,8 @@ import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail"
interface BackendServer {
id: number
@@ -542,6 +545,18 @@ export default function RecursiveRoutesPage() {
}
const currentServer = servers.find(s => s.id === selectedServerId)
const rrRailItems = useMemo<ServerTileItem[]>(() => (
servers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
}))
), [servers])
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return rows
@@ -573,90 +588,74 @@ export default function RecursiveRoutesPage() {
}, [filteredRows])
return (
<div className="flex h-full flex-col">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
actions={
<>
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
{busy === "from" ? "Синхронизация..." : "Router => DB"}
</Button>
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
{busy === "to" ? "Применение..." : "DB => Router"}
</Button>
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
<SaveIcon className="size-4" />Сохранить в БД
</Button>
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
<PlusIcon className="size-4" />Добавить
</Button>
</>
}
/>
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего маршрутов</span>
<span className="font-semibold tabular-nums">{rows.length}</span>
</div>
<div className="w-px h-4 bg-border mx-1 shrink-0" />
{servers.map((s) => {
const count = s.id === selectedServerId ? rows.length : 0
const active = selectedServerId === s.id
return (
<button key={s.id}
onClick={() => setSelectedServerId(s.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
!s.enabled && !active && "opacity-40",
)}>
<StatusDot status={s.status} />
<Flag code={s.country} size={12} />
<span className="font-mono">{s.name}</span>
<TypeChip type={s.type} />
<span className={cn(
"tabular-nums font-semibold",
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
)}>{count}</span>
</button>
)
})}
</div>
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
<>
<ServerRailLayout
items={rrRailItems}
selectedId={selectedServerId}
onSelect={setSelectedServerId}
showAll={false}
showCount={false}
loading={isLive && !liveServerListReady}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
actions={
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
{busy === "from" ? "Синхронизация..." : "Router => DB"}
</Button>
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
{busy === "to" ? "Применение..." : "DB => Router"}
</Button>
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
<SaveIcon className="size-4" />Сохранить в БД
</Button>
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
<PlusIcon className="size-4" />Добавить
</Button>
</>
}
/>
}
banner={
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего маршрутов</span>
<span className="font-semibold tabular-nums">{rows.length}</span>
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p>
{opError && (
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{opError}
</div>
)}
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p>
{opError && (
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{opError}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-6">
}
>
{!isLive ? (
<Card className="p-6 text-sm text-muted-foreground">
Раздел работает в режиме &quot;Живые данные&quot;. Переключи источник данных в настройках.
</Card>
<Frame dense className="w-full">
<FramePanel className="p-6 text-sm text-muted-foreground">
Раздел работает в режиме &quot;Живые данные&quot;. Переключи источник данных в настройках.
</FramePanel>
</Frame>
) : (
<Card className="overflow-hidden py-0 gap-0">
<DataPageCard>
{currentServer && (
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
<Flag code={currentServer.country} size={16} />
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
@@ -683,13 +682,13 @@ export default function RecursiveRoutesPage() {
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))}
/>
<button onClick={openCreate}
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" />
Добавить маршрут
</button>
</Card>
</DataPageCard>
)}
</div>
</ServerRailLayout>
<RouteSheet
open={sheetOpen}
@@ -699,6 +698,6 @@ export default function RecursiveRoutesPage() {
onClose={() => setSheetOpen(false)}
gateways={gatewayOptions}
/>
</div>
</>
)
}
+18 -22
View File
@@ -1,6 +1,6 @@
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { OpsPanel } from "@/components/ops-panel"
import { buttonVariants } from "@/components/ui/button"
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
import {
@@ -52,12 +52,11 @@ export default function ReleasesPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-[960px] mx-auto space-y-6">
<Card>
<CardHeader>
<CardTitle>Текущая версия</CardTitle>
<CardDescription>Сборка MikrotikManager, опубликованная через CI/CD.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<OpsPanel
title="Текущая версия"
description="Сборка MikrotikManager, опубликованная через CI/CD."
contentClassName="flex flex-col gap-3 px-5 pb-5"
>
<div className="flex flex-wrap items-center gap-3">
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span>
@@ -65,23 +64,21 @@ export default function ReleasesPage() {
<p className="text-sm text-muted-foreground">
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
</p>
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader>
<CardTitle>Сводка коммитов</CardTitle>
<CardDescription>
{commits.length > 0
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."}
</CardDescription>
</CardHeader>
<CardContent>
<OpsPanel
title="Сводка коммитов"
description={
commits.length > 0
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."
}
contentClassName="px-5 pb-5"
>
{commits.length === 0 ? (
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p>
) : (
<ul className="space-y-3">
<ul className="flex flex-col gap-3">
{commits.map((commit) => (
<li key={commit.sha} className="rounded-md border px-3 py-2">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
@@ -94,8 +91,7 @@ export default function ReleasesPage() {
))}
</ul>
)}
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
</div>
+54 -74
View File
@@ -9,7 +9,9 @@ import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-
import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid"
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
import { FormToggle } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Flag } from "@/components/flag"
@@ -41,7 +43,7 @@ import {
RefreshCwIcon, AlertCircleIcon, ArrowRightIcon,
SettingsIcon, ChevronDownIcon, ChevronUpIcon, PinIcon,
ZapIcon, PlayIcon, CheckCircleIcon, NetworkIcon, WifiIcon,
MonitorIcon, ServerIcon, GitMergeIcon, ShieldIcon, LayersIcon,
MonitorIcon, ServerIcon,
InfoIcon,
} from "lucide-react"
@@ -290,7 +292,8 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
const switchCount = commRecs.filter(r => r.shouldSwitch && !pinned.has(`${home.id}::${r.community}`)).length
return (
<Card className="gap-0 py-0 overflow-hidden">
<Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
<MonitorIcon className="size-4 text-muted-foreground shrink-0" />
@@ -366,7 +369,8 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
onPin={onPin} onApply={onApply}
/>
)}
</Card>
</FramePanel>
</Frame>
)
}
@@ -585,32 +589,25 @@ export default function RouteOptimizerPage() {
label: "Home роутеров",
value: homeCount,
sub: `${wanCount} WAN-аплинков`,
icon: <MonitorIcon className="size-4 text-muted-foreground" />,
icon: <MonitorIcon className="size-4" />,
},
{
label: "JumpHost",
value: jh.length,
sub: jhSub,
icon: <ServerIcon className="size-4 text-violet-400" />,
icon: <ServerIcon className="size-4" />,
},
{
label: "Exit Node",
value: ex.length,
sub: exSub,
icon: <NetworkIcon className="size-4 text-emerald-500" />,
icon: <NetworkIcon className="size-4" />,
},
{
label: "Переключений",
value: totalSwitches,
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
icon: (
<ZapIcon
className={cn(
"size-4",
totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground",
)}
/>
),
icon: <ZapIcon className="size-4" />,
},
]
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
@@ -758,21 +755,24 @@ export default function RouteOptimizerPage() {
)}
</div>
{/* Stats chips */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{statsChips.map((s) => (
<Card key={s.label}>
<CardContent className="px-4 py-3 flex items-start justify-between">
<div>
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className="text-xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">{s.sub}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка оптимизатора"
items={statsChips.map((s, i) => ({
id: `ro-${i}`,
label: s.label,
value: s.value,
hint: s.sub,
icon: s.icon,
iconClassName: s.label === "Переключений" && totalSwitches > 0
? "text-warning"
: s.label === "Exit Node"
? "text-success"
: s.label === "JumpHost"
? "text-primary"
: "text-muted-foreground",
variant: s.label === "Переключений" && totalSwitches > 0 ? "warning" as const : "default" as const,
}))}
/>
{error && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
@@ -787,7 +787,8 @@ export default function RouteOptimizerPage() {
)}
{/* Settings */}
<Card className="gap-0 py-0">
<Frame dense className="w-full">
<FramePanel className="p-0">
<div
className={cn("flex items-center gap-3 px-4 py-3 cursor-pointer select-none", showSettings && "border-b")}
onClick={() => setShowSettings(v => !v)}
@@ -801,7 +802,7 @@ export default function RouteOptimizerPage() {
</div>
{showSettings && (
<CardContent className="py-4 px-5">
<div className="py-4 px-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="flex flex-col gap-3">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Пороги переключения</p>
@@ -866,20 +867,17 @@ export default function RouteOptimizerPage() {
<Link href="/settings#route-ai" className="text-primary underline-offset-2 hover:underline">Настройки Route AI</Link>
.
</p>
</CardContent>
</div>
)}
</Card>
</FramePanel>
</Frame>
{/* OSPF optimization from Route Optimizer */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-2.5">
<div className="flex items-center gap-2 text-sm">
<NetworkIcon className="size-4 text-sky-400 shrink-0" />
<span className="font-semibold">OSPF</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
Route AI weight: ping {settings.pingWeight}%
</span>
</div>
<OpsPanel
title="OSPF"
description={`Route AI weight: ping ${settings.pingWeight}%`}
contentClassName="px-5 py-4 flex flex-col gap-2.5"
>
{!useLiveData && (
<p className="text-xs text-muted-foreground">
@@ -960,20 +958,16 @@ export default function RouteOptimizerPage() {
)}
</>
)}
</CardContent>
</Card>
</OpsPanel>
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* ECMP Card */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center gap-2">
<GitMergeIcon className="size-4 text-violet-400" />
<span className="font-semibold text-sm">ECMP</span>
<span className="text-[10px] text-muted-foreground ml-1">Equal-Cost Multi-Path</span>
<div className="ml-auto">
<OpsPanel
title="ECMP"
description="Equal-Cost Multi-Path"
headerRight={
<button type="button" role="switch" aria-checked={ecmpEnabled}
onClick={() => setEcmpEnabled(v => !v)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
@@ -981,8 +975,9 @@ export default function RouteOptimizerPage() {
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
ecmpEnabled ? "translate-x-4" : "translate-x-0")} />
</button>
</div>
</div>
}
contentClassName="px-5 py-4 flex flex-col gap-4"
>
<div className={cn("flex flex-col gap-3 transition-opacity", !ecmpEnabled && "opacity-40 pointer-events-none")}>
<div className="flex flex-col gap-1.5">
@@ -1013,17 +1008,10 @@ export default function RouteOptimizerPage() {
<span>RouterOS 7.x: <code className="font-mono">/routing/rule add ecmp=yes</code></span>
</div>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* RPF Card */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center gap-2">
<ShieldIcon className="size-4 text-amber-400" />
<span className="font-semibold text-sm">RPF</span>
<span className="text-[10px] text-muted-foreground ml-1">Reverse Path Forwarding</span>
</div>
<OpsPanel title="RPF" description="Reverse Path Forwarding" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">Режим проверки источника</span>
@@ -1057,17 +1045,10 @@ export default function RouteOptimizerPage() {
<span><code className="font-mono">/ip settings set rp-filter={rpfMode}</code></span>
</div>
)}
</CardContent>
</Card>
</OpsPanel>
{/* VRF Card */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center gap-2">
<LayersIcon className="size-4 text-sky-400" />
<span className="font-semibold text-sm">VRF</span>
<span className="text-[10px] text-muted-foreground ml-1">Virtual Routing</span>
</div>
<OpsPanel title="VRF" description="Virtual Routing" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Контекст оптимизации маршрутов</span>
@@ -1099,8 +1080,7 @@ export default function RouteOptimizerPage() {
<InfoIcon className="size-3 shrink-0 mt-0.5" />
<span>Оптимизатор работает в VRF <code className="font-mono">{selectedVrf}</code></span>
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
+34 -20
View File
@@ -27,7 +27,7 @@ import {
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -490,25 +490,39 @@ export default function ServersPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4 text-muted-foreground" /> },
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4 text-emerald-500" /> },
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4 text-violet-400" /> },
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4 text-emerald-400" /> },
].map(s => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка серверов"
items={[
{
id: "all",
label: "Всего серверов",
value: counts.all,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "online",
label: "Онлайн",
value: counts.online,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "jh-en",
label: "JH + Exit Node",
value: counts["jump-host"] + counts["exit-node"],
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "home",
label: "Home Router",
value: counts["home-router"],
icon: <HomeIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
{/* Table */}
<DataPageCard>
File diff suppressed because it is too large Load Diff
+121 -119
View File
@@ -4,8 +4,22 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { servers as mockServers } from "@/lib/data"
import { Flag } from "@/components/flag"
import type { ServerStatus } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { ScrollArea } from "@/components/ui/scroll-area"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import {
type ServerTileItem,
} from "@/components/server-tile-rail"
import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
} from "lucide-react"
@@ -259,12 +273,14 @@ function Terminal({
if (isLive && server.backendId !== null) {
setExecuting(true)
try {
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: cmd }),
})
const data = await res.json() as { output?: string; error?: string }
const data = await requestJson<{ output?: string; error?: string }>(
backendUrl,
`/api/servers/${server.backendId}/exec`,
{
method: "POST",
body: JSON.stringify({ command: cmd }),
},
)
const text = data.output ?? data.error ?? "(empty response)"
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
text.split("\n").forEach(line =>
@@ -427,7 +443,7 @@ interface BackendServer {
}
export default function TerminalPage() {
const { mode, backendUrl } = useDataSource()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live"
// Server list state
@@ -437,14 +453,13 @@ export default function TerminalPage() {
// Load servers from backend when in live mode
useEffect(() => {
if (!isLive) return
if (!isLive || !prefsHydrated) return
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
setServersLoading(true)
fetch(`${backendUrl}/api/servers`)
.then(r => r.json() as Promise<BackendServer[]>)
.then(data => {
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
.then((data) => {
if (cancelled) return
setLiveServers(data.map(s => ({
uid: String(s.id),
@@ -462,7 +477,7 @@ export default function TerminalPage() {
.catch(() => { if (!cancelled) setServersLoading(false) })
})
return () => { cancelled = true }
}, [isLive, backendUrl, refreshKey])
}, [isLive, backendUrl, refreshKey, prefsHydrated])
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
@@ -490,6 +505,64 @@ export default function TerminalPage() {
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
const railItems = useMemo<ServerTileItem[]>(() => {
return termServers.map((s) => ({
id: s.uid,
name: s.name,
host: s.host,
country: s.country || undefined,
status: (s.status ?? undefined) as ServerStatus | undefined,
enabled: s.enabled,
selectable: s.enabled && s.status !== "offline",
title: [s.name, s.host].filter(Boolean).join(" · "),
}))
}, [termServers])
const handleSelectServer = useCallback((id: string) => {
setSelectedUid(id)
setRefreshKey((k) => k + 1)
}, [])
const railHeaderRight = isLive && !serversLoading
? <Badge variant="success-light" size="xs">LIVE</Badge>
: undefined
function QuickCmds() {
return (
<Frame dense spacing="sm" className="min-h-0 shrink-0">
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
<FrameHeader className="border-b px-3 py-2">
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Быстрые команды
</FrameTitle>
</FrameHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 p-1.5">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
type="button"
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</ScrollArea>
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
<p> история команд</p>
<p>Ctrl+L очистить экран</p>
{isLive
? <p className="text-info">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>}
</FrameFooter>
</FramePanel>
</Frame>
)
}
function injectCommand(cmd: string) {
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
if (!el) return
@@ -500,108 +573,39 @@ export default function TerminalPage() {
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={
<Button variant="outline" size="sm" onClick={() => {
setRefreshKey(k => k + 1)
if (isLive) setSelectedUid("")
}}>
<RefreshCwIcon className="size-4" />Переподключить
</Button>
}
/>
<div className="flex-1 overflow-hidden p-6">
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
{/* ── sidebar ── */}
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
{/* server picker */}
<div>
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
{isLive && serversLoading && (
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
)}
{isLive && !serversLoading && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
<span className="size-1 rounded-full bg-emerald-400" />LIVE
</span>
)}
</div>
{isLive && !serversLoading && liveServers.length === 0 && (
<p className="text-xs text-muted-foreground px-2.5">
Нет доступных серверов
</p>
)}
<div className="space-y-1">
{termServers.map(s => {
const isOffline = s.status === "offline"
const isSelected = s.uid === selectedUid
return (
<button
key={s.uid}
disabled={isOffline || !s.enabled}
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
className={cn(
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
"flex items-center gap-2",
isSelected
? "bg-primary text-primary-foreground"
: "hover:bg-muted",
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
)}
>
<span className={cn(
"inline-block size-1.5 rounded-full shrink-0",
s.status === "online" ? "bg-emerald-500" :
s.status === "degraded" ? "bg-amber-400" :
s.status === null ? "bg-sky-400" : "bg-red-500",
)} />
{s.country && <Flag code={s.country} />}
<span className="truncate font-mono">{s.name}</span>
</button>
)
})}
</div>
</div>
{/* quick commands */}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Быстрые команды
</p>
<div className="space-y-1">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</div>
{/* hints */}
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
<p> история команд</p>
<p>Ctrl+L очистить экран</p>
{isLive
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>
}
</div>
</div>
{/* ── terminal ── */}
<ServerRailLayout
items={railItems}
selectedId={selected?.uid ?? selectedUid}
onSelect={handleSelectServer}
showAll={false}
showCount={false}
showType={false}
headerRight={railHeaderRight}
loading={serversLoading}
extra={<QuickCmds />}
contentClassName="overflow-hidden p-3 md:p-4"
header={
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => {
setRefreshKey((k) => k + 1)
if (isLive) setSelectedUid("")
}}
>
<RefreshCwIcon className="size-4" />
Переподключить
</Button>
</>
}
/>
}
>
{selected ? (
<Terminal
key={termKey}
@@ -610,12 +614,10 @@ export default function TerminalPage() {
backendUrl={backendUrl}
/>
) : (
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
<div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
</div>
)}
</div>
</div>
</div>
</ServerRailLayout>
)
}
File diff suppressed because it is too large Load Diff
+120 -94
View File
@@ -3,7 +3,8 @@
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
@@ -11,6 +12,7 @@ import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { Sparkline } from "@/components/sparkline"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import {
UptimeResourcesDataGrid,
type UptimeResourceRow,
@@ -959,70 +961,78 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
</Alert>
)}
{/* ── KPI summary ───────────────────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{[
{ icon: <ServerIcon className="size-4 text-muted-foreground" />, label: String(rows.length), sub: "серверов всего", color: "text-foreground" },
{ icon: <CpuIcon className="size-4" />, label: `${avgCpu}%`, sub: "средний CPU", color: resPctColor(avgCpu) },
{ icon: <HardDriveIcon className="size-4" />, label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
].map(kpi => (
<Card key={kpi.sub}>
<CardContent className="px-4 py-3 flex items-start gap-3">
<div className={cn("mt-0.5 shrink-0", kpi.color)}>{kpi.icon}</div>
<div className="min-w-0">
<p className={cn("text-xl font-semibold tabular-nums leading-tight", kpi.color)}>{kpi.label}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{kpi.sub}</p>
</div>
</CardContent>
</Card>
))}
</div>
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
<div className="flex items-center gap-3 flex-wrap">
{/* Type filter */}
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5 shrink-0">
{typeOpts.map(o => (
<button key={o.value} onClick={() => setTypeFilter(o.value)}
className={cn(
"px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
typeFilter === o.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}>
{o.label}
</button>
))}
</div>
{/* Search */}
<div className="relative min-w-[180px] flex-1 max-w-xs">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Поиск по имени, площадке…"
value={resSearch} onChange={e => setResSearch(e.target.value)} />
{resSearch && (
<button onClick={() => setResSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{visible.length} из {rows.length} серверов
</span>
{/* Export CSV */}
<Button variant="outline" size="sm" className="h-8 gap-1.5 shrink-0" onClick={exportCsv}>
<DownloadIcon className="size-3.5" />CSV
</Button>
</div>
<KpiStatGrid
aria-label="Сводка ресурсов"
items={[
{
id: "servers",
label: "Серверов всего",
value: rows.length,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "cpu",
label: "Средний CPU",
value: `${avgCpu}%`,
icon: <CpuIcon className="size-4" />,
iconClassName: avgCpu >= 85 ? "text-destructive" : avgCpu >= 70 ? "text-warning" : "text-success",
variant: avgCpu >= 85 ? "destructive" : avgCpu >= 70 ? "warning" : "default",
},
{
id: "ram",
label: "Средний RAM",
value: `${avgRam}%`,
icon: <HardDriveIcon className="size-4" />,
iconClassName: avgRam >= 85 ? "text-destructive" : avgRam >= 70 ? "text-warning" : "text-success",
variant: avgRam >= 85 ? "destructive" : avgRam >= 70 ? "warning" : "default",
},
{
id: "high-cpu",
label: "CPU > 85%",
value: highCpu,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: highCpu > 0 ? "text-destructive" : "text-muted-foreground",
variant: highCpu > 0 ? "destructive" : "default",
},
{
id: "high-ram",
label: "RAM > 85%",
value: highRam,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: highRam > 0 ? "text-destructive" : "text-muted-foreground",
variant: highRam > 0 ? "destructive" : "default",
},
{
id: "high-hdd",
label: "Диск > 85%",
value: highHdd,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: highHdd > 0 ? "text-warning" : "text-muted-foreground",
variant: highHdd > 0 ? "warning" : "default",
},
]}
/>
{/* ── Table ─────────────────────────────────────────────────────────── */}
<DataPageCard>
<DataPageToolbar
segmented={{
value: typeFilter,
onChange: setTypeFilter,
options: typeOpts,
}}
search={resSearch}
onSearchChange={setResSearch}
searchPlaceholder="Поиск по имени, площадке…"
countLabel={`${visible.length} из ${rows.length} серверов`}
actions={
<Button variant="outline" size="sm" className="h-8 gap-1.5 shrink-0" onClick={exportCsv}>
<DownloadIcon className="size-3.5" />
CSV
</Button>
}
/>
<UptimeResourcesDataGrid rows={visible} />
</DataPageCard>
@@ -2041,28 +2051,42 @@ export default function UptimePage() {
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b bg-muted/10 shrink-0">
{[
{ label: "Speed-пробы", value: speedProbes.length, unit: "шт", color: "" },
{ label: "Тестов выполнено", value: doneRuns.length, unit: "run", color: "" },
{ label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" },
{ label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" },
].map(k => (
<Card key={k.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{k.label}</p>
<div className="flex items-baseline gap-1 mt-0.5">
<span className={cn("text-2xl font-semibold tabular-nums", k.color)}>{k.value}</span>
{k.unit && <span className="text-xs text-muted-foreground">{k.unit}</span>}
</div>
{runningCnt > 0 && k.label === "Тестов выполнено" && (
<p className="text-[11px] text-[var(--status-degraded-fg)] flex items-center gap-1 mt-0.5">
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
</p>
)}
</CardContent>
</Card>
))}
<div className="px-6 py-4 border-b bg-muted/10 shrink-0">
<KpiStatGrid
aria-label="Сводка speed-проб"
items={[
{
id: "probes",
label: "Speed-пробы",
value: `${speedProbes.length} шт`,
icon: <ArrowUpDownIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "runs",
label: "Тестов выполнено",
value: `${doneRuns.length} run`,
hint: runningCnt > 0 ? `${runningCnt} выполняется` : undefined,
icon: <PlayIcon className="size-4" />,
iconClassName: runningCnt > 0 ? "text-warning" : "text-muted-foreground",
variant: runningCnt > 0 ? "warning" : "default",
},
{
id: "max-tx",
label: "Макс TX",
value: maxTx != null ? `${maxTx} Мбит/с` : "—",
icon: <ArrowUpIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "max-rx",
label: "Макс RX",
value: maxRx != null ? `${maxRx} Мбит/с` : "—",
icon: <ArrowDownIcon className="size-4" />,
iconClassName: "text-success",
},
]}
/>
</div>
)
})()}
@@ -2099,7 +2123,8 @@ export default function UptimePage() {
{speedGrouped.map(({ server, probes: srvProbes }) => {
const isCollapsed = speedCollapsed.has(server.id)
return (
<Card key={server.id} className="overflow-hidden py-0 gap-0">
<Frame key={server.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* server header */}
<button
onClick={() => toggleSpeedCollapse(server.id)}
@@ -2267,21 +2292,20 @@ export default function UptimePage() {
})}
</div>
)}
</Card>
</FramePanel>
</Frame>
)
})}
{/* ── history ── */}
{speedRuns.length > 0 && (
<Card className="overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b">
<DataPageCard>
<div className="flex items-center justify-between px-5 py-3 border-b">
<p className="text-sm font-medium">История тестов</p>
<span className="text-xs text-muted-foreground">{speedRuns.length} запусков</span>
</div>
<div className="overflow-x-auto">
<UptimeSpeedHistoryDataGrid runs={speedRuns} servers={allServers} />
</div>
</Card>
<UptimeSpeedHistoryDataGrid runs={speedRuns} servers={allServers} />
</DataPageCard>
)}
</div>
@@ -2389,7 +2413,8 @@ export default function UptimePage() {
const warnCount = allServerProbes.filter(p => p.status === "warn").length
return (
<Card key={srv.id} className="overflow-hidden py-0 gap-0">
<Frame key={srv.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* server header */}
<button onClick={() => toggleCollapse(srv.id)}
@@ -2616,7 +2641,8 @@ export default function UptimePage() {
</div>
</>
)}
</Card>
</FramePanel>
</Frame>
)
})}
</div>
+297
View File
@@ -0,0 +1,297 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { UserSheet } from "@/components/users/user-sheet"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { servers as mockServers } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import {
ALL_SECTIONS,
INIT_USERS,
bindingDiffKey,
userInitials,
type AppUser,
type AppUserForm,
type UserServerOption,
} from "@/lib/users"
import { listServers } from "@/shared/api/servers"
import {
createAppUser,
createUserBinding,
deleteAppUser,
deleteUserBinding,
listAppUsers,
updateAppUser,
} from "@/shared/api/users"
import { ApiClientError } from "@/shared/api/http-client"
import { toast } from "sonner"
import {
CableIcon,
PlusIcon,
TrashIcon,
UserCheckIcon,
UsersIcon,
} from "lucide-react"
function toServerOptionsFromMock(): UserServerOption[] {
return mockServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
}))
}
export default function UsersPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [users, setUsers] = useState<AppUser[]>(INIT_USERS)
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [sheetOpen, setSheetOpen] = useState(false)
const [editUser, setEditUser] = useState<AppUser | null>(null)
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
const loadLive = useCallback(async () => {
setLoading(true)
try {
const [list, srvs] = await Promise.all([
listAppUsers(backendUrl),
listServers(backendUrl),
])
setUsers(list)
setServerOptions(srvs.map((s) => ({
id: String(s.id),
name: s.name || s.host,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
})))
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей")
} finally {
setLoading(false)
}
}, [backendUrl])
useEffect(() => {
if (!isLive) {
setUsers(INIT_USERS)
setServerOptions(toServerOptionsFromMock())
return
}
void loadLive()
}, [isLive, loadLive])
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
const activeCount = users.filter((u) => u.active).length
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
const nextKeys = new Set(next.map(bindingDiffKey))
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
for (const b of prev) {
if (!nextKeys.has(bindingDiffKey(b))) {
await deleteUserBinding(backendUrl, userId, b.id)
}
}
for (const b of next) {
if (!prevKeys.has(bindingDiffKey(b))) {
await createUserBinding(backendUrl, userId, {
serverId: Number(b.serverId),
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
peerPublicKey: b.peerPublicKey,
peerName: b.peerName,
comment: b.comment,
})
}
}
}
const handleSave = async (form: AppUserForm) => {
if (!isLive) {
if (!editUser) {
const id = `u${Date.now()}`
const created: AppUser = {
id,
...form,
last: "только что",
avatar: userInitials(form.name),
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
}
setUsers((prev) => [...prev, created])
} else {
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
}
setSheetOpen(false)
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
return
}
setSaving(true)
try {
if (!editUser) {
const created = await createAppUser(backendUrl, {
name: form.name,
login: form.login,
email: form.email,
role: form.role,
active: form.active,
avatar: userInitials(form.name),
sections: form.sections,
servers: form.servers,
})
await applyBindingsDiff(created.id, form.bindings, [])
await loadLive()
} else {
await updateAppUser(backendUrl, editUser.id, {
name: form.name,
login: form.login,
email: form.email,
role: form.role,
active: form.active,
avatar: userInitials(form.name),
sections: form.sections,
servers: form.servers,
})
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
await loadLive()
}
setSheetOpen(false)
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
} catch (err) {
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
toast.error(msg)
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteTarget) return
if (!isLive) {
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
setDeleteTarget(null)
toast.success("Пользователь удалён")
return
}
try {
await deleteAppUser(backendUrl, deleteTarget.id)
setDeleteTarget(null)
await loadLive()
toast.success("Пользователь удалён")
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось удалить")
}
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
actions={
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
<PlusIcon className="size-4" />Пригласить
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
<KpiStatGrid
aria-label="Сводка пользователей"
items={[
{
id: "all",
label: "Пользователи",
value: users.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "active",
label: "Активные",
value: activeCount,
icon: <UserCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ifaces",
label: "Привязанные ifaces",
value: boundCount,
icon: <CableIcon className="size-4" />,
iconClassName: "text-info",
},
]}
/>
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
<DataPageCard>
<UsersDataGrid
users={users}
serversCount={serverOptions.length}
allSectionsCount={ALL_SECTIONS.length}
isLoading={loading}
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
onDelete={setDeleteTarget}
/>
</DataPageCard>
</div>
<UserSheet
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
open={sheetOpen}
user={editUser}
users={users}
servers={serverOptions}
isLive={isLive}
backendUrl={backendUrl}
saving={saving}
onSave={(f) => { void handleSave(f) }}
onClose={() => setSheetOpen(false)}
/>
{deleteTarget && (
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(null) }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<TrashIcon />
</AlertDialogMedia>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
}
+53 -82
View File
@@ -4,20 +4,16 @@ import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
NetworkIcon, PlusIcon, CopyIcon, CheckIcon,
CodeXmlIcon, LayersIcon,
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
} from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ─── helpers ──────────────────────────────────────────────────────────────────
@@ -68,57 +64,23 @@ function generateVxlanRsc(t: VxlanTunnel): string {
function ExportSheet({ open, tunnel, onClose }: {
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
}) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт VXLAN</SheetTitle>
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<CodeExportSheet
open={open}
onClose={onClose}
title="Экспорт VXLAN"
description="RouterOS 7.x · /interface/vxlan + vteps"
formats={[
{
id: "rsc",
label: "MikroTik .rsc",
filename: `${tunnel?.name ?? "vxlan"}.rsc`,
code,
},
]}
/>
)
}
@@ -155,25 +117,39 @@ export default function VxlanPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
<KpiStatGrid
aria-label="Сводка VXLAN"
items={[
{
id: "tunnels",
label: "Туннелей",
value: vxlanTunnels.length,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных",
value: upCount,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "vni",
label: "Уникальных VNI",
value: vnis,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "servers",
label: "Серверов",
value: new Set(vxlanTunnels.map((t) => t.serverId)).size,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
@@ -203,11 +179,7 @@ export default function VxlanPage() {
</DataPageCard>
{/* Reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7 · /interface/vxlan быстрые команды
</p>
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
@@ -257,8 +229,7 @@ export default function VxlanPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
"use client"
export default function AccessDeniedPage() {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
<h1 className="text-lg font-semibold">Нет доступа</h1>
<p className="text-muted-foreground max-w-md text-sm">
У вашей учётной записи нет приложения MikrotikManager (`mm`) или
необходимых прав. Обратитесь к администратору auth-portal.
</p>
<a
href="/auth/callback"
className="text-primary text-sm underline-offset-4 hover:underline"
>
Войти снова
</a>
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import {
clearPortalHandoffFlag,
clearToken,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getToken,
parseHashToken,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
setToken,
} from "@/lib/auth"
export default function AuthCallbackPage() {
const router = useRouter()
const [message, setMessage] = useState("Перенаправление на Auth Portal…")
useEffect(() => {
let cancelled = false
void (async () => {
await ensureAuthConfig()
if (cancelled) return
const params = new URLSearchParams(window.location.search)
const error = params.get("error")
if (error === "sso_loop" || error === "jwt_rejected") {
redirectToPortalLoginInteractive()
return
}
const { accessToken } = parseHashToken(window.location.hash)
if (accessToken) {
setToken(accessToken)
clearPortalHandoffFlag()
const claims = getClaims()
if (!claims) {
clearToken()
redirectToPortalLoginInteractive()
return
}
if (!claims.apps.includes("mm")) {
setMessage("Нет доступа к приложению")
router.replace("/access-denied")
return
}
try {
const res = await fetch("/api/auth/config", {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (res.status === 401) {
clearToken()
redirectToPortalLoginInteractive()
return
}
} catch {
/* ignore network — proceed */
}
const next = firstAllowedPath()
if (next === "/access-denied") {
router.replace("/access-denied")
return
}
router.replace(next)
return
}
if (getToken() && getClaims()) {
clearPortalHandoffFlag()
if (!getClaims()!.apps.includes("mm")) {
router.replace("/access-denied")
return
}
router.replace(firstAllowedPath())
return
}
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
if (!ok) redirectToPortalLoginInteractive()
})()
return () => {
cancelled = true
}
}, [router])
return (
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
{message}
</div>
)
}
+7
View File
@@ -6,3 +6,10 @@ PORT=8000
# Allowed CORS origin (Next.js frontend)
CORS_ORIGIN=http://localhost:3000
# Portal SSO (false = open API for local/dev)
AUTH_REQUIRED=false
# Same HS256 secret as auth-portal JWT_SECRET when AUTH_REQUIRED=true
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
+19 -2
View File
@@ -5,10 +5,23 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Do not set NODE_ENV=production here — npm would omit typescript needed for the build stage.
COPY package.json package-lock.json ./
COPY packages/contracts/package.json packages/contracts/
COPY backend/package.json backend/
RUN npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --include-workspace-root --ignore-scripts \
# Drop root frontend deps (Next/React/UI) so backend image stays lean.
RUN node -e "\
const fs=require('fs');\
const p=JSON.parse(fs.readFileSync('package.json','utf8'));\
p.dependencies={};\
p.devDependencies={};\
delete p.scripts;\
p.workspaces=['packages/*','backend'];\
fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');\
"
# Prefer npm ci; if lockfile rejects stripped root package.json, fall back to install.
RUN (npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts \
|| npm install --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts) \
&& npm rebuild better-sqlite3
FROM deps AS build
@@ -18,7 +31,9 @@ COPY packages/contracts packages/contracts
COPY backend backend
RUN npm run build -w @mmapp/contracts \
&& npm run build -w mikrotik-manager-backend \
&& npm prune --omit=dev
&& npm prune --omit=dev \
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
&& mkdir -p backend/node_modules
FROM node:22-bookworm-slim AS runner
WORKDIR /app
@@ -32,6 +47,8 @@ COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/contracts ./packages/contracts
COPY --from=build /app/backend/dist ./backend/dist
COPY --from=build /app/backend/package.json ./backend/package.json
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
COPY --from=build /app/backend/node_modules ./backend/node_modules
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["node", "backend/dist/index.js"]
+10 -2
View File
@@ -10,10 +10,16 @@
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "1.0.0",
"acme-client": "^5.4.0",
@@ -21,7 +27,7 @@
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"pino-pretty": "^13.1.3",
"fastify-plugin": "^5.1.0",
"undici": "^8.1.0",
"zod": "^4.4.1"
},
@@ -29,6 +35,8 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.10",
"jose": "^6.2.11",
"pino-pretty": "^13.1.3",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
+46 -2
View File
@@ -3,17 +3,61 @@ import { z } from "zod"
config()
function boolEnv(v: string | undefined, fallback: boolean): boolean {
if (v === undefined || v === "") return fallback
return v === "1" || v.toLowerCase() === "true"
}
const isProd = process.env.NODE_ENV === "production"
const envSchema = z.object({
DATABASE_PATH: z.string().default("./mikrotik.db"),
PORT: z.coerce.number().int().positive().default(8000),
CORS_ORIGIN: z.string().default("http://localhost:3000"),
AUTH_REQUIRED: z.boolean().default(false),
AUTH_JWT_SECRET: z.string().default(""),
AUTH_ISSUER: z.string().default("https://auth.shnt.top"),
AUTH_PORTAL_URL: z.string().default("http://localhost:5175"),
})
const parsed = envSchema.safeParse(process.env)
const raw = {
DATABASE_PATH: process.env.DATABASE_PATH,
PORT: process.env.PORT,
CORS_ORIGIN: process.env.CORS_ORIGIN,
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
AUTH_JWT_SECRET:
process.env.AUTH_JWT_SECRET?.trim() ||
process.env.JWT_SECRET?.trim() ||
(isProd ? "" : "dev-secret-change-me"),
AUTH_ISSUER:
process.env.AUTH_ISSUER?.trim() ||
process.env.ISSUER?.trim() ||
"https://auth.shnt.top",
AUTH_PORTAL_URL: (
process.env.AUTH_PORTAL_URL ??
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL ??
"http://localhost:5175"
).replace(/\/$/, ""),
}
const parsed = envSchema.safeParse(raw)
if (!parsed.success) {
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const env = parsed.data
if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
console.error("❌ AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
process.exit(1)
}
export const env = {
DATABASE_PATH: parsed.data.DATABASE_PATH,
PORT: parsed.data.PORT,
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
authRequired: parsed.data.AUTH_REQUIRED,
jwtSecret: parsed.data.AUTH_JWT_SECRET || "dev-secret-change-me",
authIssuer: parsed.data.AUTH_ISSUER,
authPortalUrl: parsed.data.AUTH_PORTAL_URL,
}
+144
View File
@@ -106,6 +106,7 @@ CREATE TABLE IF NOT EXISTS traffic_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
peer_public_key TEXT NOT NULL DEFAULT '',
sampled_at TEXT NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
@@ -120,6 +121,47 @@ CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
ON traffic_samples(server_id, interface_name, sampled_at);
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
public_endpoint TEXT NOT NULL DEFAULT '',
host_public_key TEXT NOT NULL DEFAULT '',
host_private_key TEXT NOT NULL DEFAULT '',
hub_server_id INTEGER,
retention_hours INTEGER NOT NULL DEFAULT 24,
top_n INTEGER NOT NULL DEFAULT 200,
last_datagram_at TEXT,
last_exporter_ip TEXT,
last_error TEXT,
packets_received INTEGER NOT NULL DEFAULT 0,
peers_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS flow_buckets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
bucket_at TEXT NOT NULL,
src TEXT NOT NULL,
dst TEXT NOT NULL,
proto INTEGER NOT NULL DEFAULT 0,
src_port INTEGER NOT NULL DEFAULT 0,
dst_port INTEGER NOT NULL DEFAULT 0,
bytes INTEGER NOT NULL DEFAULT 0,
packets INTEGER NOT NULL DEFAULT 0,
in_iface TEXT NOT NULL DEFAULT '',
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
ON flow_buckets(server_id, bucket_at, src, dst, proto, src_port, dst_port);
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time
ON flow_buckets(server_id, bucket_at);
CREATE TABLE IF NOT EXISTS uptime_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
@@ -511,9 +553,102 @@ CREATE TABLE IF NOT EXISTS alert_engine_cursor (
last_source_finished_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS app_users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
login TEXT NOT NULL UNIQUE,
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer',
active INTEGER NOT NULL DEFAULT 1,
avatar TEXT NOT NULL DEFAULT '',
last_seen TEXT,
sections_json TEXT NOT NULL DEFAULT '[]',
servers_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_interface_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other',
peer_public_key TEXT NOT NULL DEFAULT '',
peer_name TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE (server_id, interface_name, peer_public_key)
);
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user
ON user_interface_bindings(user_id);
`)
// Lightweight schema evolution for existing databases without migrations
{
const sampleCols = sqlite.prepare(`PRAGMA table_info('traffic_samples')`).all() as Array<{ name?: string }>
if (!sampleCols.some((c) => c.name === "peer_public_key")) {
sqlite.exec(`ALTER TABLE traffic_samples ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
}
}
{
const bindCols = sqlite.prepare(`PRAGMA table_info('user_interface_bindings')`).all() as Array<{ name?: string }>
if (!bindCols.some((c) => c.name === "peer_public_key")) {
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
}
if (!bindCols.some((c) => c.name === "peer_name")) {
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_name TEXT NOT NULL DEFAULT ''`)
}
const indexes = sqlite.prepare(`PRAGMA index_list('user_interface_bindings')`).all() as Array<{
name?: string
unique?: number
}>
let hasPeerUnique = false
for (const idx of indexes) {
if (!idx.name || !idx.unique) continue
const info = sqlite.prepare(`PRAGMA index_info(${JSON.stringify(idx.name)})`).all() as Array<{ name?: string }>
const names = info.map((c) => c.name)
if (names.includes("server_id") && names.includes("interface_name") && names.includes("peer_public_key")) {
hasPeerUnique = true
}
}
if (!hasPeerUnique) {
sqlite.exec(`PRAGMA foreign_keys = OFF`)
sqlite.exec(`
CREATE TABLE user_interface_bindings_new (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other',
peer_public_key TEXT NOT NULL DEFAULT '',
peer_name TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE (server_id, interface_name, peer_public_key)
);
INSERT INTO user_interface_bindings_new
(id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name, comment, created_at, updated_at)
SELECT id, user_id, server_id, interface_name, interface_type,
COALESCE(peer_public_key, ''), COALESCE(peer_name, ''), comment, created_at, updated_at
FROM user_interface_bindings;
DROP TABLE user_interface_bindings;
ALTER TABLE user_interface_bindings_new RENAME TO user_interface_bindings;
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
`)
sqlite.exec(`PRAGMA foreign_keys = ON`)
}
}
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
if (!hasCountryColumn) {
@@ -580,6 +715,9 @@ if (!serverCols.some((c) => c.name === "lan_subnet")) {
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
}
if (!serverCols.some((c) => c.name === "mgmt_tunnel_ip")) {
sqlite.exec(`ALTER TABLE servers ADD COLUMN mgmt_tunnel_ip TEXT NOT NULL DEFAULT ''`)
}
const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }>
if (!alertTgCols.some((c) => c.name === "message_thread_id")) {
@@ -625,6 +763,12 @@ SELECT 1, 1, 30, 14
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO traffic_flow_settings (id, enabled, collector_ip, flow_listen_port, wg_listen_port, prefix)
SELECT 1, 0, '10.255.254.1', 4739, 51821, '10.255.254.0/24'
WHERE NOT EXISTS (SELECT 1 FROM traffic_flow_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 15, 14
+88
View File
@@ -4,6 +4,7 @@ import {
real,
sqliteTable,
text,
uniqueIndex,
} from "drizzle-orm/sqlite-core"
// ── servers ────────────────────────────────────────────────────────────────────
@@ -31,6 +32,8 @@ export const servers = sqliteTable("servers", {
lanSubnet: text("lan_subnet").notNull().default(""),
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
wanUplinks: text("wan_uplinks").notNull().default("[]"),
/** Адрес в оверлее wg-flow (экспортёр IPFIX), например 10.255.254.5 */
mgmtTunnelIp: text("mgmt_tunnel_ip").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
@@ -157,12 +160,55 @@ export const alertBgpPeerSamples = sqliteTable("alert_bgp_peer_samples", {
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
export const trafficFlowSettings = sqliteTable("traffic_flow_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
collectorIp: text("collector_ip").notNull().default("10.255.254.1"),
flowListenPort: integer("flow_listen_port").notNull().default(4739),
wgListenPort: integer("wg_listen_port").notNull().default(51821),
prefix: text("prefix").notNull().default("10.255.254.0/24"),
publicEndpoint: text("public_endpoint").notNull().default(""),
hostPublicKey: text("host_public_key").notNull().default(""),
hostPrivateKey: text("host_private_key").notNull().default(""),
hubServerId: integer("hub_server_id"),
retentionHours: integer("retention_hours").notNull().default(24),
topN: integer("top_n").notNull().default(200),
lastDatagramAt: text("last_datagram_at"),
lastExporterIp: text("last_exporter_ip"),
lastError: text("last_error"),
packetsReceived: integer("packets_received").notNull().default(0),
peersJson: text("peers_json").notNull().default("[]"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const flowBuckets = sqliteTable("flow_buckets", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
bucketAt: text("bucket_at").notNull(),
src: text("src").notNull(),
dst: text("dst").notNull(),
proto: integer("proto").notNull().default(0),
srcPort: integer("src_port").notNull().default(0),
dstPort: integer("dst_port").notNull().default(0),
bytes: integer("bytes").notNull().default(0),
packets: integer("packets").notNull().default(0),
inIface: text("in_iface").notNull().default(""),
}, (t) => [
uniqueIndex("idx_flow_buckets_unique").on(
t.serverId, t.bucketAt, t.src, t.dst, t.proto, t.srcPort, t.dstPort,
),
])
export const trafficSamples = sqliteTable("traffic_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
interfaceName: text("interface_name").notNull(),
peerPublicKey: text("peer_public_key").notNull().default(""),
sampledAt: text("sampled_at").notNull(),
rxBytes: integer("rx_bytes").notNull().default(0),
txBytes: integer("tx_bytes").notNull().default(0),
@@ -540,6 +586,44 @@ export const internetPathSettings = sqliteTable("internet_path_settings", {
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
// ── app users (local catalog, not portal JWT) ────────────────────────────────
export const appUsers = sqliteTable("app_users", {
id: text("id").primaryKey(),
name: text("name").notNull().default(""),
login: text("login").notNull().unique(),
email: text("email").notNull().default(""),
role: text("role", { enum: ["admin", "operator", "viewer"] }).notNull().default("viewer"),
active: integer("active", { mode: "boolean" }).notNull().default(true),
avatar: text("avatar").notNull().default(""),
lastSeen: text("last_seen"),
sectionsJson: text("sections_json").notNull().default("[]"),
serversJson: text("servers_json").notNull().default("[]"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const userInterfaceBindings = sqliteTable("user_interface_bindings", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => appUsers.id, { onDelete: "cascade" }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
interfaceName: text("interface_name").notNull(),
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] })
.notNull()
.default("other"),
peerPublicKey: text("peer_public_key").notNull().default(""),
peerName: text("peer_name").notNull().default(""),
comment: text("comment").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
}, (t) => [
uniqueIndex("idx_user_iface_bind_server_name_peer").on(t.serverId, t.interfaceName, t.peerPublicKey),
])
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
id: integer("id").primaryKey({ autoIncrement: true }),
sampledAt: text("sampled_at").notNull(),
@@ -555,6 +639,8 @@ export type SnapshotInsert = typeof serverSnapshots.$inferInsert
export type FilterRuleRow = typeof filterRules.$inferSelect
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
export type TrafficFlowSettingsRow = typeof trafficFlowSettings.$inferSelect
export type FlowBucketRow = typeof flowBuckets.$inferSelect
export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
@@ -582,3 +668,5 @@ export type AlertDestinationRow = typeof alertDestinations.$inferSelect
export type AlertHistoryRow = typeof alertHistory.$inferSelect
export type AlertOutboxRow = typeof alertOutbox.$inferSelect
export type AlertEngineCursorRow = typeof alertEngineCursor.$inferSelect
export type AppUserRow = typeof appUsers.$inferSelect
export type UserInterfaceBindingRow = typeof userInterfaceBindings.$inferSelect
+112 -62
View File
@@ -1,14 +1,16 @@
import Fastify from "fastify"
import Fastify, { type FastifyInstance } from "fastify"
import cors from "@fastify/cors"
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
import { env } from "./config.js"
import authPlugin, { requireAuth } from "./plugins/auth.js"
import serversRoutes from "./routes/servers.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
import trafficFlowRoutes from "./routes/traffic-flow.js"
import serversApiPingRoutes from "./routes/servers-api-ping.js"
import uptimeRoutes from "./routes/uptime.js"
import networkRoutes from "./routes/network.js"
@@ -22,73 +24,121 @@ import backupsRoutes from "./routes/backups.js"
import certificatesRoutes from "./routes/certificates.js"
import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js"
import firewallRoutes from "./routes/firewall.js"
import usersRoutes from "./routes/users.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
import { startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
// ── app factory ────────────────────────────────────────────────────────────────
export async function buildApp(opts?: {
logger?: boolean
startScheduler?: boolean
}): Promise<FastifyInstance> {
const usePrettyLogger =
opts?.logger !== false && process.env.NODE_ENV !== "production"
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger:
opts?.logger === false
? false
: usePrettyLogger
? {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
},
},
}
: true,
})
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger: {
transport: {
target: "pino-pretty",
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
},
},
})
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
// Use Zod for request validation and response serialization
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
await app.register(cors, {
origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
// CORS — allow Next.js frontend
await app.register(cors, {
origin: env.CORS_ORIGIN,
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
await app.register(authPlugin)
// ── routes ─────────────────────────────────────────────────────────────────────
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
app.get("/api/auth/config", async () => ({
required: env.authRequired,
portal_url: env.authPortalUrl,
issuer: env.authIssuer,
}))
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
if (env.authRequired) {
app.addHook("preHandler", async (request, reply) => {
const pathname = request.url.split("?")[0] ?? request.url
if (!pathname.startsWith("/api/")) return
if (pathname === "/api/auth/config") return
await requireAuth(request, reply)
if (reply.sent) return
})
}
refreshScheduler()
app.addHook("onClose", async () => {
stopScheduler()
})
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(trafficFlowRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
await app.register(wireguardRoutes, { prefix: "/api" })
await app.register(firewallRoutes, { prefix: "/api" })
await app.register(usersRoutes, { prefix: "/api" })
// ── start ──────────────────────────────────────────────────────────────────────
if (opts?.startScheduler !== false) {
refreshScheduler()
startTrafficFlowListener()
app.addHook("onClose", async () => {
stopScheduler()
stopTrafficFlowListener()
})
}
try {
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
app.log.error(err)
process.exit(1)
return app
}
const isMain =
process.argv[1] &&
(process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
if (isMain) {
try {
const app = await buildApp()
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
console.error(err)
process.exit(1)
}
}
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict"
import { hasPermission, permissionForRequest } from "./permissions.js"
assert.equal(hasPermission(["mm:servers:write"], "mm:servers:read"), true)
assert.equal(hasPermission(["mm:servers:admin"], "mm:servers:write"), true)
assert.equal(hasPermission(["mm:servers:read"], "mm:servers:write"), false)
assert.equal(
permissionForRequest("GET", "/api/servers"),
"mm:servers:read",
)
assert.equal(
permissionForRequest("POST", "/api/servers"),
"mm:servers:write",
)
assert.equal(
permissionForRequest("GET", "/api/system/database/backup"),
"mm:settings:admin",
)
assert.equal(
permissionForRequest("GET", "/api/traffic/servers/1/live"),
"mm:traffic:read",
)
assert.equal(
permissionForRequest("GET", "/api/unknown-thing"),
"mm:dashboard:read",
)
assert.equal(
permissionForRequest("GET", "/api/wireguard"),
"mm:network:read",
)
assert.equal(
permissionForRequest("POST", "/api/wireguard/interfaces"),
"mm:network:write",
)
assert.equal(
permissionForRequest("GET", "/api/firewall/all"),
"mm:network:read",
)
assert.equal(
permissionForRequest("GET", "/api/users"),
"mm:users:read",
)
assert.equal(
permissionForRequest("POST", "/api/users"),
"mm:users:write",
)
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:read"), true)
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:write"), true)
assert.equal(hasPermission(["mm:dashboard:read"], "mm:users:write"), false)
console.log("permissions.test.ts: ok")
+189
View File
@@ -0,0 +1,189 @@
/**
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
* Format: mm:<section>:<read|write|admin>
*/
export type AuthUser = {
id: string
email: string
name: string
apps: string[]
permissions: string[]
isAdmin?: boolean
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
if (required.startsWith("mm:users:") && granted.includes("mm:settings:admin")) {
return true
}
const parts = required.split(":")
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === "read") {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === "write") {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
type Rule = {
methods: string[]
match: (path: string) => boolean
permission: string
}
const RULES: Rule[] = [
{
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/system") ||
p.startsWith("/api/scheduler") ||
p.startsWith("/api/evobgp"),
permission: "mm:settings:admin",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/users"),
permission: "mm:users:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/users"),
permission: "mm:users:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
permission: "mm:dashboard:read",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/servers"),
permission: "mm:servers:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/servers"),
permission: "mm:servers:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/filters"),
permission: "mm:filters:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/filters"),
permission: "mm:filters:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/bgp"),
permission: "mm:bgp:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/bgp"),
permission: "mm:bgp:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/uptime"),
permission: "mm:uptime:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/uptime"),
permission: "mm:uptime:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/traffic"),
permission: "mm:traffic:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/traffic"),
permission: "mm:traffic:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/alerts"),
permission: "mm:alerts:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/alerts"),
permission: "mm:alerts:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/backups"),
permission: "mm:backups:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/backups"),
permission: "mm:backups:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/certificates"),
permission: "mm:certificates:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/certificates"),
permission: "mm:certificates:write",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/network") ||
p.startsWith("/api/ospf") ||
p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"),
permission: "mm:network:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/network") ||
p.startsWith("/api/ospf") ||
p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"),
permission: "mm:network:write",
},
]
/** Resolve required permission for method+path, or null if public / unknown. */
export function permissionForRequest(
method: string,
path: string,
): string | null {
const m = method.toUpperCase()
const pathname = path.split("?")[0] ?? path
for (const rule of RULES) {
if (!rule.methods.includes(m)) continue
if (rule.match(pathname)) return rule.permission
}
if (pathname.startsWith("/api/")) return "mm:dashboard:read"
return null
}
@@ -0,0 +1,90 @@
import assert from "node:assert/strict"
import Database from "better-sqlite3"
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
const sqlite = new Database(":memory:")
sqlite.pragma("foreign_keys = ON")
sqlite.exec(`
CREATE TABLE servers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '127.0.0.1'
);
CREATE TABLE app_users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
login TEXT NOT NULL UNIQUE,
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer',
active INTEGER NOT NULL DEFAULT 1,
avatar TEXT NOT NULL DEFAULT '',
last_seen TEXT,
sections_json TEXT NOT NULL DEFAULT '[]',
servers_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE user_interface_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other',
peer_public_key TEXT NOT NULL DEFAULT '',
peer_name TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE (server_id, interface_name, peer_public_key)
);
`)
sqlite.prepare("INSERT INTO servers (id, name, host) VALUES (1, 'jh', '10.0.0.1')").run()
sqlite.prepare("INSERT INTO app_users (id, name, login) VALUES ('u1', 'A', 'a.user')").run()
sqlite.prepare("INSERT INTO app_users (id, name, login) VALUES ('u2', 'B', 'b.user')").run()
sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b1', 'u1', 1, 'gre-office', 'gre')
`).run()
assert.throws(
() => sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b2', 'u2', 1, 'gre-office', 'gre')
`).run(),
/UNIQUE/i,
"один интерфейс на сервере — один пользователь",
)
sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
VALUES ('wg1', 'u1', 1, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
`).run()
sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
VALUES ('wg2', 'u2', 1, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
`).run()
assert.throws(
() => sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
VALUES ('wg3', 'u2', 1, 'wg-server', 'wg', 'peer-key-aaa')
`).run(),
/UNIQUE/i,
"один пир — один пользователь",
)
assert.throws(
() => normalizeBindingPeer("wg", ""),
(err: unknown) => err instanceof PeerBindError && err.status === 400,
"WG без ключа — 400",
)
assert.equal(normalizeBindingPeer("ether", "ignored"), "")
assert.equal(normalizeBindingPeer("wg", " abc "), "abc")
sqlite.prepare("DELETE FROM app_users WHERE id = 'u1'").run()
const leftover = sqlite.prepare("SELECT COUNT(*) AS n FROM user_interface_bindings").get() as { n: number }
assert.equal(leftover.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
console.log("users bindings unique+cascade tests ok")
@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import { mapRosInterfaceType, parseRawInterfaces, isUniqueConstraintError } from "./iface-type.js"
assert.equal(mapRosInterfaceType("ether"), "ether")
assert.equal(mapRosInterfaceType("ethernet"), "ether")
assert.equal(mapRosInterfaceType("GRE"), "gre")
assert.equal(mapRosInterfaceType("gre-tunnel"), "gre")
assert.equal(mapRosInterfaceType("gre6-tunnel"), "gre")
assert.equal(mapRosInterfaceType("wg"), "wg")
assert.equal(mapRosInterfaceType("wireguard"), "wg")
assert.equal(mapRosInterfaceType("vlan"), "other")
assert.equal(mapRosInterfaceType(""), "other")
assert.equal(mapRosInterfaceType("", "gre-tunnel1"), "gre")
assert.equal(mapRosInterfaceType("", "MSK-DC"), "other")
assert.equal(mapRosInterfaceType("gre-tunnel", "MSK-DC"), "gre")
assert.equal(mapRosInterfaceType("", "wg-msk-spb"), "wg")
assert.equal(mapRosInterfaceType("", "ether1"), "ether")
const parsed = parseRawInterfaces(JSON.stringify([
{ name: "ether1", type: "ether", running: "true", disabled: "false" },
{ name: "gre-office", type: "gre-tunnel", running: "false", disabled: "false" },
{ name: "wg-msk", type: "wg", running: true, disabled: false },
{ name: "MSK-DC", type: "gre-tunnel", running: true, disabled: false },
{ name: "", type: "ether" },
]))
assert.equal(parsed.length, 4)
assert.equal(parsed[0]?.type, "ether")
assert.equal(parsed[0]?.running, true)
assert.equal(parsed[1]?.type, "gre")
assert.equal(parsed[1]?.running, false)
assert.equal(parsed[2]?.type, "wg")
assert.equal(parsed[3]?.type, "gre")
assert.equal(parseRawInterfaces("not-json").length, 0)
assert.equal(parseRawInterfaces(null).length, 0)
assert.equal(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT_UNIQUE", message: "UNIQUE" }), true)
assert.equal(isUniqueConstraintError({ message: "UNIQUE constraint failed: t.c" }), true)
assert.equal(isUniqueConstraintError({ message: "other" }), false)
console.log("users iface-type tests ok")
+60
View File
@@ -0,0 +1,60 @@
export type InterfaceType = "ether" | "gre" | "wg" | "other"
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
const t = String(raw ?? "").trim().toLowerCase()
if (t === "ether" || t === "ethernet" || t.startsWith("ether")) return "ether"
// RouterOS /interface type for GRE is "gre-tunnel" (also gre, gre6, gre6-tunnel)
if (t === "gre" || t.startsWith("gre-") || t.startsWith("gre6")) return "gre"
if (t === "wg" || t === "wireguard") return "wg"
const n = String(name ?? "").trim().toLowerCase()
if (n.startsWith("gre") || n.includes("gre-tunnel")) return "gre"
if (n.startsWith("wg-") || n.startsWith("wireguard")) return "wg"
if (n.startsWith("ether") || n.startsWith("sfp")) return "ether"
return "other"
}
export interface ParsedRosIface {
name: string
type: InterfaceType
running: boolean
disabled: boolean
}
function asBool(raw: unknown): boolean {
if (typeof raw === "boolean") return raw
const s = String(raw ?? "").trim().toLowerCase()
return s === "true" || s === "yes" || s === "1"
}
export function parseRawInterfaces(json: string | null | undefined): ParsedRosIface[] {
if (!json) return []
try {
const parsed = JSON.parse(json) as unknown
const arr = Array.isArray(parsed) ? parsed : []
const out: ParsedRosIface[] = []
for (const item of arr) {
if (!item || typeof item !== "object") continue
const rec = item as Record<string, unknown>
const name = String(rec.name ?? "").trim()
if (!name) continue
out.push({
name,
type: mapRosInterfaceType(String(rec.type ?? ""), name),
running: asBool(rec.running),
disabled: asBool(rec.disabled),
})
}
return out
} catch {
return []
}
}
export function isUniqueConstraintError(err: unknown): boolean {
if (!err || typeof err !== "object") return false
const rec = err as { code?: unknown; message?: unknown }
const code = String(rec.code ?? "")
const msg = String(rec.message ?? "")
return code.includes("SQLITE_CONSTRAINT") || /unique constraint/i.test(msg)
}
+44
View File
@@ -0,0 +1,44 @@
import type { InterfaceType } from "./iface-type.js"
export class PeerBindError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message)
this.name = "PeerBindError"
}
}
export function truncPeerKey(key: string): string {
const k = key.trim()
if (k.length <= 20) return k
return `${k.slice(0, 8)}${k.slice(-8)}`
}
export function peerDisplayName(opts: {
publicKey: string
name?: string | null
comment?: string | null
}): string {
const name = (opts.name ?? "").trim()
if (name) return name
const comment = (opts.comment ?? "").trim()
if (comment) return comment
return truncPeerKey(opts.publicKey)
}
/** Ether/GRE — пустой ключ. WG — обязательный public-key. */
export function normalizeBindingPeer(
type: InterfaceType,
peerPublicKey: string | undefined,
): string {
const key = (peerPublicKey ?? "").trim()
if (type === "wg") {
if (!key) {
throw new PeerBindError("Для WireGuard укажите пир (public-key)", 400)
}
return key
}
return ""
}
@@ -0,0 +1,77 @@
import { and, eq } from "drizzle-orm"
import { db } from "../../../db/index.js"
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
export type AppUserRow = typeof appUsers.$inferSelect
export type BindingRow = typeof userInterfaceBindings.$inferSelect
export function listUserRows(): AppUserRow[] {
return db.select().from(appUsers).all()
}
export function getUserRowById(id: string): AppUserRow | undefined {
return db.select().from(appUsers).where(eq(appUsers.id, id)).limit(1).all()[0]
}
export function getUserRowByLogin(login: string): AppUserRow | undefined {
return db.select().from(appUsers).where(eq(appUsers.login, login)).limit(1).all()[0]
}
export function createUserRow(values: typeof appUsers.$inferInsert): AppUserRow {
const [inserted] = db.insert(appUsers).values(values).returning().all()
return inserted
}
export function updateUserRowById(
id: string,
values: Partial<AppUserRow>,
): AppUserRow {
const [updated] = db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning().all()
return updated
}
export function deleteUserRowById(id: string): void {
db.delete(appUsers).where(eq(appUsers.id, id)).run()
}
export function listBindingRows(): BindingRow[] {
return db.select().from(userInterfaceBindings).all()
}
export function listBindingRowsByUser(userId: string): BindingRow[] {
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
}
export function getBindingRowById(id: string): BindingRow | undefined {
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1).all()[0]
}
export function getBindingByServerIfacePeer(
serverId: number,
interfaceName: string,
peerPublicKey = "",
): BindingRow | undefined {
return db
.select()
.from(userInterfaceBindings)
.where(and(
eq(userInterfaceBindings.serverId, serverId),
eq(userInterfaceBindings.interfaceName, interfaceName),
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
))
.limit(1)
.all()[0]
}
export function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): BindingRow {
const [inserted] = db.insert(userInterfaceBindings).values(values).returning().all()
return inserted
}
export function deleteBindingRowById(id: string): void {
db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).run()
}
export function countUserRows(): number {
return db.select().from(appUsers).all().length
}
@@ -0,0 +1,343 @@
import { randomUUID } from "node:crypto"
import { desc, eq } from "drizzle-orm"
import type {
AppUserCreate,
AppUserRead,
AppUserUpdate,
CatalogInterface,
InterfaceType,
SectionPerm,
ServerPerm,
UserBinding,
UserBindingCreate,
} from "@mmapp/contracts/users"
import { db } from "../../../db/index.js"
import { servers, trafficSamples } from "../../../db/schema.js"
import {
createBindingRow,
createUserRow,
deleteBindingRowById,
deleteUserRowById,
getBindingByServerIfacePeer,
getBindingRowById,
getUserRowById,
getUserRowByLogin,
listBindingRows,
listBindingRowsByUser,
listUserRows,
updateUserRowById,
type AppUserRow,
type BindingRow,
} from "../repository/users-repository.js"
import { getLatestSnapshot } from "../../servers/repository/servers-repository.js"
import {
isUniqueConstraintError,
mapRosInterfaceType,
parseRawInterfaces,
} from "../iface-type.js"
import {
normalizeBindingPeer,
PeerBindError,
peerDisplayName,
} from "../peer-bind.js"
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
export class UsersServiceError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message)
this.name = "UsersServiceError"
}
}
function parseJsonArray<T>(raw: string, fallback: T[]): T[] {
try {
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? (parsed as T[]) : fallback
} catch {
return fallback
}
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
return parts.map((p) => p[0] ?? "").slice(0, 2).join("").toUpperCase() || "??"
}
function serverMeta(serverId: number): { name: string; site: string; country: string } {
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
return {
name: row?.name || row?.host || String(serverId),
site: row?.site || "—",
country: row?.country || "UN",
}
}
function toBindingDto(row: BindingRow): UserBinding {
const meta = serverMeta(row.serverId)
return {
id: row.id,
userId: row.userId,
serverId: row.serverId,
serverName: meta.name,
serverSite: meta.site,
serverCountry: meta.country,
interfaceName: row.interfaceName,
interfaceType: row.interfaceType,
peerPublicKey: row.peerPublicKey ?? "",
peerName: row.peerName ?? "",
comment: row.comment,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
function toUserDto(row: AppUserRow, bindings: BindingRow[]): AppUserRead {
return {
id: row.id,
name: row.name,
login: row.login,
email: row.email,
role: row.role,
active: Boolean(row.active),
avatar: row.avatar,
lastSeen: row.lastSeen ?? null,
sections: parseJsonArray<SectionPerm>(row.sectionsJson, []),
servers: parseJsonArray<ServerPerm>(row.serversJson, []),
bindings: bindings.map(toBindingDto),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
export function listUsers(): AppUserRead[] {
const users = listUserRows()
const allBindings = listBindingRows()
const byUser = new Map<string, BindingRow[]>()
for (const b of allBindings) {
const arr = byUser.get(b.userId) ?? []
arr.push(b)
byUser.set(b.userId, arr)
}
return users.map((u) => toUserDto(u, byUser.get(u.id) ?? []))
}
export function getUserById(id: string): AppUserRead | undefined {
const row = getUserRowById(id)
if (!row) return undefined
return toUserDto(row, listBindingRowsByUser(id))
}
export function createUser(input: AppUserCreate): AppUserRead {
const login = input.login.trim()
if (getUserRowByLogin(login)) {
throw new UsersServiceError("Логин уже занят", 409)
}
const now = new Date().toISOString()
const row = createUserRow({
id: randomUUID(),
name: input.name.trim(),
login,
email: input.email.trim(),
role: input.role ?? "viewer",
active: input.active ?? true,
avatar: (input.avatar ?? "").trim() || initials(input.name),
lastSeen: input.lastSeen ?? null,
sectionsJson: JSON.stringify(input.sections ?? []),
serversJson: JSON.stringify(input.servers ?? []),
createdAt: now,
updatedAt: now,
})
return toUserDto(row, [])
}
export function updateUser(id: string, input: AppUserUpdate): AppUserRead {
const existing = getUserRowById(id)
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
if (input.login != null) {
const other = getUserRowByLogin(input.login.trim())
if (other && other.id !== id) throw new UsersServiceError("Логин уже занят", 409)
}
const patch: Partial<AppUserRow> = { updatedAt: new Date().toISOString() }
if (input.name != null) patch.name = input.name.trim()
if (input.login != null) patch.login = input.login.trim()
if (input.email != null) patch.email = input.email.trim()
if (input.role != null) patch.role = input.role
if (input.active != null) patch.active = input.active
if (input.avatar != null) patch.avatar = input.avatar.trim() || existing.avatar
if (input.lastSeen !== undefined) patch.lastSeen = input.lastSeen
if (input.sections != null) patch.sectionsJson = JSON.stringify(input.sections)
if (input.servers != null) patch.serversJson = JSON.stringify(input.servers)
const updated = updateUserRowById(id, patch)
return toUserDto(updated, listBindingRowsByUser(id))
}
export function deleteUser(id: string): void {
const existing = getUserRowById(id)
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
deleteUserRowById(id)
}
export function addBinding(userId: string, input: UserBindingCreate): UserBinding {
const user = getUserRowById(userId)
if (!user) throw new UsersServiceError("Пользователь не найден", 404)
const server = db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1).all()[0]
if (!server) throw new UsersServiceError("Сервер не найден", 404)
const ifaceName = input.interfaceName.trim()
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
const type: InterfaceType = input.interfaceType ?? inferIfaceType(input.serverId, ifaceName)
let peerPublicKey = ""
try {
peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey)
} catch (err) {
if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status)
throw err
}
const peerName = type === "wg"
? peerDisplayName({
publicKey: peerPublicKey,
name: input.peerName,
})
: ""
const taken = getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
if (taken) {
throw new UsersServiceError(
type === "wg"
? "Этот пир уже привязан к другому пользователю"
: "Интерфейс уже привязан к другому пользователю",
409,
)
}
const now = new Date().toISOString()
try {
const row = createBindingRow({
id: randomUUID(),
userId,
serverId: input.serverId,
interfaceName: ifaceName,
interfaceType: type,
peerPublicKey,
peerName,
comment: (input.comment ?? "").trim(),
createdAt: now,
updatedAt: now,
})
return toBindingDto(row)
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new UsersServiceError(
type === "wg"
? "Этот пир уже привязан к другому пользователю"
: "Интерфейс уже привязан к другому пользователю",
409,
)
}
throw err
}
}
export function removeBinding(userId: string, bindingId: string): void {
const row = getBindingRowById(bindingId)
if (!row || row.userId !== userId) {
throw new UsersServiceError("Привязка не найдена", 404)
}
deleteBindingRowById(bindingId)
}
function inferIfaceType(serverId: number, ifaceName: string): InterfaceType {
const snap = getLatestSnapshot(serverId)
const parsed = parseRawInterfaces(snap?.rawInterfaces)
const found = parsed.find((i) => i.name === ifaceName)
return found?.type ?? "other"
}
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) throw new UsersServiceError("Сервер не найден", 404)
const snap = getLatestSnapshot(serverId)
let ifaces = parseRawInterfaces(snap?.rawInterfaces)
if (ifaces.length === 0) {
const last = db
.select({ sampledAt: trafficSamples.sampledAt })
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.orderBy(desc(trafficSamples.sampledAt))
.limit(1)
.all()[0]
if (last) {
const rows = db
.select({
interfaceName: trafficSamples.interfaceName,
peerPublicKey: trafficSamples.peerPublicKey,
running: trafficSamples.running,
disabled: trafficSamples.disabled,
})
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.all()
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
const seen = new Set<string>()
ifaces = []
for (const r of rows) {
if (seen.has(r.interfaceName)) continue
seen.add(r.interfaceName)
ifaces.push({
name: r.interfaceName,
type: mapRosInterfaceType("", r.interfaceName),
running: Boolean(r.running),
disabled: Boolean(r.disabled),
})
}
}
}
const bindings = listBindingRows().filter((b) => b.serverId === serverId)
const usersById = new Map(listUserRows().map((u) => [u.id, u]))
const hasWg = ifaces.some((i) => i.type === "wg")
const wgLive = hasWg
? await listWireGuardPeersForCatalog(serverId)
: { peers: [] as Awaited<ReturnType<typeof listWireGuardPeersForCatalog>>["peers"] }
const peersByIface = new Map<string, typeof wgLive.peers>()
for (const peer of wgLive.peers) {
const list = peersByIface.get(peer.interfaceName) ?? []
list.push(peer)
peersByIface.set(peer.interfaceName, list)
}
return ifaces.map((iface) => {
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
const base: CatalogInterface = {
name: iface.name,
type: iface.type,
running: iface.running,
disabled: iface.disabled,
boundUserId: ifaceBind?.userId ?? null,
boundUserLogin: owner?.login ?? null,
}
if (iface.type !== "wg") return base
const livePeers = peersByIface.get(iface.name) ?? []
return {
...base,
peersError: wgLive.error,
peers: livePeers.map((p) => {
const bind = bindings.find((b) => b.interfaceName === iface.name && b.peerPublicKey === p.publicKey)
const peerOwner = bind ? usersById.get(bind.userId) : undefined
return {
publicKey: p.publicKey,
name: peerDisplayName({ publicKey: p.publicKey, name: p.name, comment: p.comment }),
comment: p.comment,
allowedIps: p.allowedIps,
latestHandshake: p.latestHandshake,
boundUserId: bind?.userId ?? null,
boundUserLogin: peerOwner?.login ?? null,
}
}),
}
}).sort((a, b) => a.name.localeCompare(b.name))
}
export { parseRawInterfaces, mapRosInterfaceType }
+72
View File
@@ -0,0 +1,72 @@
/**
* Smoke: AUTH_REQUIRED gate via Fastify inject.
* Run: AUTH_REQUIRED=true AUTH_JWT_SECRET=test-secret-at-least-8 tsx src/plugins/auth.smoke.test.ts
*/
import assert from "node:assert/strict"
import { SignJWT } from "jose"
process.env.AUTH_REQUIRED = "true"
process.env.AUTH_JWT_SECRET = "test-secret-at-least-8"
process.env.AUTH_ISSUER = "https://auth.test.local"
process.env.AUTH_PORTAL_URL = "http://localhost:5175"
process.env.CORS_ORIGIN = "http://localhost:3000"
process.env.DATABASE_PATH = ":memory:"
process.env.NODE_ENV = "test"
// Dynamic import after env is set
const { buildApp } = await import("../index.js")
const secret = new TextEncoder().encode("test-secret-at-least-8")
async function mint(payload: Record<string, unknown>): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuer("https://auth.test.local")
.setExpirationTime("1h")
.sign(secret)
}
const app = await buildApp({ logger: false, startScheduler: false })
const health = await app.inject({ method: "GET", url: "/health" })
assert.equal(health.statusCode, 200)
const cfg = await app.inject({ method: "GET", url: "/api/auth/config" })
assert.equal(cfg.statusCode, 200)
assert.equal(cfg.json().required, true)
const noToken = await app.inject({ method: "GET", url: "/api/sidebar-counts" })
assert.equal(noToken.statusCode, 401)
const badApp = await mint({
sub: "u1",
email: "a@b.c",
name: "A",
apps: ["cdn"],
permissions: ["cdn:dashboard:read"],
})
const forbiddenApp = await app.inject({
method: "GET",
url: "/api/sidebar-counts",
headers: { authorization: `Bearer ${badApp}` },
})
assert.equal(forbiddenApp.statusCode, 403)
const okToken = await mint({
sub: "u1",
email: "a@b.c",
name: "A",
apps: ["mm"],
permissions: ["mm:dashboard:read"],
})
const ok = await app.inject({
method: "GET",
url: "/api/sidebar-counts",
headers: { authorization: `Bearer ${okToken}` },
})
// May be 200 or 500 if DB missing — must not be 401/403
assert.notEqual(ok.statusCode, 401)
assert.notEqual(ok.statusCode, 403)
await app.close()
console.log("auth.smoke.test.ts: ok")
+119
View File
@@ -0,0 +1,119 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
import fp from "fastify-plugin"
import { env } from "../config.js"
import {
hasPermission,
permissionForRequest,
type AuthUser,
} from "../lib/permissions.js"
declare module "fastify" {
interface FastifyRequest {
authUser?: AuthUser
}
}
declare module "@fastify/jwt" {
interface FastifyJWT {
payload: {
sub: string
email?: string
name?: string
apps?: string[]
permissions?: string[]
is_admin?: boolean
iss?: string
exp?: number
}
user: {
sub: string
email?: string
name?: string
apps?: string[]
permissions?: string[]
is_admin?: boolean
iss?: string
exp?: number
}
}
}
async function authPlugin(app: FastifyInstance) {
if (env.authRequired && env.jwtSecret.length < 8) {
throw new Error("AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
}
await app.register(import("@fastify/jwt"), {
secret: env.jwtSecret,
...(env.authRequired
? {
verify: {
allowedIss: [env.authIssuer],
},
}
: {}),
})
if (env.authRequired) {
app.log.info(
{ issuer: env.authIssuer, portal: env.authPortalUrl },
"AUTH_REQUIRED=true — portal JWT middleware enabled",
)
} else {
app.log.info("AUTH_REQUIRED=false — /api/* open without JWT")
}
}
/**
* Protect /api/* when AUTH_REQUIRED=true.
* Public: /health, /api/auth/config
*/
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
if (!env.authRequired) return
const pathname = (request.url.split("?")[0] ?? request.url)
if (pathname === "/api/auth/config") return
const authHeader = request.headers.authorization ?? ""
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""
if (!token) {
return reply.code(401).send({ error: "Unauthorized" })
}
try {
await request.jwtVerify()
} catch {
return reply.code(401).send({ error: "Unauthorized" })
}
const payload = request.user
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []
const permissions = Array.isArray(payload.permissions)
? payload.permissions.map(String)
: []
if (!apps.includes("mm")) {
return reply
.code(403)
.send({ error: "Нет доступа к приложению MikrotikManager" })
}
request.authUser = {
id: String(payload.sub),
email: String(payload.email ?? ""),
name: String(payload.name ?? ""),
apps,
permissions,
isAdmin: Boolean(payload.is_admin),
}
const required = permissionForRequest(request.method, pathname)
if (required && !hasPermission(permissions, required)) {
return reply.code(403).send({ error: `Недостаточно прав: ${required}` })
}
}
export default fp(authPlugin, { name: "auth" })
+19 -8
View File
@@ -37,6 +37,12 @@ function normalizeBaseUrl(raw: string): string {
}
}
/** Сырой API-ключ без префикса Bearer (иначе EvoBGP получит `Bearer Bearer …`). */
function normalizeApiKey(raw: string): string {
const trimmed = raw.trim()
return trimmed.replace(/^Bearer\s+/i, "").trim()
}
interface EvoCatalogRaw {
modules: { items: Array<{ id: string; name: string; type: string }> }
domains: {
@@ -158,7 +164,7 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
function credentialsFromDb(): { root: string; apiKey: string } | null {
const row = ensureEvobgpRow()
const root = normalizeBaseUrl(row.baseUrl)
const apiKey = row.apiKey.trim()
const apiKey = normalizeApiKey(row.apiKey)
if (!root || !apiKey) return null
return { root, apiKey }
}
@@ -168,8 +174,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false,
secretConfigured: Boolean(row.apiKey?.trim()),
enabled: Boolean(row.enabled),
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
})
})
@@ -183,10 +189,15 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
let nextEnabled = cur.enabled
let nextKey = cur.apiKey
if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim()
if (parsed.data.baseUrl !== undefined) {
nextBase = normalizeBaseUrl(parsed.data.baseUrl)
}
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
if (parsed.data.apiKey !== undefined) {
nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim()
nextKey =
parsed.data.apiKey === null || parsed.data.apiKey === ""
? ""
: normalizeApiKey(parsed.data.apiKey)
}
db.update(evobgpSettings)
@@ -202,8 +213,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false,
secretConfigured: Boolean(row.apiKey?.trim()),
enabled: Boolean(row.enabled),
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
})
})
@@ -223,7 +234,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const keyRaw =
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
const root = normalizeBaseUrl(urlRaw.trim())
const token = keyRaw.trim()
const token = normalizeApiKey(keyRaw)
if (!root || !token) {
return reply.status(400).send({
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",

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