11 Commits
Author SHA1 Message Date
Denozordec 8524a25373 chore(repo): pnpm-lock для cmdk, игнор локальных MCP-конфигов и планов сессий, шаблон .zcode
quality / changes (push) Successful in 8s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 40s
quality / web (push) Successful in 52s
quality / api (push) Successful in 57s
CD / quality (push) Successful in 2m44s
CD / publish (push) Successful in 3m2s
2026-09-25 02:10:10 +07:00
Denozordec 08c01b3cdd chore(reui): обновление agent skill до v d9f4a302f4, конфигурация MCP reui/shadcn 2026-09-25 02:09:58 +07:00
Denozordec 27c1ba7e23 fix(web): колонка выделения в таблице агентов, корень Command в CommandDialog, RU-метки KPI и дашборда, пустой f не пишется в URL
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / docker-check (push) Skipped
quality / openapi (push) Failing after 36s
quality / web (push) Failing after 36s
quality / api (push) Failing after 36s
CD / quality (push) Failing after 2m7s
CD / publish (push) Skipped
2026-09-25 02:06:29 +07:00
Denozordec 19af67f8a3 test(api): покрытие POST /agents/delete-bulk 2026-09-25 01:55:08 +07:00
Denozordec 8a8ea95da6 refactor(web): RHF+zod валидация форм — создание списка, правила, override, настройки (dirty-state, reveal токена), живой разбор вставок 2026-09-25 01:53:07 +07:00
Denozordec 65434857bb feat(web): URL-состояние вкладок/поиска/фильтров на страницах агентов, списков, правил и статистики 2026-09-25 01:44:32 +07:00
Denozordec 3d4edbad8b feat(web): командная палитра ⌘K — поиск агентов/списков/наборов и действия (ReUI c-command-7 + cmdk) 2026-09-25 01:35:29 +07:00
Denozordec 28f1f35f16 fix(web): целостность состояний, русификация обвязки, RBAC-гейтинг навигации, чистка мёртвого кода 2026-09-25 01:26:14 +07:00
Denozordec 0b426fa7ae feat(web): live-данные — polling, онлайн-индикаторы, относительное время, actionable-очередь ошибок apply, часовая агрегация статистики 2026-09-25 01:17:33 +07:00
Denozordec 98e4dcdefb feat(web): bulk-операции в таблице агентов и подтверждения опасных действий 2026-09-25 01:07:05 +07:00
Denozordec 0f7daf77e3 feat(api): массовое удаление агентов (POST /agents/delete-bulk) 2026-09-25 00:59:50 +07:00
67 changed files with 2554 additions and 997 deletions
+7 -6
View File
@@ -1,19 +1,20 @@
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
metadata:
user-invocable: "false"
--- ---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `d9f4a302f4`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
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. 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.
@@ -58,7 +59,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+12 -5
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project) ## Registry setup (one-time, per project)
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`: Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json ```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } } { "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -29,9 +29,16 @@ REUI_LICENSE_KEY=your-license-key
} }
``` ```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`). The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry - Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing ## Installing
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary ## Free vs premium boundary
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on. - Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate. - Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade). If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
+22 -2
View File
@@ -1,8 +1,8 @@
# ReUI components # ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the deeper reference. Do not assume you received all of it: a very large API (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries to fit your context, and every dropped heading is named in `sectionsOmitted`, so a trimmed capsule is a partial read - follow the response's own `next` hint to pull back the part you need instead of guessing at it. `validate_usage` always checks the FULL API, trimmed capsule or not. 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 (the flagship - read its API every time)
@@ -362,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon. **Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert ## alert
**Required:** `Alert` > `AlertTitle` **Required:** `Alert` > `AlertTitle`
+1 -1
View File
@@ -27,7 +27,7 @@ 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) 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. Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons: Finding icons:
+3 -3
View File
@@ -2,9 +2,9 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild. ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`. - **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`. - **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens. - **block** - a premium, full-page section that composes several components (`data-grid-base-2`, `settings-2`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md). - **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry ## The @reui registry
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium ## Free vs premium
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill. - **Free, no license key:** the 22 components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)). - **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index ## Component API index
+6 -4
View File
@@ -4,11 +4,11 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
## 1. Find (ReUI MCP `search` / `compose_page`) ## 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. **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); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. 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: For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`. - `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...). - `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`. - `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
@@ -16,7 +16,9 @@ Example: "build a users management page with filters" -> `search({ query: "users
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. 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. Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
**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. Where a result carries `thumbnail` (blocks and `c-*` examples), show the image itself as markdown linked to `previewUrl`, so the user sees the block before you install it.
## 2. Install (shadcn CLI) ## 2. Install (shadcn CLI)
@@ -32,7 +34,7 @@ The CLI reads `components.json`, installs the correct base+style variant, resolv
Before writing code against any component an item uses: 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. 1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **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. 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. 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.
+16 -10
View File
@@ -16,15 +16,15 @@ If you already know the exact item name, skip `search`. Everything else is situa
## The 5 task-specific tools (when to reach for each) ## 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. - **`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). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. 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. - **`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. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
- **`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. - **`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. - **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
- **`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. - **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale: it returns the current workflow inline plus the installer command, and its `version` can be compared with the version stamped at the top of your local `SKILL.md`.
## All 19 tools ## 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. `search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules ## Token + speed rules
@@ -41,18 +41,24 @@ If you already know the exact item name, skip `search`. Everything else is situa
- `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. - `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). - 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. - `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
- 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>`). - 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>`).
- Blocks and `c-*` examples carry `thumbnail`, an absolute URL to a hosted preview image (3:2; blocks 1080x720, examples 900x600, light theme). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components and icons have no thumbnail.
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
## Error playbook ## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`. - **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples. - **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`. - **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command. - **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
## Fallbacks ## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API). - No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp. - The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+7 -6
View File
@@ -1,19 +1,20 @@
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
metadata:
user-invocable: "false"
--- ---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `d9f4a302f4`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
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. 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.
@@ -58,7 +59,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+12 -5
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project) ## Registry setup (one-time, per project)
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`: Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json ```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } } { "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -29,9 +29,16 @@ REUI_LICENSE_KEY=your-license-key
} }
``` ```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`). The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry - Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing ## Installing
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary ## Free vs premium boundary
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on. - Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate. - Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade). If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
+22 -2
View File
@@ -1,8 +1,8 @@
# ReUI components # ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the deeper reference. Do not assume you received all of it: a very large API (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries to fit your context, and every dropped heading is named in `sectionsOmitted`, so a trimmed capsule is a partial read - follow the response's own `next` hint to pull back the part you need instead of guessing at it. `validate_usage` always checks the FULL API, trimmed capsule or not. 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 (the flagship - read its API every time)
@@ -362,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon. **Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert ## alert
**Required:** `Alert` > `AlertTitle` **Required:** `Alert` > `AlertTitle`
+1 -1
View File
@@ -27,7 +27,7 @@ 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) 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. Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons: Finding icons:
+3 -3
View File
@@ -2,9 +2,9 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild. ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`. - **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`. - **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens. - **block** - a premium, full-page section that composes several components (`data-grid-base-2`, `settings-2`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md). - **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry ## The @reui registry
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium ## Free vs premium
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill. - **Free, no license key:** the 22 components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)). - **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index ## Component API index
+6 -4
View File
@@ -4,11 +4,11 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
## 1. Find (ReUI MCP `search` / `compose_page`) ## 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. **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); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. 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: For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`. - `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...). - `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`. - `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
@@ -16,7 +16,9 @@ Example: "build a users management page with filters" -> `search({ query: "users
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. 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. Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
**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. Where a result carries `thumbnail` (blocks and `c-*` examples), show the image itself as markdown linked to `previewUrl`, so the user sees the block before you install it.
## 2. Install (shadcn CLI) ## 2. Install (shadcn CLI)
@@ -32,7 +34,7 @@ The CLI reads `components.json`, installs the correct base+style variant, resolv
Before writing code against any component an item uses: 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. 1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **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. 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. 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.
+16 -10
View File
@@ -16,15 +16,15 @@ If you already know the exact item name, skip `search`. Everything else is situa
## The 5 task-specific tools (when to reach for each) ## 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. - **`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). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. 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. - **`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. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
- **`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. - **`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. - **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
- **`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. - **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale: it returns the current workflow inline plus the installer command, and its `version` can be compared with the version stamped at the top of your local `SKILL.md`.
## All 19 tools ## 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. `search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules ## Token + speed rules
@@ -41,18 +41,24 @@ If you already know the exact item name, skip `search`. Everything else is situa
- `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. - `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). - 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. - `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
- 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>`). - 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>`).
- Blocks and `c-*` examples carry `thumbnail`, an absolute URL to a hosted preview image (3:2; blocks 1080x720, examples 900x600, light theme). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components and icons have no thumbnail.
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
## Error playbook ## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`. - **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples. - **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`. - **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command. - **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
## Fallbacks ## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API). - No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp. - The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+1 -1
View File
@@ -31,7 +31,7 @@ alwaysApply: true
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны. **Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
**Registry (актуально):** 20 free components — `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.claude/skills/reui` (v `668fb463eb`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта. **Registry (актуально):** 22 free components — `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.agents/skills/reui` (v `d9f4a302f4`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
**Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции. **Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции.
+4 -10
View File
@@ -4,22 +4,16 @@ globs: ["**/*.tsx","**/*.ts"]
alwaysApply: false 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 skill version `d9f4a302f4`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
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. 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.
@@ -64,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+7 -6
View File
@@ -1,19 +1,20 @@
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
metadata:
user-invocable: "false"
--- ---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `d9f4a302f4`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
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. 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.
@@ -58,7 +59,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+12 -5
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project) ## Registry setup (one-time, per project)
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`: Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json ```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } } { "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -29,9 +29,16 @@ REUI_LICENSE_KEY=your-license-key
} }
``` ```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`). The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry - Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing ## Installing
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary ## Free vs premium boundary
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on. - Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate. - Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade). If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
+22 -2
View File
@@ -1,8 +1,8 @@
# ReUI components # ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the deeper reference. Do not assume you received all of it: a very large API (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries to fit your context, and every dropped heading is named in `sectionsOmitted`, so a trimmed capsule is a partial read - follow the response's own `next` hint to pull back the part you need instead of guessing at it. `validate_usage` always checks the FULL API, trimmed capsule or not. 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 (the flagship - read its API every time)
@@ -362,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon. **Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert ## alert
**Required:** `Alert` > `AlertTitle` **Required:** `Alert` > `AlertTitle`
+1 -1
View File
@@ -27,7 +27,7 @@ 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) 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. Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons: Finding icons:
+3 -3
View File
@@ -2,9 +2,9 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild. ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`. - **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`. - **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens. - **block** - a premium, full-page section that composes several components (`data-grid-base-2`, `settings-2`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md). - **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry ## The @reui registry
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium ## Free vs premium
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill. - **Free, no license key:** the 22 components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)). - **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index ## Component API index
+6 -4
View File
@@ -4,11 +4,11 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
## 1. Find (ReUI MCP `search` / `compose_page`) ## 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. **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); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. 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: For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`. - `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...). - `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`. - `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
@@ -16,7 +16,9 @@ Example: "build a users management page with filters" -> `search({ query: "users
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. 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. Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
**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. Where a result carries `thumbnail` (blocks and `c-*` examples), show the image itself as markdown linked to `previewUrl`, so the user sees the block before you install it.
## 2. Install (shadcn CLI) ## 2. Install (shadcn CLI)
@@ -32,7 +34,7 @@ The CLI reads `components.json`, installs the correct base+style variant, resolv
Before writing code against any component an item uses: 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. 1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **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. 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. 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.
+16 -10
View File
@@ -16,15 +16,15 @@ If you already know the exact item name, skip `search`. Everything else is situa
## The 5 task-specific tools (when to reach for each) ## 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. - **`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). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. 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. - **`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. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
- **`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. - **`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. - **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
- **`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. - **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale: it returns the current workflow inline plus the installer command, and its `version` can be compared with the version stamped at the top of your local `SKILL.md`.
## All 19 tools ## 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. `search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules ## Token + speed rules
@@ -41,18 +41,24 @@ If you already know the exact item name, skip `search`. Everything else is situa
- `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. - `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). - 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. - `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
- 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>`). - 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>`).
- Blocks and `c-*` examples carry `thumbnail`, an absolute URL to a hosted preview image (3:2; blocks 1080x720, examples 900x600, light theme). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components and icons have no thumbnail.
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
## Error playbook ## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`. - **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples. - **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`. - **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command. - **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
## Fallbacks ## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API). - No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp. - The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+3
View File
@@ -37,5 +37,8 @@ deploy/docker/docker-bake.override.hcl
*.tsbuildinfo *.tsbuildinfo
# Local MCP configs (may contain REUI license Bearer) # Local MCP configs (may contain REUI license Bearer)
.cursor/mcp.json .cursor/mcp.json
.zcode/config.json
.zcode/plans/
.mcp.json .mcp.json
.codegraph/daemon.pid .codegraph/daemon.pid
.claude/settings.local.json
+7 -6
View File
@@ -1,19 +1,20 @@
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
metadata:
user-invocable: "false"
--- ---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `d9f4a302f4`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
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. 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.
@@ -58,7 +59,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+12 -5
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project) ## Registry setup (one-time, per project)
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`: Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json ```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } } { "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -29,9 +29,16 @@ REUI_LICENSE_KEY=your-license-key
} }
``` ```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`). The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry - Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing ## Installing
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary ## Free vs premium boundary
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on. - Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate. - Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade). If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
+22 -2
View File
@@ -1,8 +1,8 @@
# ReUI components # ReUI components
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the deeper reference. Do not assume you received all of it: a very large API (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries to fit your context, and every dropped heading is named in `sectionsOmitted`, so a trimmed capsule is a partial read - follow the response's own `next` hint to pull back the part you need instead of guessing at it. `validate_usage` always checks the FULL API, trimmed capsule or not. 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 (the flagship - read its API every time)
@@ -362,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon. **Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert ## alert
**Required:** `Alert` > `AlertTitle` **Required:** `Alert` > `AlertTitle`
+1 -1
View File
@@ -27,7 +27,7 @@ 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) 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. Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons: Finding icons:
+3 -3
View File
@@ -2,9 +2,9 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild. ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`. - **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`. - **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens. - **block** - a premium, full-page section that composes several components (`data-grid-base-2`, `settings-2`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md). - **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry ## The @reui registry
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium ## Free vs premium
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill. - **Free, no license key:** the 22 components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)). - **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index ## Component API index
+6 -4
View File
@@ -4,11 +4,11 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
## 1. Find (ReUI MCP `search` / `compose_page`) ## 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. **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); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. 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: For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`. - `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...). - `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`. - `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
@@ -16,7 +16,9 @@ Example: "build a users management page with filters" -> `search({ query: "users
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. 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. Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
**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. Where a result carries `thumbnail` (blocks and `c-*` examples), show the image itself as markdown linked to `previewUrl`, so the user sees the block before you install it.
## 2. Install (shadcn CLI) ## 2. Install (shadcn CLI)
@@ -32,7 +34,7 @@ The CLI reads `components.json`, installs the correct base+style variant, resolv
Before writing code against any component an item uses: 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. 1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **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. 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. 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.
+16 -10
View File
@@ -16,15 +16,15 @@ If you already know the exact item name, skip `search`. Everything else is situa
## The 5 task-specific tools (when to reach for each) ## 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. - **`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). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. 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. - **`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. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
- **`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. - **`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. - **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
- **`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. - **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale: it returns the current workflow inline plus the installer command, and its `version` can be compared with the version stamped at the top of your local `SKILL.md`.
## All 19 tools ## 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. `search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules ## Token + speed rules
@@ -41,18 +41,24 @@ If you already know the exact item name, skip `search`. Everything else is situa
- `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. - `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). - 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. - `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
- 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>`). - 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>`).
- Blocks and `c-*` examples carry `thumbnail`, an absolute URL to a hosted preview image (3:2; blocks 1080x720, examples 900x600, light theme). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components and icons have no thumbnail.
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
## Error playbook ## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`. - **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples. - **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`. - **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command. - **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
## Fallbacks ## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API). - No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp. - The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+19
View File
@@ -0,0 +1,19 @@
{
"mcp": {
"servers": {
"reui": {
"type": "http",
"url": "https://mcp.reui.io/api/mcp",
"headers": {
"Authorization": "Bearer <REUI_LICENSE_KEY>"
}
},
"shadcn": {
"type": "stdio",
"command": "cmd",
"args": ["/c", "npx", "-y", "shadcn@latest", "mcp"],
"cwd": "<abs-path>/apps/web"
}
}
}
}
+2 -2
View File
@@ -25,10 +25,10 @@ pnpm --filter @evofw/web build
## ReUI PRO ## ReUI PRO
Surface: **frame**. Hierarchy: **ReUI PRO > shadcn**. Contract: `docs/ui-design-contract.md`. Surface: **frame**. Hierarchy: **ReUI PRO > shadcn**. Contract: `docs/ui-design-contract.md`.
MCP `user-reui` primary + `plugin-shadcn-shadcn` primitives. CLI из `apps/web`. MCP `reui` (PRO, Bearer `REUI_LICENSE_KEY`) primary + `shadcn` primitives — локально: `.zcode/config.json` (ZCode, шаблон `.zcode/config.json.example`) / `.cursor/mcp.json` (Cursor). CLI из `apps/web`.
KPI/QuickActions: EvoBGP hybrid SoT (`KpiStatGrid`, `QuickActionGrid`). KPI/QuickActions: EvoBGP hybrid SoT (`KpiStatGrid`, `QuickActionGrid`).
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). Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills).
Skill: `.claude/skills/reui` (`668fb463eb`). Skill: `.agents/skills/reui` (v `d9f4a302f4`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
## Docs ## Docs
+24
View File
@@ -212,6 +212,30 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
return { ok: true } return { ok: true }
}) })
app.post('/agents/delete-bulk', async (req) => {
const body = agentIdsBodySchema.parse(req.body)
const deleted: { id: string; name: string }[] = []
app.sqlite.transaction(() => {
for (const id of body.agent_ids) {
const a = repos.getAgent(app.db, id)
if (!a) continue
repos.deleteAgent(app.db, id)
deleted.push({ id: a.id, name: a.name })
}
})()
if (deleted.length > 0) {
auditMutation(app, config, req, {
action: 'agent.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: deleted[0]!.id,
summary: `Массовое удаление агентов: ${deleted.length}`,
details: { agent_ids: deleted.map((d) => d.id) },
})
}
return { items: deleted.map((d) => d.id), deleted: deleted.length }
})
app.post<{ Params: { id: string; sourceId: string } }>( app.post<{ Params: { id: string; sourceId: string } }>(
'/agents/:id/clone-from/:sourceId', '/agents/:id/clone-from/:sourceId',
async (req) => { async (req) => {
+28
View File
@@ -152,4 +152,32 @@ describe('bulk agent operations', () => {
}) })
expect(bad.statusCode).toBe(404) expect(bad.statusCode).toBe(404)
}) })
it('delete-bulk removes agents in one request and skips unknown ids', async () => {
const app = await appPromise
await app.ready()
const a1 = await createInvitedAgent(app, 'bulk-del-01')
const a2 = await createInvitedAgent(app, 'bulk-del-02')
const a3 = await createInvitedAgent(app, 'bulk-del-03')
const bulk = await app.inject({
method: 'POST',
url: '/api/v1/agents/delete-bulk',
payload: { agent_ids: [a1, a2, 'no-such-agent'] },
})
expect(bulk.statusCode).toBe(200)
const body = bulk.json() as { items: string[]; deleted: number }
expect(body.deleted).toBe(2)
expect(body.items.sort()).toEqual([a1, a2].sort())
for (const id of [a1, a2]) {
const res = await app.inject({ method: 'GET', url: `/api/v1/agents/${id}` })
expect(res.statusCode).toBe(404)
}
// a3 не упомянут в запросе — остаётся на месте
const kept = await app.inject({ method: 'GET', url: `/api/v1/agents/${a3}` })
expect(kept.statusCode).toBe(200)
})
}) })
+2
View File
@@ -26,6 +26,8 @@
"@tanstack/react-virtual": "^3.14.7", "@tanstack/react-virtual": "^3.14.7",
"@tanstack/router-plugin": "^1.120.0", "@tanstack/router-plugin": "^1.120.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"cn": "^0.4.0",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
+48 -11
View File
@@ -1,9 +1,14 @@
import type { Agent } from '@evofw/shared' import type { Agent } from '@evofw/shared'
import { Trash2 } from 'lucide-react' import {
MoreVerticalIcon,
PanelRight,
Trash2,
} from 'lucide-react'
import { import {
AgentPlatformIcon, AgentPlatformIcon,
platformLabel, platformLabel,
} from '@/components/agents/agent-platform-icon' } from '@/components/agents/agent-platform-icon'
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
import { import {
agentHasTrafficSample, agentHasTrafficSample,
agentTrafficAccepted, agentTrafficAccepted,
@@ -13,6 +18,13 @@ import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Frame, FramePanel } from '@/components/reui/frame' import { Frame, FramePanel } from '@/components/reui/frame'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evofw/ui/components/dropdown-menu'
import { import {
Item, Item,
ItemContent, ItemContent,
@@ -21,7 +33,7 @@ import {
} from '@evofw/ui/components/item' } from '@evofw/ui/components/item'
import { Separator } from '@evofw/ui/components/separator' import { Separator } from '@evofw/ui/components/separator'
import { cn } from '@evofw/ui/lib/utils' import { cn } from '@evofw/ui/lib/utils'
import { formatPackets, formatShortDateTime } from '@/lib/format' import { formatPackets, formatRelativeTime } from '@/lib/format'
/** /**
* Agent catalog card — hybrid card-3 header + stats strip + stats-12 values. * Agent catalog card — hybrid card-3 header + stats strip + stats-12 values.
@@ -48,20 +60,22 @@ export function AgentCard({
dropped === '—' && accepted === '—' dropped === '—' && accepted === '—'
? '—' ? '—'
: `↓${dropped} · ↑${accepted}` : `↓${dropped} · ↑${accepted}`
const seen = formatShortDateTime(agent.last_seen_at ?? agent.last_apply_at) const seen = formatRelativeTime(
agent.last_seen_at ?? agent.last_apply_at,
)
const defaultAction = const defaultAction =
agent.default_action === 'drop' ? 'Drop' : 'Accept' agent.default_action === 'drop' ? 'Блокировать' : 'Пропускать'
const subtitle = [ const subtitle = [
agent.hostname, agent.hostname,
platformLabel(agent.platform), platformLabel(agent.platform),
`gen ${agent.policy_generation}`, `поколение ${agent.policy_generation}`,
] ]
.filter(Boolean) .filter(Boolean)
.join(' · ') .join(' · ')
const stats = [ const stats = [
{ {
label: 'Traffic', label: 'Трафик',
value: traffic, value: traffic,
valueClass: valueClass:
traffic === '—' traffic === '—'
@@ -79,7 +93,7 @@ export function AgentCard({
), ),
}, },
{ {
label: 'Seen', label: 'Активность',
value: seen, value: seen,
valueClass: 'text-muted-foreground', valueClass: 'text-muted-foreground',
valueNode: seen, valueNode: seen,
@@ -91,6 +105,7 @@ export function AgentCard({
<button <button
type="button" type="button"
onClick={() => onSelect(agent.id)} onClick={() => onSelect(agent.id)}
aria-label={`Открыть агента ${agent.name}`}
className={cn( className={cn(
'w-full text-left outline-none', 'w-full text-left outline-none',
'focus-visible:ring-ring rounded-[calc(var(--frame-radius)+2px)] focus-visible:ring-2 focus-visible:ring-offset-2', 'focus-visible:ring-ring rounded-[calc(var(--frame-radius)+2px)] focus-visible:ring-2 focus-visible:ring-offset-2',
@@ -113,7 +128,10 @@ export function AgentCard({
<h3 className="truncate text-sm leading-tight font-semibold"> <h3 className="truncate text-sm leading-tight font-semibold">
{agent.name} {agent.name}
</h3> </h3>
<span className="flex items-center gap-1.5">
<AgentOnlineDot agent={agent} />
<StatusBadge status={agent.status} /> <StatusBadge status={agent.status} />
</span>
<Badge <Badge
variant={ variant={
agent.default_action === 'drop' agent.default_action === 'drop'
@@ -173,16 +191,35 @@ export function AgentCard({
</FramePanel> </FramePanel>
</Frame> </Frame>
</button> </button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button <Button
type="button" type="button"
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
className="text-destructive absolute top-3 right-3 z-10" className="absolute top-3 right-3 z-10"
aria-label="Удалить" aria-label={`Действия с агентом ${agent.name}`}
/>
}
>
<MoreVerticalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onSelect(agent.id)}>
<PanelRight className="size-4" />
Открыть
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDelete(agent.id)} onClick={() => onDelete(agent.id)}
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-4" />
</Button> Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
) )
} }
@@ -21,10 +21,8 @@ import {
} from '@/components/reui/alert' } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { import { AgentPlatformIcon, platformLabel } from '@/components/agents/agent-platform-icon'
AgentPlatformIcon, import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
platformLabel,
} from '@/components/agents/agent-platform-icon'
import { import {
agentTrafficAccepted, agentTrafficAccepted,
agentTrafficDropped, agentTrafficDropped,
@@ -38,6 +36,7 @@ import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall' import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
import { AgentPortAcl } from '@/components/agents/agent-port-acl' import { AgentPortAcl } from '@/components/agents/agent-port-acl'
import { CountedLineTabs } from '@/components/counted-line-tabs' import { CountedLineTabs } from '@/components/counted-line-tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { import {
AgentCloneSetsSheet, AgentCloneSetsSheet,
AgentOverrideSheet, AgentOverrideSheet,
@@ -47,6 +46,7 @@ import {
agentQueryOptions, agentQueryOptions,
} from '@/queries' } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { formatDateTime, formatRelativeTime } from '@/lib/format'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton' import { Skeleton } from '@evofw/ui/components/skeleton'
@@ -79,6 +79,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
const installRef = useRef<HTMLDivElement>(null) const installRef = useRef<HTMLDivElement>(null)
const [overrideOpen, setOverrideOpen] = useState(false) const [overrideOpen, setOverrideOpen] = useState(false)
const [cloneOpen, setCloneOpen] = useState(false) const [cloneOpen, setCloneOpen] = useState(false)
const [revokeOpen, setRevokeOpen] = useState(false)
const [resetStatsOpen, setResetStatsOpen] = useState(false)
const [fwTab, setFwTab] = useState('host') const [fwTab, setFwTab] = useState('host')
const revoke = useMutation({ const revoke = useMutation({
@@ -111,7 +113,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] }) void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] }) void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] }) void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
void qc.invalidateQueries({ queryKey: ['stats'] }) void qc.invalidateQueries({ queryKey: ['stats-recent'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] }) void qc.invalidateQueries({ queryKey: ['dashboard'] })
}, },
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
@@ -135,8 +137,19 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
<Alert variant="destructive"> <Alert variant="destructive">
<CircleAlertIcon /> <CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle> <AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription> <AlertDescription className="flex flex-col gap-2">
<span>
{agentQ.error?.message ?? 'Не удалось загрузить агента'} {agentQ.error?.message ?? 'Не удалось загрузить агента'}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => void agentQ.refetch()}
>
Повторить
</Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>
@@ -146,8 +159,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
const headerDesc = [ const headerDesc = [
a.hostname, a.hostname,
platformLabel(a.platform), platformLabel(a.platform),
`gen ${a.policy_generation}`, `поколение ${a.policy_generation}`,
a.default_action === 'drop' ? 'default Drop' : 'default Accept', a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
] ]
.filter(Boolean) .filter(Boolean)
.join(' · ') .join(' · ')
@@ -162,7 +175,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
actions={ actions={
<> <>
<AgentPlatformIcon platform={a.platform} /> <AgentPlatformIcon platform={a.platform} />
<span className="flex items-center gap-1.5">
<AgentOnlineDot agent={a} />
<StatusBadge status={a.status} /> <StatusBadge status={a.status} />
</span>
<Badge <Badge
variant={ variant={
a.default_action === 'drop' a.default_action === 'drop'
@@ -180,17 +196,17 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
onClick={() => approve.mutate()} onClick={() => approve.mutate()}
disabled={approve.isPending} disabled={approve.isPending}
> >
Approve Утвердить
</Button> </Button>
) : null} ) : null}
{a.status === 'approved' ? ( {a.status === 'approved' ? (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => revoke.mutate()} onClick={() => setRevokeOpen(true)}
disabled={revoke.isPending} disabled={revoke.isPending}
> >
Revoke Отозвать
</Button> </Button>
) : null} ) : null}
{a.install_curl ? ( {a.install_curl ? (
@@ -199,11 +215,11 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
size="sm" size="sm"
onClick={() => { onClick={() => {
copyToClipboard(a.install_curl!) copyToClipboard(a.install_curl!)
toast.success('Скопировано') toast.success('Команда установки скопирована')
}} }}
> >
<Copy data-icon="inline-start" /> <Copy data-icon="inline-start" />
Install Установка
</Button> </Button>
) : null} ) : null}
<DropdownMenu> <DropdownMenu>
@@ -231,14 +247,14 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
copyToClipboard(a.install_curl!) copyToClipboard(a.install_curl!)
toast.success('Скопировано') toast.success('Команда установки скопирована')
installRef.current?.scrollIntoView({ installRef.current?.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
}) })
}} }}
> >
<TerminalIcon className="size-4" /> <TerminalIcon className="size-4" />
Install curl Команда установки
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{onDelete ? ( {onDelete ? (
@@ -262,7 +278,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
{a.last_apply_error ? ( {a.last_apply_error ? (
<Alert variant="destructive"> <Alert variant="destructive">
<CircleAlertIcon /> <CircleAlertIcon />
<AlertTitle>Ошибка apply</AlertTitle> <AlertTitle>Ошибка применения политики</AlertTitle>
<AlertDescription>{a.last_apply_error}</AlertDescription> <AlertDescription>{a.last_apply_error}</AlertDescription>
</Alert> </Alert>
) : null} ) : null}
@@ -273,7 +289,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
id: 'traffic', id: 'traffic',
icon: <ActivityIcon aria-hidden />, icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-warning', iconClassName: 'text-warning',
label: 'Traffic', label: 'Трафик',
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`, description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
hint: 'накопительно', hint: 'накопительно',
variant: 'warning', variant: 'warning',
@@ -283,7 +299,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
size="sm" size="sm"
variant="outline" variant="outline"
disabled={resetStats.isPending} disabled={resetStats.isPending}
onClick={() => resetStats.mutate()} onClick={() => setResetStatsOpen(true)}
> >
Сбросить Сбросить
</Button> </Button>
@@ -293,15 +309,18 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
id: 'kernel', id: 'kernel',
icon: <CpuIcon aria-hidden />, icon: <CpuIcon aria-hidden />,
iconClassName: 'text-info', iconClassName: 'text-info',
label: 'Kernel', label: 'Ядро',
description: a.last_apply_kernel_method ?? '—', description: a.last_apply_kernel_method ?? '—',
}, },
{ {
id: 'apply', id: 'apply',
icon: <ClockIcon aria-hidden />, icon: <ClockIcon aria-hidden />,
iconClassName: 'text-primary', iconClassName: 'text-primary',
label: 'Last apply', label: 'Последнее применение',
description: a.last_apply_at ?? '—', description: formatDateTime(a.last_apply_at),
hint: a.last_apply_at
? formatRelativeTime(a.last_apply_at)
: undefined,
}, },
]} ]}
/> />
@@ -333,9 +352,9 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
value={fwTab} value={fwTab}
onValueChange={setFwTab} onValueChange={setFwTab}
tabs={[ tabs={[
{ id: 'host', label: 'Host firewall' }, { id: 'host', label: 'Хост-фаервол' },
{ id: 'acl', label: 'Port ACL' }, { id: 'acl', label: 'Правила портов' },
{ id: 'hits', label: 'Blocked' }, { id: 'hits', label: 'Заблокированное' },
]} ]}
> >
<TabsContent value="host" className="mt-3"> <TabsContent value="host" className="mt-3">
@@ -374,6 +393,32 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
open={cloneOpen} open={cloneOpen}
onOpenChange={setCloneOpen} onOpenChange={setCloneOpen}
/> />
<ConfirmDialog
open={revokeOpen}
onOpenChange={setRevokeOpen}
title="Отозвать агента?"
confirmLabel="Отозвать"
description={`Агент «${a.name}» потеряет доступ к API управления и перестанет получать обновления политики. Действие нельзя отменить.`}
onConfirm={() => {
setRevokeOpen(false)
revoke.mutate()
}}
disabled={revoke.isPending}
/>
<ConfirmDialog
open={resetStatsOpen}
onOpenChange={setResetStatsOpen}
title="Сбросить статистику?"
confirmLabel="Сбросить"
description={`Счётчики пакетов и история статистики агента «${a.name}» будут обнулены и удалены. Действие нельзя отменить.`}
onConfirm={() => {
setResetStatsOpen(false)
resetStats.mutate()
}}
disabled={resetStats.isPending}
/>
</> </>
) )
} }
@@ -1,11 +1,15 @@
import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react' import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
import { Check, Copy, PanelRight, Trash2 } from 'lucide-react' import { Check, Copy, PanelRight, Trash2 } from 'lucide-react'
import type { Agent } from '@evofw/shared' import type { Agent } from '@evofw/shared'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit' import { ResourcePage } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { import {
DataGridMutedCell, DataGridMutedCell,
DataGridPrimaryCell, DataGridPrimaryCell,
@@ -20,13 +24,15 @@ import {
agentTrafficAccepted, agentTrafficAccepted,
agentTrafficDropped, agentTrafficDropped,
} from '@/components/agents/agent-traffic' } from '@/components/agents/agent-traffic'
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
import { formatRelativeTime } from '@/lib/format'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@evofw/ui/components/tooltip' } from '@evofw/ui/components/tooltip'
import { formatPackets, formatShortDateTime } from '@/lib/format' import { formatPackets } from '@/lib/format'
/** /**
* Fleet triage DataGrid — firewall ops density. * Fleet triage DataGrid — firewall ops density.
@@ -54,7 +60,17 @@ export type AgentFleetDataGridProps = {
error?: Error | null error?: Error | null
onRetry?: () => void onRetry?: () => void
emptyAction?: ReactNode emptyAction?: ReactNode
filteredEmptyMessage?: string
toolbarExtra?: ReactNode toolbarExtra?: ReactNode
/** Bulk-операции: controlled selection пробрасывается в ResourcePage. */
enableRowSelection?: boolean
rowSelection?: RowSelectionState
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
selectionToolbar?: (ctx: {
selectedIds: string[]
selectedCount: number
clearSelection: () => void
}) => ReactNode
onSelect: (id: string) => void onSelect: (id: string) => void
onApprove: (id: string) => void onApprove: (id: string) => void
approvePending?: boolean approvePending?: boolean
@@ -81,7 +97,12 @@ export function AgentFleetDataGrid({
error, error,
onRetry, onRetry,
emptyAction, emptyAction,
filteredEmptyMessage,
toolbarExtra, toolbarExtra,
enableRowSelection,
rowSelection,
onRowSelectionChange,
selectionToolbar,
onSelect, onSelect,
onApprove, onApprove,
approvePending, approvePending,
@@ -98,6 +119,18 @@ export function AgentFleetDataGrid({
const columns: ColumnDef<Agent>[] = useMemo( const columns: ColumnDef<Agent>[] = useMemo(
() => [ () => [
...(enableRowSelection
? [
{
id: '__select__',
enableSorting: false,
enableResizing: false,
size: 44,
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
} as ColumnDef<Agent>,
]
: []),
{ {
accessorKey: 'name', accessorKey: 'name',
size: 260, size: 260,
@@ -136,12 +169,12 @@ export function AgentFleetDataGrid({
}, },
{ {
id: 'default_action', id: 'default_action',
size: 100, size: 130,
minSize: 90, minSize: 120,
maxSize: 120, maxSize: 150,
accessorFn: (row) => row.default_action, accessorFn: (row) => row.default_action,
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Default" /> <DataGridColumnHeader column={column} title="Политика" />
), ),
cell: ({ row }) => { cell: ({ row }) => {
const drop = row.original.default_action === 'drop' const drop = row.original.default_action === 'drop'
@@ -151,19 +184,19 @@ export function AgentFleetDataGrid({
size="sm" size="sm"
radius="full" radius="full"
> >
{drop ? 'Drop' : 'Accept'} {drop ? 'Блокировать' : 'Пропускать'}
</Badge> </Badge>
) )
}, },
}, },
{ {
id: 'apply', id: 'apply',
size: 100, size: 110,
minSize: 90, minSize: 100,
maxSize: 120, maxSize: 130,
accessorFn: (row) => row.last_apply_status ?? '', accessorFn: (row) => row.last_apply_status ?? '',
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Apply" /> <DataGridColumnHeader column={column} title="Применение" />
), ),
cell: ({ row }) => { cell: ({ row }) => {
const a = row.original const a = row.original
@@ -176,7 +209,7 @@ export function AgentFleetDataGrid({
} }
> >
<Badge variant="destructive-light" size="sm"> <Badge variant="destructive-light" size="sm">
error Ошибка
</Badge> </Badge>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-sm"> <TooltipContent className="max-w-sm">
@@ -188,9 +221,10 @@ export function AgentFleetDataGrid({
if (!a.last_apply_status && !a.last_apply_at) { if (!a.last_apply_status && !a.last_apply_at) {
return <DataGridMutedCell>—</DataGridMutedCell> return <DataGridMutedCell>—</DataGridMutedCell>
} }
const status = a.last_apply_status ?? 'ok'
return ( return (
<Badge variant="secondary" size="sm"> <Badge variant="secondary" size="sm">
{a.last_apply_status ?? 'ok'} {status === 'applied' ? 'Применено' : status}
</Badge> </Badge>
) )
}, },
@@ -203,7 +237,7 @@ export function AgentFleetDataGrid({
accessorFn: (row) => accessorFn: (row) =>
agentTrafficDropped(row) + agentTrafficAccepted(row), agentTrafficDropped(row) + agentTrafficAccepted(row),
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Traffic" /> <DataGridColumnHeader column={column} title="Трафик" />
), ),
cell: ({ row }) => { cell: ({ row }) => {
const a = row.original const a = row.original
@@ -224,39 +258,44 @@ export function AgentFleetDataGrid({
}, },
{ {
accessorKey: 'last_seen_at', accessorKey: 'last_seen_at',
size: 140, size: 150,
minSize: 120, minSize: 130,
maxSize: 180, maxSize: 190,
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Seen" /> <DataGridColumnHeader column={column} title="Активность" />
), ),
cell: ({ row }) => { cell: ({ row }) => {
const a = row.original const a = row.original
const short = formatShortDateTime(a.last_seen_at) if (!a.last_seen_at) {
if (!a.last_seen_at || short === '—') {
return <DataGridMutedCell>—</DataGridMutedCell> return <DataGridMutedCell>—</DataGridMutedCell>
} }
return ( return (
<div className="flex min-w-0 items-center gap-2">
<AgentOnlineDot agent={a} />
<DataGridPrimaryCell <DataGridPrimaryCell
accent="default" accent="default"
title={short} title={formatRelativeTime(a.last_seen_at)}
subtitle={a.last_seen_ip ?? undefined} subtitle={a.last_seen_ip ?? undefined}
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal [&>span:first-child]:tabular-nums" className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal"
/> />
</div>
) )
}, },
}, },
{ {
id: 'gen', id: 'gen',
size: 70, size: 110,
minSize: 60, minSize: 100,
maxSize: 90, maxSize: 130,
accessorFn: (row) => row.policy_generation, accessorFn: (row) => row.policy_generation,
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Gen" /> <DataGridColumnHeader column={column} title="Поколение" />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums"> <span
className="text-muted-foreground text-xs tabular-nums"
title="Версия применённой политики — растёт при каждом apply"
>
{row.original.policy_generation} {row.original.policy_generation}
</span> </span>
), ),
@@ -277,7 +316,7 @@ export function AgentFleetDataGrid({
<Button <Button
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
aria-label="Approve" aria-label="Утвердить"
disabled={approvePending} disabled={approvePending}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@@ -291,7 +330,7 @@ export function AgentFleetDataGrid({
<Button <Button
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
aria-label="Copy install" aria-label="Скопировать команду установки"
onClick={(e) => handleCopy(a.install_curl!, e)} onClick={(e) => handleCopy(a.install_curl!, e)}
> >
<Copy className="size-3.5" /> <Copy className="size-3.5" />
@@ -325,7 +364,7 @@ export function AgentFleetDataGrid({
}, },
}, },
], ],
[approvePending, handleCopy, onApprove, onDelete, onSelect], [approvePending, handleCopy, onApprove, onDelete, onSelect, enableRowSelection],
) )
return ( return (
@@ -346,6 +385,10 @@ export function AgentFleetDataGrid({
getSearchText={getSearchText} getSearchText={getSearchText}
tableLayout={{ width: 'fixed', columnsResizable: true }} tableLayout={{ width: 'fixed', columnsResizable: true }}
onRowClick={(row) => onSelect(row.id)} onRowClick={(row) => onSelect(row.id)}
enableRowSelection={enableRowSelection}
rowSelection={rowSelection}
onRowSelectionChange={onRowSelectionChange}
selectionToolbar={selectionToolbar}
tabs={tabs} tabs={tabs}
activeTab={activeTab} activeTab={activeTab}
onTabChange={onTabChange} onTabChange={onTabChange}
@@ -355,10 +398,11 @@ export function AgentFleetDataGrid({
error={error} error={error}
onRetry={onRetry} onRetry={onRetry}
toolbarExtra={toolbarExtra} toolbarExtra={toolbarExtra}
filteredEmptyMessage={filteredEmptyMessage}
emptyState={{ emptyState={{
title: 'Нет агентов', title: 'Нет агентов',
description: description:
'Создайте агента — он появится в списке как Invited с командой установки.', 'Создайте агента — он появится в списке со статусом «Приглашён» и командой установки.',
action: emptyAction, action: emptyAction,
}} }}
/> />
@@ -1,122 +0,0 @@
import type { Agent } from '@evofw/shared'
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { formatDateTime } from '@/lib/format'
/**
* Agent lifecycle timeline.
* Preview: https://reui.io/preview/base/solution-agents-3
* Docs: https://reui.io/docs/components/base/timeline
*/
type Step = {
title: string
date?: string | null
detail?: string
done: boolean
}
function formatWhen(iso?: string | null): string | undefined {
if (!iso) return undefined
return formatDateTime(iso)
}
export function AgentLifecycleTimeline({ agent }: { agent: Agent }) {
const steps: Step[] = [
{
title: 'Создан (Invited)',
date: agent.created_at,
detail: 'Install-ссылка выдана',
done: true,
},
{
title: 'Первый контакт',
date: agent.last_seen_at,
detail: agent.last_seen_ip
? `IP ${agent.last_seen_ip}`
: agent.hostname
? agent.hostname
: 'Ещё не подключался',
done: Boolean(agent.last_seen_at),
},
{
title: 'Approved',
date: agent.approved_at,
detail: agent.status === 'pending' ? 'Ожидает approve' : undefined,
done: Boolean(agent.approved_at) || agent.status === 'approved',
},
{
title: 'Last apply',
date: agent.last_apply_at,
detail: agent.last_apply_error
? agent.last_apply_error
: (agent.last_apply_status ??
(agent.last_apply_prefix_count != null
? `${agent.last_apply_prefix_count} prefixes`
: undefined)),
done: Boolean(agent.last_apply_at),
},
]
if (agent.revoked_at || agent.status === 'revoked') {
steps.push({
title: 'Revoked',
date: agent.revoked_at,
done: true,
})
}
const activeStep = Math.max(
1,
steps.reduce((acc, s, i) => (s.done ? i + 1 : acc), 1),
)
return (
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Жизненный цикл</FrameTitle>
<FrameDescription>
Invite → enroll → approve → apply
</FrameDescription>
</FrameHeader>
<FramePanel>
<Timeline value={activeStep} className="gap-4 ps-6">
{steps.map((s, i) => (
<TimelineItem key={s.title} step={i + 1}>
<TimelineSeparator />
<TimelineIndicator />
<TimelineHeader>
<TimelineTitle>{s.title}</TimelineTitle>
{s.date ? (
<TimelineDate dateTime={s.date}>
{formatWhen(s.date)}
</TimelineDate>
) : (
<TimelineDate>—</TimelineDate>
)}
</TimelineHeader>
{s.detail ? (
<TimelineContent>{s.detail}</TimelineContent>
) : null}
</TimelineItem>
))}
</Timeline>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,27 @@
import type { Agent } from '@evofw/shared'
import { isAgentOnline } from '@/lib/agent-online'
import { cn } from '@evofw/ui/lib/utils'
/** Индикатор «на связи» (last_seen < 5 мин) — тот же порог, что в API. */
export function AgentOnlineDot({
agent,
className,
}: {
agent: Pick<Agent, 'last_seen_at'> | null | undefined
className?: string
}) {
const online = isAgentOnline(agent)
const label = online ? 'На связи' : 'Не на связи'
return (
<span
role="img"
aria-label={label}
title={label}
className={cn(
'inline-block size-2 shrink-0 rounded-full',
online ? 'bg-success' : 'bg-muted-foreground/30',
className,
)}
/>
)
}
@@ -1,6 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { parseListEntry } from '@evofw/shared'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
agentOverridesQueryOptions, agentOverridesQueryOptions,
@@ -8,8 +12,9 @@ import {
} from '@/queries' } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field' import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input' import { Input } from '@evofw/ui/components/input'
import { ScrollArea } from '@evofw/ui/components/scroll-area' import { ScrollArea } from '@evofw/ui/components/scroll-area'
import { import {
@@ -40,6 +45,29 @@ type OverrideSheetProps = {
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
} }
const OVERRIDE_ACTION_ITEMS = [
{ value: 'deny', label: 'deny — блокировать' },
{ value: 'allow', label: 'allow — пропускать' },
] as const
const overrideSchema = z.object({
cidr: z
.string()
.trim()
.min(1, 'Укажите CIDR или IP')
.refine((v) => {
try {
const kind = parseListEntry(v).kind
return kind === 'cidr' || kind === 'ip'
} catch {
return false
}
}, 'Ожидается IP или CIDR, например 1.2.3.4/32'),
action: z.enum(['deny', 'allow']),
})
type OverrideValues = z.infer<typeof overrideSchema>
export function AgentOverrideSheet({ export function AgentOverrideSheet({
agentId, agentId,
open, open,
@@ -50,18 +78,21 @@ export function AgentOverrideSheet({
...agentOverridesQueryOptions(agentId), ...agentOverridesQueryOptions(agentId),
enabled: open, enabled: open,
}) })
const [cidr, setCidr] = useState('') const form = useForm<OverrideValues>({
const [action, setAction] = useState<'allow' | 'deny'>('deny') resolver: zodResolver(overrideSchema),
defaultValues: { cidr: '', action: 'deny' },
mode: 'onBlur',
})
const add = useMutation({ const add = useMutation({
mutationFn: () => mutationFn: (values: OverrideValues) =>
apiFetch(`/api/v1/agents/${agentId}/overrides`, { apiFetch(`/api/v1/agents/${agentId}/overrides`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ cidr, action }), body: JSON.stringify(values),
}), }),
onSuccess: () => { onSuccess: () => {
toast.success('Override добавлен — подхватится на следующей итерации sync') toast.success('Override добавлен — подхватится на следующей итерации sync')
setCidr('') form.reset({ cidr: '', action: 'deny' })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] }) void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] }) void qc.invalidateQueries({ queryKey: ['agents', agentId] })
}, },
@@ -96,42 +127,58 @@ export function AgentOverrideSheet({
<ScrollArea className="flex-1 px-4"> <ScrollArea className="flex-1 px-4">
<div className="flex flex-col gap-4 py-2 pb-4"> <div className="flex flex-col gap-4 py-2 pb-4">
<div className="grid gap-3"> <form
<Field> onSubmit={form.handleSubmit((values) => add.mutateAsync(values))}
className="grid gap-3"
>
<Field
data-invalid={Boolean(form.formState.errors.cidr) || undefined}
>
<FieldLabel htmlFor="ov-cidr">CIDR / IP</FieldLabel> <FieldLabel htmlFor="ov-cidr">CIDR / IP</FieldLabel>
<Input <Input
id="ov-cidr" id="ov-cidr"
placeholder="1.2.3.4/32" placeholder="1.2.3.4/32"
value={cidr} {...form.register('cidr')}
onChange={(e) => setCidr(e.target.value)} aria-invalid={Boolean(form.formState.errors.cidr) || undefined}
/> />
<FieldError errors={[form.formState.errors.cidr]} />
</Field> </Field>
<Field> <Field>
<FieldLabel>Действие</FieldLabel> <FieldLabel>Действие</FieldLabel>
<Controller
control={form.control}
name="action"
render={({ field }) => (
<Select <Select
value={action} items={[...OVERRIDE_ACTION_ITEMS]}
value={field.value}
onValueChange={(v) => { onValueChange={(v) => {
if (v) setAction(v as 'allow' | 'deny') if (v === 'allow' || v === 'deny') field.onChange(v)
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="deny">deny</SelectItem> {OVERRIDE_ACTION_ITEMS.map((item) => (
<SelectItem value="allow">allow</SelectItem> <SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
)}
/>
</Field> </Field>
<Button <LoadingButton
type="submit"
size="sm" size="sm"
isLoading={add.isPending}
className="self-start" className="self-start"
disabled={!cidr.trim() || add.isPending}
onClick={() => add.mutate()}
> >
Добавить Добавить
</Button> </LoadingButton>
</div> </form>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
@@ -52,24 +52,24 @@ export function fleetKpiCards(
return [ return [
{ {
id: 'pending', id: 'pending',
label: 'Pending', label: 'Ожидают',
value: counts.pending, value: counts.pending,
hint: 'approve backlog', hint: 'одобрение',
icon: icons.pending, icon: icons.pending,
iconClassName: 'text-warning', iconClassName: 'text-warning',
variant: counts.pending > 0 ? 'warning' : 'default', variant: counts.pending > 0 ? 'warning' : 'default',
}, },
{ {
id: 'invited', id: 'invited',
label: 'Invited', label: 'Приглашённые',
value: counts.invited, value: counts.invited,
hint: 'ожидают install', hint: 'ждут установки',
icon: icons.invited, icon: icons.invited,
iconClassName: 'text-info', iconClassName: 'text-info',
}, },
{ {
id: 'approved', id: 'approved',
label: 'Approved', label: 'Одобренные',
value: counts.approved, value: counts.approved,
hint: 'в парке', hint: 'в парке',
icon: icons.approved, icon: icons.approved,
@@ -77,9 +77,9 @@ export function fleetKpiCards(
}, },
{ {
id: 'stale', id: 'stale',
label: 'Offline / stale', label: 'Не на связи',
value: counts.stale, value: counts.stale,
hint: '>24ч без seen', hint: 'нет seen >24ч',
icon: icons.stale, icon: icons.stale,
iconClassName: 'text-muted-foreground', iconClassName: 'text-muted-foreground',
variant: counts.stale > 0 ? 'warning' : 'default', variant: counts.stale > 0 ? 'warning' : 'default',
+10 -1
View File
@@ -2,6 +2,7 @@ import { Link, useRouterState } from '@tanstack/react-router'
import { AppSwitcher } from '@/components/app-switcher' import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/layout/nav-user' import { NavUser } from '@/components/layout/nav-user'
import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav' import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
import { useCan } from '@/lib/permissions'
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -24,17 +25,23 @@ function NavSection({
label, label,
items, items,
pathname, pathname,
can,
}: { }: {
label: string label: string
items: readonly NavItem[] items: readonly NavItem[]
pathname: string pathname: string
can: (permission: string) => boolean
}) { }) {
const visible = items.filter(
(item) => !item.permission || can(item.permission),
)
if (visible.length === 0) return null
return ( return (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel> <SidebarGroupLabel>{label}</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{items.map((item) => ( {visible.map((item) => (
<SidebarMenuItem key={item.to}> <SidebarMenuItem key={item.to}>
<SidebarMenuButton <SidebarMenuButton
tooltip={item.label} tooltip={item.label}
@@ -56,6 +63,7 @@ function NavSection({
export function AppSidebar() { export function AppSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
const can = useCan()
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
@@ -69,6 +77,7 @@ export function AppSidebar() {
label={section.label} label={section.label}
items={navItemsForSection(section.id)} items={navItemsForSection(section.id)}
pathname={pathname} pathname={pathname}
can={can}
/> />
))} ))}
</SidebarContent> </SidebarContent>
+4 -1
View File
@@ -18,6 +18,8 @@ interface ConfirmDialogProps {
title: string title: string
description: string description: string
confirmLabel?: string confirmLabel?: string
/** 'default' для позитивных действий (утвердить), 'destructive' — для опасных. */
confirmVariant?: 'default' | 'destructive'
cancelLabel?: string cancelLabel?: string
onConfirm: () => void onConfirm: () => void
disabled?: boolean disabled?: boolean
@@ -30,6 +32,7 @@ export function ConfirmDialog({
title, title,
description, description,
confirmLabel = 'Удалить', confirmLabel = 'Удалить',
confirmVariant = 'destructive',
cancelLabel = 'Отмена', cancelLabel = 'Отмена',
onConfirm, onConfirm,
disabled, disabled,
@@ -46,7 +49,7 @@ export function ConfirmDialog({
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel> <AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}> <AlertDialogAction variant={confirmVariant} onClick={onConfirm}>
{confirmLabel} {confirmLabel}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
-35
View File
@@ -1,35 +0,0 @@
import type { ReactNode } from 'react'
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from '@evofw/ui/components/field'
import { cn } from '@evofw/ui/lib/utils'
interface FormFieldSimpleProps {
label: string
htmlFor: string
error?: { message?: string }
hint?: string
className?: string
children: ReactNode
}
export function FormFieldSimple({
label,
htmlFor,
error,
hint,
className,
children,
}: FormFieldSimpleProps) {
return (
<Field data-invalid={!!error} className={cn(className)}>
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
{children}
{hint && !error ? <FieldDescription>{hint}</FieldDescription> : null}
<FieldError errors={[error]} />
</Field>
)
}
+190 -106
View File
@@ -1,36 +1,55 @@
import { useEffect, useId, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { SearchIcon } from 'lucide-react' import { useQuery } from '@tanstack/react-query'
import {
ListIcon,
ListPlusIcon,
PlusIcon,
ServerIcon,
ShieldIcon,
ShieldPlusIcon,
} from 'lucide-react'
import { NAV_ITEMS } from '@/lib/nav' import { NAV_ITEMS } from '@/lib/nav'
import { Button } from '@evofw/ui/components/button' import { useCan } from '@/lib/permissions'
import { import {
Dialog, agentsQueryOptions,
DialogContent, listsQueryOptions,
DialogDescription, policySetsQueryOptions,
DialogHeader, } from '@/queries'
DialogTitle,
} from '@evofw/ui/components/dialog'
import { Input } from '@evofw/ui/components/input'
import { import {
Item, CommandDialog,
ItemContent, CommandEmpty,
ItemGroup, CommandGroup,
ItemMedia, CommandInput,
ItemTitle, CommandItem,
} from '@evofw/ui/components/item' CommandList,
CommandSeparator,
} from '@evofw/ui/components/command'
/**
* Command-K palette — навигация, поиск ресурсов и действия.
* DNA: https://reui.io/components/command/c-command-7 (hotkey-only по контракту).
*/
/** Command-K search — hotkey dialog (no header chrome trigger). */
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) { export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
void hotkeyOnly
return <SearchMenuDialog />
}
function SearchMenuDialog() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
const navigate = useNavigate() const navigate = useNavigate()
const can = useCan()
const agentsQ = useQuery({ ...agentsQueryOptions(), enabled: open })
const listsQ = useQuery({ ...listsQueryOptions(), enabled: open })
const setsQ = useQuery({ ...policySetsQueryOptions(), enabled: open })
useEffect(() => { useEffect(() => {
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) { if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault() event.preventDefault()
setOpen(true) setOpen((prev) => !prev)
} }
} }
@@ -38,99 +57,164 @@ export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
return () => window.removeEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown)
}, []) }, [])
useEffect(() => { const go = (run: () => void) => {
if (!open) setQuery('')
}, [open])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return NAV_ITEMS
return NAV_ITEMS.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
item.keywords.some((k) => k.includes(q)),
)
}, [query])
function goTo(to: string) {
setOpen(false) setOpen(false)
void navigate({ to }) run()
} }
const navItems = NAV_ITEMS.filter(
(item) => !item.permission || can(item.permission),
)
const agents = (agentsQ.data?.items ?? []).slice(0, 50)
const lists = (listsQ.data?.items ?? []).slice(0, 50)
const sets = (setsQ.data?.items ?? []).slice(0, 50)
return ( return (
<> <CommandDialog
{hotkeyOnly ? null : ( open={open}
<Button onOpenChange={setOpen}
type="button" title="Палитра команд"
variant="ghost" description="Поиск разделов, агентов, списков и действий"
size="icon" className="max-w-xl"
aria-label="Поиск"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
> >
<SearchIcon <CommandInput placeholder="Поиск разделов, агентов, списков…" />
className="size-4.5 transition-colors" <CommandList>
aria-hidden="true" <CommandEmpty>Ничего не найдено.</CommandEmpty>
/>
</Button>
)}
<Dialog open={open} onOpenChange={setOpen}> <CommandGroup heading="Действия">
<DialogHeader className="sr-only"> {can('fw:agents:write') ? (
<DialogTitle>Поиск</DialogTitle> <CommandItem
<DialogDescription> value="Добавить агента agent invite"
Переход к разделам приложения onSelect={() =>
</DialogDescription> go(() =>
</DialogHeader> void navigate({
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60"> to: '/agents',
<div className="relative flex items-center gap-3 border-b px-4 py-2"> search: { view: 'cards', add: true },
<SearchIcon }),
aria-hidden="true" )
className="pointer-events-none size-4 opacity-60 select-none"
/>
<Input
id={searchInputId}
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
autoFocus
placeholder="Перейти к разделу…"
aria-label="Поиск разделов"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered[0]) {
e.preventDefault()
goTo(filtered[0].to)
} }
}}
/>
</div>
<ItemGroup className="max-h-72 overflow-y-auto p-2">
{filtered.length === 0 ? (
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
Ничего не найдено
</p>
) : (
filtered.map((item) => (
<Item
key={item.to}
size="sm"
variant="muted"
className="cursor-pointer border-0"
render={<Link to={item.to} onClick={() => setOpen(false)} />}
> >
<ItemMedia variant="icon"> <PlusIcon aria-hidden />
<item.icon aria-hidden="true" /> Добавить агента
</ItemMedia> </CommandItem>
<ItemContent> ) : null}
<ItemTitle>{item.label}</ItemTitle> {can('fw:lists:write') ? (
</ItemContent> <CommandItem
</Item> value="Создать список blocklist"
)) onSelect={() =>
)} go(() =>
</ItemGroup> void navigate({ to: '/lists', search: { create: true } }),
</DialogContent> )
</Dialog> }
>
<ListPlusIcon aria-hidden />
Создать список
</CommandItem>
) : null}
{can('fw:policies:write') ? (
<CommandItem
value="Создать набор правил policy"
onSelect={() =>
go(() =>
void navigate({ to: '/rules', search: { create: true } }),
)
}
>
<ShieldPlusIcon aria-hidden />
Создать набор правил
</CommandItem>
) : null}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Навигация">
{navItems.map((item) => (
<CommandItem
key={item.to}
value={`${item.label} ${item.keywords.join(' ')}`}
onSelect={() => go(() => void navigate({ to: item.to }))}
>
<item.icon aria-hidden />
{item.label}
</CommandItem>
))}
</CommandGroup>
{agents.length > 0 ? (
<>
<CommandSeparator />
<CommandGroup heading="Агенты">
{agents.map((a) => (
<CommandItem
key={a.id}
value={`${a.name} ${a.hostname ?? ''} ${a.last_seen_ip ?? ''}`}
onSelect={() =>
go(() =>
void navigate({
to: '/agents',
search: { view: 'cards', agent: a.id },
}),
)
}
>
<ServerIcon aria-hidden />
<span className="truncate">{a.name}</span>
<span className="text-muted-foreground truncate text-xs">
{a.hostname ?? a.status}
</span>
</CommandItem>
))}
</CommandGroup>
</> </>
) : null}
{lists.length > 0 ? (
<>
<CommandSeparator />
<CommandGroup heading="Списки">
{lists.map((l) => (
<CommandItem
key={l.id}
value={l.name}
onSelect={() =>
go(() => void navigate({ to: '/lists/$id', params: { id: l.id } }))
}
>
<ListIcon aria-hidden />
<span className="truncate">{l.name}</span>
</CommandItem>
))}
</CommandGroup>
</>
) : null}
{sets.length > 0 ? (
<>
<CommandSeparator />
<CommandGroup heading="Наборы правил">
{sets.map((s) => (
<CommandItem
key={s.id}
value={s.name}
onSelect={() =>
go(() =>
void navigate({
to: '/rules/$setId',
params: { setId: s.id },
}),
)
}
>
<ShieldIcon aria-hidden />
<span className="truncate">{s.name}</span>
</CommandItem>
))}
</CommandGroup>
</>
) : null}
</CommandList>
</CommandDialog>
) )
} }
@@ -22,6 +22,7 @@ import {
Filters, Filters,
type Filter, type Filter,
type FilterFieldConfig, type FilterFieldConfig,
type FilterI18nConfig,
} from '@/components/reui/filters' } from '@/components/reui/filters'
import { import {
Frame, Frame,
@@ -51,6 +52,26 @@ type DataGridTableLayout = NonNullable<
ComponentProps<typeof DataGrid>['tableLayout'] ComponentProps<typeof DataGrid>['tableLayout']
> >
/** Русская локаль фильтров (DNA data-grid-filtering-2, i18n по контракту). */
const FILTERS_I18N: Partial<FilterI18nConfig> = {
addFilter: 'Фильтр',
searchFields: 'Фильтр…',
noFieldsFound: 'Фильтры не найдены.',
noResultsFound: 'Ничего не найдено.',
select: 'Выберите…',
true: 'Да',
false: 'Нет',
min: 'От',
max: 'До',
to: '—',
typeAndPressEnter: 'Введите и нажмите Enter',
selected: 'выбрано',
selectedCount: 'выбрано',
addFilterTitle: 'Добавить фильтр',
loadingOptions: 'Загрузка…',
errorLoadingOptions: 'Не удалось загрузить варианты.',
}
export interface ResourcePageTab { export interface ResourcePageTab {
id: string id: string
label: string label: string
@@ -78,8 +99,13 @@ export interface ResourcePageProps<T extends object> {
onRetry?: () => void onRetry?: () => void
primaryAction?: ReactNode primaryAction?: ReactNode
emptyState?: { title: string; description?: string; action?: ReactNode } emptyState?: { title: string; description?: string; action?: ReactNode }
/** Текст для пустого результата после фильтров/поиска (по вкладкам). */
filteredEmptyMessage?: string
pageSize?: number pageSize?: number
enableRowSelection?: boolean enableRowSelection?: boolean
/** Controlled selection — page owns state, so bulk-действия видят выбор. */
rowSelection?: RowSelectionState
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
selectionToolbar?: (ctx: { selectionToolbar?: (ctx: {
selectedIds: string[] selectedIds: string[]
selectedCount: number selectedCount: number
@@ -139,8 +165,11 @@ export function ResourcePage<T extends object>({
onRetry, onRetry,
primaryAction, primaryAction,
emptyState, emptyState,
filteredEmptyMessage,
pageSize = 10, pageSize = 10,
enableRowSelection = false, enableRowSelection = false,
rowSelection: rowSelectionProp,
onRowSelectionChange: onRowSelectionChangeProp,
selectionToolbar, selectionToolbar,
toolbarExtra, toolbarExtra,
hideHeader = false, hideHeader = false,
@@ -156,7 +185,30 @@ export function ResourcePage<T extends object>({
const activeTab = controlledTab ?? internalTab const activeTab = controlledTab ?? internalTab
const [sorting, setSorting] = useState<SortingState>([]) const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const isSelectionControlled = rowSelectionProp !== undefined
const [internalRowSelection, setInternalRowSelection] =
useState<RowSelectionState>({})
const rowSelection = isSelectionControlled
? rowSelectionProp
: internalRowSelection
const handleRowSelectionChange = useCallback(
(
updater: RowSelectionState | ((prev: RowSelectionState) => RowSelectionState),
) => {
const current = isSelectionControlled
? rowSelectionProp
: internalRowSelection
const next = typeof updater === 'function' ? updater(current) : updater
if (isSelectionControlled) onRowSelectionChangeProp?.(next)
else setInternalRowSelection(next)
},
[
isSelectionControlled,
rowSelectionProp,
internalRowSelection,
onRowSelectionChangeProp,
],
)
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>( const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(
() => columnPinningProp ?? {}, () => columnPinningProp ?? {},
) )
@@ -224,8 +276,8 @@ export function ResourcePage<T extends object>({
const selectedCount = selectedIds.length const selectedCount = selectedIds.length
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
setRowSelection({}) handleRowSelectionChange({})
}, []) }, [handleRowSelectionChange])
const tableLayout = useMemo( const tableLayout = useMemo(
() => ({ () => ({
@@ -253,7 +305,7 @@ export function ResourcePage<T extends object>({
// без drag-handles — enableColumnResizing остаётся false. // без drag-handles — enableColumnResizing остаётся false.
enableColumnResizing: false, enableColumnResizing: false,
onSortingChange: setSorting, onSortingChange: setSorting,
onRowSelectionChange: setRowSelection, onRowSelectionChange: handleRowSelectionChange,
onPaginationChange: setPagination, onPaginationChange: setPagination,
onColumnPinningChange: setColumnPinning, onColumnPinningChange: setColumnPinning,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -357,7 +409,8 @@ export function ResourcePage<T extends object>({
) )
} }
const emptyMessage = 'Нет записей по выбранным фильтрам.' const emptyMessage =
filteredEmptyMessage ?? 'Нет записей по выбранным фильтрам.'
return ( return (
<div className="w-full"> <div className="w-full">
@@ -431,6 +484,7 @@ export function ResourcePage<T extends object>({
fields={filterFields} fields={filterFields}
onChange={handleFiltersChange} onChange={handleFiltersChange}
size="default" size="default"
i18n={FILTERS_I18N}
trigger={ trigger={
<Button type="button" variant="outline" aria-label="Фильтры"> <Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" /> <FilterIcon className="size-4" aria-hidden="true" />
@@ -480,9 +534,9 @@ export function ResourcePage<T extends object>({
<DataGridPagination <DataGridPagination
sizes={[5, 10, 20, 50]} sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице" rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}" info="{from}–{to} из {count}"
previousPageLabel="Предыдущая" previousPageLabel="Назад"
nextPageLabel="Следующая" nextPageLabel="Вперёд"
/> />
</FrameFooter> </FrameFooter>
</FramePanel> </FramePanel>
+8 -8
View File
@@ -54,21 +54,21 @@ const STATUS_LABELS: Record<string, string> = {
active: 'Активен', active: 'Активен',
synced: 'Синхронизировано', synced: 'Синхронизировано',
pending_push: 'Ожидает отправки', pending_push: 'Ожидает отправки',
pending: 'Pending', pending: 'Ожидает одобрения',
invited: 'Invited', invited: 'Приглашён',
approved: 'Approved', approved: 'Одобрен',
revoked: 'Revoked', revoked: 'Отозван',
enabled: 'Включён', enabled: 'Включён',
disabled: 'Выключен', disabled: 'Выключен',
allow: 'allow', allow: 'Разрешить',
deny: 'deny', deny: 'Блокировать',
conflict: 'Конфликт', conflict: 'Конфликт',
error: 'Ошибка', error: 'Ошибка',
ok: 'OK', ok: 'OK',
up: 'OK', up: 'OK',
warning: 'Предупреждение', warning: 'Предупреждение',
degraded: 'Slow', degraded: 'Замедление',
down: 'Down', down: 'Недоступен',
expired: 'Истёк', expired: 'Истёк',
static: 'Ручной', static: 'Ручной',
domains: 'Ручной', domains: 'Ручной',
@@ -0,0 +1,11 @@
import { formatTime } from '@/lib/format'
/** «Обновлено в HH:mm:ss» — подпись свежести данных для polling-страниц. */
export function UpdatedAtLabel({ updatedAt }: { updatedAt?: number }) {
if (!updatedAt) return null
return (
<span className="text-muted-foreground text-xs tabular-nums">
Обновлено в {formatTime(new Date(updatedAt))}
</span>
)
}
+16
View File
@@ -0,0 +1,16 @@
import type { Agent } from '@evofw/shared'
/**
* Порог «на связи» — тот же, что в API /dashboard: агент считается
* онлайн, если last_seen_at свежее 5 минут.
*/
export const AGENT_ONLINE_WINDOW_MS = 5 * 60_000
export function isAgentOnline(
agent: Pick<Agent, 'last_seen_at'> | null | undefined,
): boolean {
if (!agent?.last_seen_at) return false
const t = Date.parse(agent.last_seen_at)
if (Number.isNaN(t)) return false
return Date.now() - t < AGENT_ONLINE_WINDOW_MS
}
+19
View File
@@ -34,6 +34,8 @@ const timeFmt = new Intl.DateTimeFormat('ru-RU', {
second: '2-digit', second: '2-digit',
}) })
const relativeFmt = new Intl.RelativeTimeFormat('ru-RU', { numeric: 'auto' })
/** Compact packet counter: 1,2K / 3,4M */ /** Compact packet counter: 1,2K / 3,4M */
export function formatPackets(n: number | undefined | null): string { export function formatPackets(n: number | undefined | null): string {
if (n === undefined || n === null) return '—' if (n === undefined || n === null) return '—'
@@ -72,3 +74,20 @@ export function formatStampDateTime(iso: string | null | undefined): string {
export function formatTime(d: Date): string { export function formatTime(d: Date): string {
return timeFmt.format(d) return timeFmt.format(d)
} }
/** «2 минуты назад» / «5 часов назад»; старше 7 дней — полная дата. */
export function formatRelativeTime(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
const diffMs = t - Date.now()
const absMin = Math.round(Math.abs(diffMs) / 60_000)
if (absMin < 1) return 'только что'
const sign = diffMs < 0 ? -1 : 1
if (absMin < 60) return relativeFmt.format(sign * absMin, 'minute')
const absHours = Math.round(absMin / 60)
if (absHours < 24) return relativeFmt.format(sign * absHours, 'hour')
const absDays = Math.round(absHours / 24)
if (absDays < 7) return relativeFmt.format(sign * absDays, 'day')
return formatDateTime(iso)
}
+8
View File
@@ -22,6 +22,8 @@ export type NavItem = {
keywords: string[] keywords: string[]
icon: typeof ServerIcon icon: typeof ServerIcon
section: NavSectionId section: NavSectionId
/** Read-право для показа пункта (useCan); undefined — виден всем с доступом к app. */
permission?: string
} }
export const NAV_ITEMS: readonly NavItem[] = [ export const NAV_ITEMS: readonly NavItem[] = [
@@ -32,6 +34,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
keywords: ['dashboard', 'панель', 'обзор'], keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon, icon: LayoutDashboardIcon,
section: 'overview', section: 'overview',
permission: 'fw:dashboard:read',
}, },
{ {
to: '/agents', to: '/agents',
@@ -39,6 +42,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
keywords: ['agents', 'агенты', 'nodes'], keywords: ['agents', 'агенты', 'nodes'],
icon: ServerIcon, icon: ServerIcon,
section: 'ops', section: 'ops',
permission: 'fw:agents:read',
}, },
{ {
to: '/lists', to: '/lists',
@@ -46,6 +50,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
keywords: ['lists', 'списки', 'blocklist'], keywords: ['lists', 'списки', 'blocklist'],
icon: ListIcon, icon: ListIcon,
section: 'ops', section: 'ops',
permission: 'fw:lists:read',
}, },
{ {
to: '/rules', to: '/rules',
@@ -53,6 +58,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'], keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
icon: ShieldIcon, icon: ShieldIcon,
section: 'ops', section: 'ops',
permission: 'fw:policies:read',
}, },
{ {
to: '/stats', to: '/stats',
@@ -60,6 +66,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
keywords: ['stats', 'статистика', 'packets'], keywords: ['stats', 'статистика', 'packets'],
icon: BarChart3Icon, icon: BarChart3Icon,
section: 'ops', section: 'ops',
permission: 'fw:stats:read',
}, },
{ {
to: '/settings', to: '/settings',
@@ -67,6 +74,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
keywords: ['settings', 'настройки'], keywords: ['settings', 'настройки'],
icon: SettingsIcon, icon: SettingsIcon,
section: 'system', section: 'system',
permission: 'fw:settings:read',
}, },
] ]
+52
View File
@@ -0,0 +1,52 @@
import type { Filter } from '@/components/reui/filters'
/**
* Сериализация состояния страниц (таб/поиск/фильтры) в URL search-параметры:
* tab — id вкладки, q — строка поиска, f — JSON-массив фильтров.
* Пустые значения не пишутся в URL (deep-link остаётся чистым).
*/
export function filtersToSearch(filters: Filter[]): string | undefined {
if (filters.length === 0) return undefined
try {
return JSON.stringify(filters)
} catch {
return undefined
}
}
export function searchToFilters(raw: unknown): Filter[] {
if (typeof raw !== 'string' || raw.length === 0) return []
try {
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(
(x): x is Filter =>
typeof x === 'object' &&
x !== null &&
typeof (x as Filter).field === 'string' &&
typeof (x as Filter).operator === 'string' &&
Array.isArray((x as Filter).values),
)
} catch {
return []
}
}
/** Строка поиска: undefined при пустой, чтобы не писать пустой q в URL. */
export function searchToQ(raw: unknown): string | undefined {
return typeof raw === 'string' && raw.length > 0 ? raw : undefined
}
/** Вкладка: валидируется по списку допустимых id. */
export function searchToTab(
raw: unknown,
allowed: readonly string[],
): string | undefined {
return typeof raw === 'string' && allowed.includes(raw) ? raw : undefined
}
/** Флаг из search-параметра: только `true` пишется в URL. */
export function searchToFlag(raw: unknown): boolean | undefined {
return raw === true || raw === '1' ? true : undefined
}
+3
View File
@@ -12,12 +12,14 @@ export const dashboardQueryOptions = () =>
queryOptions({ queryOptions({
queryKey: ['dashboard'], queryKey: ['dashboard'],
queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'), queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'),
refetchInterval: 30_000,
}) })
export const agentsQueryOptions = () => export const agentsQueryOptions = () =>
queryOptions({ queryOptions({
queryKey: ['agents'], queryKey: ['agents'],
queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'), queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'),
refetchInterval: 60_000,
}) })
export const agentQueryOptions = (id: string) => export const agentQueryOptions = (id: string) =>
@@ -272,4 +274,5 @@ export const recentStatsQueryOptions = () =>
recorded_at: string recorded_at: string
}[] }[]
}>('/api/v1/stats/recent'), }>('/api/v1/stats/recent'),
refetchInterval: 60_000,
}) })
+344 -77
View File
@@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { RowSelectionState } from '@tanstack/react-table'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
@@ -13,6 +14,7 @@ import {
Plus, Plus,
ShieldIcon, ShieldIcon,
TableIcon, TableIcon,
Trash2,
UserPlus, UserPlus,
WifiOff, WifiOff,
} from 'lucide-react' } from 'lucide-react'
@@ -27,13 +29,10 @@ import {
Frame, Frame,
FrameDescription, FrameDescription,
FrameHeader, FrameHeader,
FramePanel,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { import { Badge } from '@/components/reui/badge'
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { AddAgentSheet } from '@/components/agents/add-agent-sheet' import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid' import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet' import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
@@ -45,8 +44,17 @@ import {
} from '@/components/agents/agents-fleet-kpis' } from '@/components/agents/agents-fleet-kpis'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { UpdatedAtLabel } from '@/components/updated-at-label'
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
import { agentsQueryOptions, settingsQueryOptions } from '@/queries' import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { isAgentOnline } from '@/lib/agent-online'
import {
searchToFilters,
searchToFlag,
searchToQ,
searchToTab,
} from '@/lib/search-params'
import { useCan } from '@/lib/permissions' import { useCan } from '@/lib/permissions'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
@@ -66,44 +74,71 @@ import type { Agent } from '@evofw/shared'
type AgentsSearch = { type AgentsSearch = {
agent?: string agent?: string
view: 'cards' | 'table' view: 'cards' | 'table'
/** Открыть sheet добавления агента (из ⌘K-палитры). */
add?: boolean
/** URL-состояние: вкладка, поиск, фильтры (deep-link и refresh сохраняют вид). */
tab?: string
q?: string
f?: Filter[]
} }
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
const view = search.view === 'table' ? 'table' : 'cards'
const agent =
typeof search.agent === 'string' && search.agent.length > 0
? search.agent
: undefined
return { view, agent }
}
export const Route = createFileRoute('/_auth/agents/')({
validateSearch: (search: Record<string, unknown>): AgentsSearch =>
parseAgentsSearch(search),
component: AgentsPage,
})
const AGENT_TABS = [ const AGENT_TABS = [
{ id: 'all', label: 'Все' }, { id: 'all', label: 'Все' },
{ id: 'invited', label: 'Invited' }, { id: 'invited', label: 'Приглашённые' },
{ id: 'pending', label: 'Pending' }, { id: 'pending', label: 'Ожидают' },
{ id: 'approved', label: 'Approved' }, { id: 'approved', label: 'Одобренные' },
{ id: 'revoked', label: 'Revoked' }, { id: 'revoked', label: 'Отозванные' },
] as const ] as const
const AGENT_TAB_IDS = AGENT_TABS.map((t) => t.id) as string[]
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
const view = search.view === 'table' ? 'table' : 'cards'
const agent = typeof search.agent === 'string' && search.agent.length > 0
? search.agent
: undefined
const add = searchToFlag(search.add)
const f = searchToFilters(search.f)
return {
view,
agent,
add,
tab: searchToTab(search.tab, AGENT_TAB_IDS),
q: searchToQ(search.q),
f: f.length > 0 ? f : undefined,
}
}
export const Route = createFileRoute('/_auth/agents/')({
validateSearch: parseAgentsSearch,
component: AgentsPage,
})
const EMPTY_FILTERS: Filter[] = []
/** Список имён для подтверждений: первые 5, дальше «и ещё N». */
function formatNames(names: string[]): string {
if (names.length <= 5) return names.join(', ')
return `${names.slice(0, 5).join(', ')} и ещё ${names.length - 5}`
}
function AgentsPage() { function AgentsPage() {
const navigate = useNavigate({ from: Route.fullPath }) const navigate = useNavigate({ from: Route.fullPath })
const { agent: detailAgentId, view } = Route.useSearch() const { agent: detailAgentId, view, add: addParam } = Route.useSearch()
const qc = useQueryClient() const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions()) const agentsQ = useQuery(agentsQueryOptions())
const settingsQ = useQuery(settingsQueryOptions()) const settingsQ = useQuery(settingsQueryOptions())
const { copyToClipboard } = useCopyToClipboard() const { copyToClipboard } = useCopyToClipboard()
const canWrite = useCan()('fw:agents:write') const canWrite = useCan()('fw:agents:write')
const [createOpen, setCreateOpen] = useState(false) const [createOpenState, setCreateOpenState] = useState(false)
const createOpen = createOpenState || addParam === true
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null) const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
const [filters, setFilters] = useState<Filter[]>([]) const [approveAllOpen, setApproveAllOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
const [activeTab, setActiveTab] = useState('all') const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const activeTab = Route.useSearch().tab ?? 'all'
const searchQuery = Route.useSearch().q ?? ''
const filters = Route.useSearch().f ?? EMPTY_FILTERS
const detailOpen = Boolean(detailAgentId) const detailOpen = Boolean(detailAgentId)
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false' const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
@@ -119,6 +154,23 @@ function AgentsPage() {
next.agent !== undefined next.agent !== undefined
? next.agent || undefined ? next.agent || undefined
: base.agent, : base.agent,
add:
next.add !== undefined
? next.add || undefined
: base.add,
tab: next.tab !== undefined
? next.tab === 'all'
? undefined
: next.tab
: base.tab,
q: next.q !== undefined
? next.q || undefined
: base.q,
f: next.f !== undefined
? next.f.length > 0
? next.f
: undefined
: base.f,
} satisfies AgentsSearch } satisfies AgentsSearch
}, },
replace: true, replace: true,
@@ -156,7 +208,7 @@ function AgentsPage() {
}, },
}) })
const approveAllPending = useMutation({ const approveBulk = useMutation({
mutationFn: (ids: string[]) => mutationFn: (ids: string[]) =>
apiFetch('/api/v1/agents/approve-bulk', { apiFetch('/api/v1/agents/approve-bulk', {
method: 'POST', method: 'POST',
@@ -170,9 +222,39 @@ function AgentsPage() {
), ),
) )
}, },
onSuccess: () => { onSuccess: (_data, ids) => {
toast.success('Все pending одобрены') toast.success(`Одобрено агентов: ${ids.length}`)
setRowSelection({})
void qc.invalidateQueries({ queryKey: ['agents'] }) void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
onError: (e: Error, _ids, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const deleteBulk = useMutation({
mutationFn: (ids: string[]) =>
apiFetch('/api/v1/agents/delete-bulk', {
method: 'POST',
body: JSON.stringify({ agent_ids: ids }),
}),
onMutate: (ids) => {
const target = new Set(ids)
return patchAgentsCache((items) =>
items.filter((a) => !target.has(a.id)),
)
},
onSuccess: (_data, ids) => {
toast.success(`Удалено агентов: ${ids.length}`)
setBulkDeleteIds(null)
setRowSelection({})
if (detailAgentId && ids.includes(detailAgentId)) {
setSearch({ agent: '' })
}
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
}, },
onError: (e: Error, _ids, rollback) => { onError: (e: Error, _ids, rollback) => {
rollback?.() rollback?.()
@@ -205,6 +287,77 @@ function AgentsPage() {
() => items.filter((a) => a.status === 'pending').map((a) => a.id), () => items.filter((a) => a.status === 'pending').map((a) => a.id),
[items], [items],
) )
const pendingNames = useMemo(
() =>
items.filter((a) => a.status === 'pending').map((a) => a.name),
[items],
)
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
)
const selectedAgents = useMemo(
() => items.filter((a) => selectedIds.includes(a.id)),
[items, selectedIds],
)
const approvableSelected = useMemo(
() =>
selectedAgents.filter(
(a) => a.status === 'pending' || a.status === 'invited',
),
[selectedAgents],
)
const clearSelection = useCallback(() => setRowSelection({}), [])
const selectionToolbar = useCallback(
(ctx: { selectedCount: number }) => {
if (ctx.selectedCount === 0 || !canWrite) return null
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<Badge variant="secondary" size="sm">
{ctx.selectedCount} выбрано
</Badge>
<div className="flex flex-wrap items-center gap-2">
{approvableSelected.length > 0 ? (
<Button
size="sm"
variant="outline"
disabled={approveBulk.isPending}
onClick={() =>
approveBulk.mutate(approvableSelected.map((a) => a.id))
}
>
<Check data-icon="inline-start" />
Утвердить ({approvableSelected.length})
</Button>
) : null}
<Button
size="sm"
variant="outline"
className="text-destructive"
disabled={deleteBulk.isPending}
onClick={() => setBulkDeleteIds(selectedIds)}
>
<Trash2 data-icon="inline-start" />
Удалить
</Button>
<Button size="sm" variant="ghost" onClick={clearSelection}>
Снять выделение
</Button>
</div>
</div>
)
},
[
canWrite,
approvableSelected,
selectedIds,
approveBulk.isPending,
deleteBulk.isPending,
clearSelection,
],
)
const kpiCards = useMemo( const kpiCards = useMemo(
() => () =>
@@ -216,10 +369,22 @@ function AgentsPage() {
}).map((card) => ({ }).map((card) => ({
...card, ...card,
onSelect: () => { onSelect: () => {
if (card.id === 'pending') setActiveTab('pending') if (card.id === 'pending') setSearch({ tab: 'pending' })
else if (card.id === 'invited') setActiveTab('invited') else if (card.id === 'invited') setSearch({ tab: 'invited' })
else if (card.id === 'approved') setActiveTab('approved') else if (card.id === 'approved') setSearch({ tab: 'approved' })
else if (card.id === 'stale') setActiveTab('approved') else if (card.id === 'stale') {
setSearch({
tab: 'all',
f: [
{
id: 'stale',
field: 'online',
operator: 'is',
values: ['offline'],
},
],
})
}
}, },
})), })),
[counts], [counts],
@@ -238,10 +403,10 @@ function AgentsPage() {
label: 'Статус', label: 'Статус',
type: 'select', type: 'select',
options: [ options: [
{ value: 'invited', label: 'invited' }, { value: 'invited', label: 'Приглашён' },
{ value: 'approved', label: 'approved' }, { value: 'pending', label: 'Ожидает одобрения' },
{ value: 'pending', label: 'pending' }, { value: 'approved', label: 'Одобрен' },
{ value: 'revoked', label: 'revoked' }, { value: 'revoked', label: 'Отозван' },
], ],
}, },
{ {
@@ -249,8 +414,17 @@ function AgentsPage() {
label: 'Платформа', label: 'Платформа',
type: 'select', type: 'select',
options: [ options: [
{ value: 'linux', label: 'linux' }, { value: 'linux', label: 'Linux' },
{ value: 'mikrotik', label: 'mikrotik' }, { value: 'mikrotik', label: 'MikroTik' },
],
},
{
key: 'online',
label: 'На связи',
type: 'select',
options: [
{ value: 'online', label: 'На связи' },
{ value: 'offline', label: 'Не на связи' },
], ],
}, },
], ],
@@ -261,6 +435,7 @@ function AgentsPage() {
if (field === 'name') return item.name if (field === 'name') return item.name
if (field === 'status') return item.status if (field === 'status') return item.status
if (field === 'platform') return item.platform if (field === 'platform') return item.platform
if (field === 'online') return isAgentOnline(item) ? 'online' : 'offline'
return undefined return undefined
}, []) }, [])
@@ -332,25 +507,25 @@ function AgentsPage() {
{ {
id: 'add', id: 'add',
title: 'Добавить агента', title: 'Добавить агента',
description: 'Invite + install one-liner', description: 'Приглашение и команда установки',
icon: <Plus aria-hidden />, icon: <Plus aria-hidden />,
iconClassName: 'text-primary [&_svg]:text-current', iconClassName: 'text-primary [&_svg]:text-current',
badgeLabel: 'Открыть', badgeLabel: 'Открыть',
onSelect: () => setCreateOpen(true), onSelect: () => setCreateOpenState(true),
}, },
{ {
id: 'pending', id: 'pending',
title: 'Pending', title: 'Ожидают одобрения',
description: `${counts.pending} ждут approve`, description: `${counts.pending} ждут подтверждения`,
icon: <Inbox aria-hidden />, icon: <Inbox aria-hidden />,
iconClassName: 'text-warning [&_svg]:text-current', iconClassName: 'text-warning [&_svg]:text-current',
badgeLabel: 'Показать', badgeLabel: 'Показать',
onSelect: () => setActiveTab('pending'), onSelect: () => setSearch({ tab: 'pending' }),
}, },
{ {
id: 'rules', id: 'rules',
title: 'Наборы правил', title: 'Наборы правил',
description: 'Политика firewall', description: 'Политика фаервола',
to: '/rules', to: '/rules',
icon: <ShieldIcon aria-hidden />, icon: <ShieldIcon aria-hidden />,
iconClassName: 'text-info [&_svg]:text-current', iconClassName: 'text-info [&_svg]:text-current',
@@ -367,6 +542,26 @@ function AgentsPage() {
[counts.pending], [counts.pending],
) )
const applyErrorAgents = useMemo(
() => items.filter((a) => a.last_apply_error),
[items],
)
const filteredEmptyMessage = useMemo(() => {
switch (activeTab) {
case 'pending':
return 'Нет агентов на одобрении — всё обработано.'
case 'invited':
return 'Нет приглашённых агентов.'
case 'approved':
return 'Нет одобренных агентов.'
case 'revoked':
return 'Нет отозванных агентов.'
default:
return undefined
}
}, [activeTab])
const handleSelectAgent = useCallback( const handleSelectAgent = useCallback(
(id: string) => { (id: string) => {
setSearch({ agent: id }) setSearch({ agent: id })
@@ -384,9 +579,8 @@ function AgentsPage() {
) )
const handleClearFilters = useCallback(() => { const handleClearFilters = useCallback(() => {
setFilters([]) setSearch({ f: [], q: '' })
setSearchQuery('') }, [setSearch])
}, [])
const handleCopyInstall = useCallback( const handleCopyInstall = useCallback(
(curl: string) => { (curl: string) => {
@@ -421,7 +615,7 @@ function AgentsPage() {
) )
const addButton = canWrite ? ( const addButton = canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" onClick={() => setCreateOpenState(true)}>
<Plus data-icon="inline-start" /> <Plus data-icon="inline-start" />
Добавить агента Добавить агента
</Button> </Button>
@@ -431,8 +625,13 @@ function AgentsPage() {
<PageShell> <PageShell>
<PageHeader <PageHeader
title="Агенты" title="Агенты"
description="Ops console: invite, install, approve" description="Приглашение, установка и одобрение агентов"
actions={addButton} actions={
<>
<UpdatedAtLabel updatedAt={agentsQ.dataUpdatedAt} />
{addButton}
</>
}
/> />
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} /> <KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
@@ -443,16 +642,16 @@ function AgentsPage() {
<Frame dense spacing="sm"> <Frame dense spacing="sm">
<FrameHeader className="flex-row items-start justify-between gap-3"> <FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px"> <div className="flex min-w-0 flex-col gap-px">
<FrameTitle>Attention — pending approve</FrameTitle> <FrameTitle>Требуют одобрения</FrameTitle>
<FrameDescription> <FrameDescription>
{counts.pending} агент(ов) ждут одобрения (DNA approval inbox) {counts.pending} агент(ов) ждут одобрения
</FrameDescription> </FrameDescription>
</div> </div>
<div className="flex shrink-0 flex-wrap gap-2"> <div className="flex shrink-0 flex-wrap gap-2">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => setActiveTab('pending')} onClick={() => setSearch({ tab: 'pending' })}
> >
Показать Показать
</Button> </Button>
@@ -460,12 +659,12 @@ function AgentsPage() {
<Button <Button
size="sm" size="sm"
disabled={ disabled={
approveAllPending.isPending || pendingIds.length === 0 approveBulk.isPending || pendingIds.length === 0
} }
onClick={() => approveAllPending.mutate(pendingIds)} onClick={() => setApproveAllOpen(true)}
> >
<Check data-icon="inline-start" /> <Check data-icon="inline-start" />
Approve all Утвердить всех
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -473,15 +672,33 @@ function AgentsPage() {
</Frame> </Frame>
) : null} ) : null}
{counts.applyErrors > 0 ? ( {applyErrorAgents.length > 0 ? (
<Alert variant="warning"> <Frame dense spacing="sm">
<CircleAlertIcon /> <FrameHeader className="flex-row items-start justify-between gap-3">
<AlertTitle>Ошибки apply</AlertTitle> <div className="flex min-w-0 flex-col gap-px">
<AlertDescription> <FrameTitle>Ошибки применения политики</FrameTitle>
У {counts.applyErrors} агент(ов) есть last_apply_error — откройте <FrameDescription>
карточку для деталей. У {applyErrorAgents.length} агент(ов) последняя попытка apply
</AlertDescription> завершилась ошибкой — откройте карточку для деталей.
</Alert> </FrameDescription>
</div>
</FrameHeader>
<FramePanel className="flex flex-wrap gap-2">
{applyErrorAgents.map((a) => (
<Button
key={a.id}
size="sm"
variant="outline"
className="text-destructive"
title={a.last_apply_error ?? undefined}
onClick={() => setSearch({ agent: a.id })}
>
<CircleAlertIcon className="size-3.5" aria-hidden />
{a.name}
</Button>
))}
</FramePanel>
</Frame>
) : null} ) : null}
{view === 'table' ? ( {view === 'table' ? (
@@ -489,22 +706,27 @@ function AgentsPage() {
data={items} data={items}
filterFields={filterFields} filterFields={filterFields}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={(f) => setSearch({ f })}
onClearFilters={handleClearFilters} onClearFilters={handleClearFilters}
getFilterFieldValue={getFilterFieldValue} getFilterFieldValue={getFilterFieldValue}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={(q) => setSearch({ q })}
getSearchText={getSearchText} getSearchText={getSearchText}
tabs={[...AGENT_TABS]} tabs={[...AGENT_TABS]}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={(tab) => setSearch({ tab })}
tabFilter={tabFilter} tabFilter={tabFilter}
isLoading={agentsQ.isLoading} isLoading={agentsQ.isLoading}
isError={agentsQ.isError} isError={agentsQ.isError}
error={agentsQ.error} error={agentsQ.error}
onRetry={() => void agentsQ.refetch()} onRetry={() => void agentsQ.refetch()}
emptyAction={addButton} emptyAction={addButton}
filteredEmptyMessage={filteredEmptyMessage}
toolbarExtra={viewToggle} toolbarExtra={viewToggle}
enableRowSelection={canWrite}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
selectionToolbar={selectionToolbar}
onSelect={handleSelectAgent} onSelect={handleSelectAgent}
onApprove={(id) => approve.mutate(id)} onApprove={(id) => approve.mutate(id)}
approvePending={approve.isPending} approvePending={approve.isPending}
@@ -520,13 +742,13 @@ function AgentsPage() {
<AgentsFleetChrome <AgentsFleetChrome
tabs={countedTabs} tabs={countedTabs}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={(tab) => setSearch({ tab })}
filterFields={filterFields} filterFields={filterFields}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={(f) => setSearch({ f })}
onClearFilters={handleClearFilters} onClearFilters={handleClearFilters}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={(q) => setSearch({ q })}
toolbarExtra={viewToggle} toolbarExtra={viewToggle}
> >
<AgentCardsGrid <AgentCardsGrid
@@ -542,7 +764,7 @@ function AgentsPage() {
} }
emptyDescription={ emptyDescription={
items.length === 0 items.length === 0
? 'Создайте агента — он появится в каталоге как Invited с командой установки.' ? 'Создайте агента — он появится в каталоге со статусом «Приглашён» и командой установки.'
: undefined : undefined
} }
emptyAction={items.length === 0 ? addButton : undefined} emptyAction={items.length === 0 ? addButton : undefined}
@@ -551,7 +773,13 @@ function AgentsPage() {
</QueryState> </QueryState>
)} )}
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} /> <AddAgentSheet
open={createOpen}
onOpenChange={(open) => {
setCreateOpenState(open)
if (!open && addParam) setSearch({ add: false })
}}
/>
<AgentDetailSheet <AgentDetailSheet
agentId={detailAgentId ?? null} agentId={detailAgentId ?? null}
@@ -578,6 +806,45 @@ function AgentsPage() {
}} }}
disabled={removeAgent.isPending} disabled={removeAgent.isPending}
/> />
<ConfirmDialog
open={approveAllOpen}
onOpenChange={setApproveAllOpen}
title="Утвердить всех ожидающих?"
confirmLabel="Утвердить"
confirmVariant="default"
description={
pendingIds.length > 0
? `Будут одобрены ${pendingIds.length} агент(ов): ${formatNames(pendingNames)}. Агентам будет назначен общий набор правил по умолчанию.`
: 'Нет агентов, ожидающих одобрения.'
}
onConfirm={() => {
setApproveAllOpen(false)
approveBulk.mutate(pendingIds)
}}
disabled={approveBulk.isPending || pendingIds.length === 0}
/>
<ConfirmDialog
open={bulkDeleteIds !== null}
onOpenChange={(open) => {
if (!open) setBulkDeleteIds(null)
}}
title="Удалить выбранных агентов?"
description={
bulkDeleteIds && bulkDeleteIds.length > 0
? `Будут удалены ${bulkDeleteIds.length} агент(ов): ${formatNames(
selectedAgents
.filter((a) => bulkDeleteIds.includes(a.id))
.map((a) => a.name),
)}. Вместе с overrides, install-ссылками и статистикой. Действие нельзя отменить.`
: 'Вместе с overrides, install-ссылками и статистикой. Действие нельзя отменить.'
}
onConfirm={() => {
if (bulkDeleteIds) deleteBulk.mutate(bulkDeleteIds)
}}
disabled={deleteBulk.isPending}
/>
</PageShell> </PageShell>
) )
} }
+7 -5
View File
@@ -35,6 +35,7 @@ import {
} from '@/queries' } from '@/queries'
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart' import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
import { agentTrafficDropped } from '@/components/agents/agent-traffic' import { agentTrafficDropped } from '@/components/agents/agent-traffic'
import { UpdatedAtLabel } from '@/components/updated-at-label'
import type { Agent } from '@evofw/shared' import type { Agent } from '@evofw/shared'
export const Route = createFileRoute('/_auth/')({ export const Route = createFileRoute('/_auth/')({
@@ -75,7 +76,7 @@ function DashboardPage() {
}, },
{ {
id: 'dropped', id: 'dropped',
label: 'Dropped', label: 'Заблокировано',
value: d?.packets_dropped ?? '—', value: d?.packets_dropped ?? '—',
hint: 'сумма counters', hint: 'сумма counters',
to: '/stats', to: '/stats',
@@ -85,7 +86,7 @@ function DashboardPage() {
}, },
{ {
id: 'accepted', id: 'accepted',
label: 'Accepted', label: 'Пропущено',
value: d?.packets_accepted ?? '—', value: d?.packets_accepted ?? '—',
hint: 'сумма counters', hint: 'сумма counters',
to: '/stats', to: '/stats',
@@ -109,7 +110,7 @@ function DashboardPage() {
{ {
id: 'agents', id: 'agents',
title: 'Агенты', title: 'Агенты',
description: 'Enroll и approve', description: 'Подключение и одобрение агентов',
to: '/agents', to: '/agents',
icon: <ServerIcon aria-hidden />, icon: <ServerIcon aria-hidden />,
iconClassName: 'text-info', iconClassName: 'text-info',
@@ -117,7 +118,7 @@ function DashboardPage() {
{ {
id: 'lists', id: 'lists',
title: 'Списки', title: 'Списки',
description: 'Blocklists / sources', description: 'Блок-листы и источники',
to: '/lists', to: '/lists',
icon: <ListIcon aria-hidden />, icon: <ListIcon aria-hidden />,
iconClassName: 'text-primary', iconClassName: 'text-primary',
@@ -125,7 +126,7 @@ function DashboardPage() {
{ {
id: 'rules', id: 'rules',
title: 'Правила', title: 'Правила',
description: 'Allow / deny policy', description: 'Политика блокировки',
to: '/rules', to: '/rules',
icon: <BanIcon aria-hidden />, icon: <BanIcon aria-hidden />,
iconClassName: 'text-warning', iconClassName: 'text-warning',
@@ -139,6 +140,7 @@ function DashboardPage() {
<PageHeader <PageHeader
title="Панель управления" title="Панель управления"
description="Обзор агентов и пакетной статистики" description="Обзор агентов и пакетной статистики"
actions={<UpdatedAtLabel updatedAt={dash.dataUpdatedAt} />}
/> />
<OpsDashboard <OpsDashboard
isLoading={dash.isLoading} isLoading={dash.isLoading}
+77 -7
View File
@@ -137,12 +137,14 @@ function ListDetailPage() {
}), }),
}) })
} }
const text = addValue.trim() if (!addParse || addParse.valid === 0) {
if (!text) throw new Error('Введите значение') throw new Error('Нет валидных записей')
// Backend auto-classifies each token via parseListEntry (ip / cidr / hostname) }
// Отправляем только валидные уникальные токены; backend классифицирует
// каждый через parseListEntry (ip / cidr / hostname)
return apiFetch(`/api/v1/lists/${id}/entries`, { return apiFetch(`/api/v1/lists/${id}/entries`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ values: [text] }), body: JSON.stringify({ values: addParse.values }),
}) })
}, },
onSuccess: () => { onSuccess: () => {
@@ -291,10 +293,35 @@ function ListDetailPage() {
return [...kinds] return [...kinds]
}, [addMode, addValue]) }, [addMode, addValue])
/** Живой разбор вставки: валидные / не распознанные / дубликаты. */
const addParse = useMemo(() => {
if (addMode !== 'plaintext') return null
const text = addValue.trim()
if (!text) return null
const seen = new Set<string>()
let valid = 0
let invalid = 0
let duplicates = 0
for (const token of splitListPlaintext(text)) {
try {
const parsed = parseListEntry(token)
if (seen.has(parsed.value)) {
duplicates++
continue
}
seen.add(parsed.value)
valid++
} catch {
invalid++
}
}
return { valid, invalid, duplicates, values: [...seen] }
}, [addMode, addValue])
const canAdd = const canAdd =
addMode === 'nested' addMode === 'nested'
? Boolean(addListRef) ? Boolean(addListRef)
: Boolean(addValue.trim()) : Boolean(addParse && addParse.valid > 0)
if (listQ.isLoading) { if (listQ.isLoading) {
return ( return (
@@ -311,6 +338,29 @@ function ListDetailPage() {
) )
} }
if (listQ.isError) {
return (
<PageShell>
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{listQ.error?.message ?? 'Не удалось загрузить список'}</span>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => void listQ.refetch()}
>
Повторить
</Button>
</AlertDescription>
</Alert>
</PageShell>
)
}
if (!detail) { if (!detail) {
return ( return (
<PageShell> <PageShell>
@@ -566,6 +616,8 @@ function ListDetailPage() {
placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'} placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'}
onChange={(e) => setAddValue(e.target.value)} onChange={(e) => setAddValue(e.target.value)}
/> />
{addParse && addParse.valid > 0 ? (
<div className="flex flex-col gap-1.5">
{detectedKinds.length > 0 ? ( {detectedKinds.length > 0 ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
@@ -575,9 +627,27 @@ function ListDetailPage() {
<StatusBadge key={kind} status={kind} /> <StatusBadge key={kind} status={kind} />
))} ))}
</div> </div>
) : null}
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="text-success">
{addParse.valid} валидно
</span>
{addParse.invalid > 0 ? (
<span className="text-destructive">
{addParse.invalid} не распознано
</span>
) : null}
{addParse.duplicates > 0 ? (
<span className="text-muted-foreground">
{addParse.duplicates} дубликатов будет пропущено
</span>
) : null}
</div>
</div>
) : addValue.trim() ? ( ) : addValue.trim() ? (
<p className="text-muted-foreground text-xs"> <p className="text-destructive text-xs">
Не распознано — проверьте формат перед добавлением. Нет валидных записей — проверьте формат (IP, CIDR или
домен).
</p> </p>
) : null} ) : null}
</Field> </Field>
+176 -87
View File
@@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Plus, RefreshCwIcon } from 'lucide-react' import { Plus, RefreshCwIcon } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { Filter } from '@/components/reui/filters' import type { Filter } from '@/components/reui/filters'
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit' import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
import { import {
@@ -16,6 +19,12 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries' import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions' import { useCan } from '@/lib/permissions'
import {
searchToFilters,
searchToFlag,
searchToQ,
searchToTab,
} from '@/lib/search-params'
import { import {
Autocomplete, Autocomplete,
AutocompleteContent, AutocompleteContent,
@@ -25,6 +34,8 @@ import {
AutocompleteList, AutocompleteList,
} from '@/components/reui/autocomplete' } from '@/components/reui/autocomplete'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { FormSheet } from '@/components/form-sheet'
import { FieldError } from '@evofw/ui/components/field'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field' import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input' import { Input } from '@evofw/ui/components/input'
@@ -44,14 +55,61 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@evofw/ui/components/sheet' } from '@evofw/ui/components/sheet'
import { guessListSourceFromInput } from '@evofw/shared'
const LIST_TAB_IDS = LIST_TABS.map((t) => t.id) as string[]
type ListsSearch = {
create?: boolean
tab?: string
q?: string
f?: Filter[]
}
/** URL-состояние страницы: create/tab/q/f (deep-link и refresh сохраняют вид). */
function parseListsSearch(search: Record<string, unknown>): ListsSearch {
const f = searchToFilters(search.f)
return {
create: searchToFlag(search.create),
tab: searchToTab(search.tab, LIST_TAB_IDS),
q: searchToQ(search.q),
f: f.length > 0 ? f : undefined,
}
}
export const Route = createFileRoute('/_auth/lists/')({ export const Route = createFileRoute('/_auth/lists/')({
validateSearch: parseListsSearch,
component: ListsPage, component: ListsPage,
}) })
type CreateSource = 'static' | 'json_url' | 'evobgp_community' type CreateSource = 'static' | 'json_url' | 'evobgp_community'
function buildCreateListSchema(resolveCommunity: (input: string) => string) {
return z
.object({
name: z.string().trim().min(2, 'Минимум 2 символа'),
source: z.enum(['static', 'json_url', 'evobgp_community']),
extra: z.string(),
})
.superRefine((v, ctx) => {
if (v.source === 'json_url' && !/^https?:\/\//i.test(v.extra.trim())) {
ctx.addIssue({
code: 'custom',
path: ['extra'],
message: 'Укажите полный URL, начиная с http:// или https://',
})
}
if (v.source === 'evobgp_community' && !resolveCommunity(v.extra)) {
ctx.addIssue({
code: 'custom',
path: ['extra'],
message: 'Выберите community из списка или укажите ID',
})
}
})
}
type CreateListValues = z.infer<ReturnType<typeof buildCreateListSchema>>
const CREATE_SOURCE_ITEMS = [ const CREATE_SOURCE_ITEMS = [
{ value: 'static', label: 'Ручной' }, { value: 'static', label: 'Ручной' },
{ value: 'json_url', label: 'JSON по URL' }, { value: 'json_url', label: 'JSON по URL' },
@@ -66,24 +124,51 @@ const CREATE_SOURCE_ITEMS = [
* Docs: https://reui.io/blocks * Docs: https://reui.io/blocks
*/ */
function ListsPage() { function ListsPage() {
const navigate = useNavigate() const navigate = useNavigate({ from: Route.fullPath })
const qc = useQueryClient() const qc = useQueryClient()
const canWrite = useCan()('fw:lists:write') const canWrite = useCan()('fw:lists:write')
const [createOpen, setCreateOpen] = useState(false) const { create: createParam, q: qParam, f: fParam } = Route.useSearch()
const [name, setName] = useState('') const activeTab = Route.useSearch().tab ?? 'all'
const [source, setSource] = useState<CreateSource>('static') const searchQuery = qParam ?? ''
const [extra, setExtra] = useState('') const filters = fParam ?? []
/** Resolved EvoBGP community UUID (Base UI Autocomplete stores label in input). */ const [createOpenState, setCreateOpenState] = useState(false)
const [communityId, setCommunityId] = useState('') const createOpen = createOpenState || createParam === true
const [filters, setFilters] = useState<Filter[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('all')
const [deleteListId, setDeleteListId] = useState<string | null>(null) const [deleteListId, setDeleteListId] = useState<string | null>(null)
const createForm = useForm<CreateListValues>({
resolver: zodResolver(buildCreateListSchema((input) => resolveCommunityId(input))),
defaultValues: { name: '', source: 'static', extra: '' },
mode: 'onBlur',
})
const createSource = createForm.watch('source') as CreateSource
const createExtra = createForm.watch('extra')
/** Обновление URL-состояния (tab/q/f) без перезагрузки. */
const setListSearch = useCallback(
(next: { tab?: string; q?: string; f?: Filter[]; create?: boolean }) => {
void navigate({
search: (prev) => {
const base = parseListsSearch(prev as Record<string, unknown>)
return {
create:
next.create !== undefined
? next.create || undefined
: base.create,
tab: next.tab !== undefined ? searchToTab(next.tab, LIST_TAB_IDS) : base.tab,
q: next.q !== undefined ? searchToQ(next.q) : base.q,
f: next.f !== undefined ? next.f : base.f,
}
},
replace: true,
})
},
[navigate],
)
const listsQ = useQuery(listsQueryOptions()) const listsQ = useQuery(listsQueryOptions())
const communitiesQ = useQuery({ const communitiesQ = useQuery({
...evobgpCommunitiesQueryOptions(), ...evobgpCommunitiesQueryOptions(),
enabled: createOpen && source === 'evobgp_community', enabled: createOpen && createSource === 'evobgp_community',
}) })
const communityItems = useMemo( const communityItems = useMemo(
@@ -111,25 +196,22 @@ function ListsPage() {
} }
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async (values: CreateListValues) => {
const config: Record<string, unknown> = {} const config: Record<string, unknown> = {}
if (source === 'json_url') { if (values.source === 'json_url') {
config.url = extra.trim() config.url = values.extra.trim()
} else if (source === 'evobgp_community') { } else if (values.source === 'evobgp_community') {
config.community_id = communityId || resolveCommunityId(extra) config.community_id = resolveCommunityId(values.extra)
} }
return apiFetch<{ id: string }>('/api/v1/lists', { return apiFetch<{ id: string }>('/api/v1/lists', {
method: 'POST', method: 'POST',
body: JSON.stringify({ name, type: source, config }), body: JSON.stringify({ name: values.name, type: values.source, config }),
}) })
}, },
onSuccess: (row) => { onSuccess: (row) => {
toast.success('Список создан') toast.success('Список создан')
setName('') createForm.reset()
setExtra('') setCreateOpenState(false)
setCommunityId('')
setSource('static')
setCreateOpen(false)
void qc.invalidateQueries({ queryKey: ['lists'] }) void qc.invalidateQueries({ queryKey: ['lists'] })
void navigate({ to: '/lists/$id', params: { id: row.id } }) void navigate({ to: '/lists/$id', params: { id: row.id } })
}, },
@@ -176,13 +258,6 @@ function ListsPage() {
[], [],
) )
const canCreate =
Boolean(name.trim()) &&
(source === 'static' ||
(source === 'json_url' && Boolean(extra.trim())) ||
(source === 'evobgp_community' &&
Boolean(communityId || resolveCommunityId(extra))))
return ( return (
<PageShell> <PageShell>
<PageHeader <PageHeader
@@ -202,7 +277,7 @@ function ListsPage() {
Обновить Обновить
</Button> </Button>
{canWrite ? ( {canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" onClick={() => setCreateOpenState(true)}>
<Plus data-icon="inline-start" /> <Plus data-icon="inline-start" />
Создать Создать
</Button> </Button>
@@ -219,16 +294,16 @@ function ListsPage() {
getRowId={(r) => r.id} getRowId={(r) => r.id}
filterFields={listFilterFields} filterFields={listFilterFields}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={(f) => setListSearch({ f })}
onClearFilters={() => setFilters([])} onClearFilters={() => setListSearch({ f: [], q: '' })}
getFilterFieldValue={getFilterFieldValue} getFilterFieldValue={getFilterFieldValue}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={(q) => setListSearch({ q })}
searchPlaceholder="Поиск списков…" searchPlaceholder="Поиск списков…"
getSearchText={getSearchText} getSearchText={getSearchText}
tabs={[...LIST_TABS]} tabs={[...LIST_TABS]}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={(tab) => setListSearch({ tab })}
tabFilter={tabFilter} tabFilter={tabFilter}
isLoading={listsQ.isLoading} isLoading={listsQ.isLoading}
isError={listsQ.isError} isError={listsQ.isError}
@@ -241,7 +316,7 @@ function ListsPage() {
title: 'Нет списков', title: 'Нет списков',
description: 'Создайте первый список для политики firewall.', description: 'Создайте первый список для политики firewall.',
action: ( action: (
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" onClick={() => setCreateOpenState(true)}>
<Plus data-icon="inline-start" /> <Plus data-icon="inline-start" />
Создать Создать
</Button> </Button>
@@ -262,34 +337,62 @@ function ListsPage() {
disabled={removeList.isPending} disabled={removeList.isPending}
/> />
<Sheet open={createOpen} onOpenChange={setCreateOpen}> <FormSheet
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md"> open={createOpen}
<SheetHeader className="shrink-0"> onOpenChange={(open) => {
<SheetTitle>Новый список</SheetTitle> setCreateOpenState(open)
<SheetDescription> if (!open && createParam) {
После создания откроется страница со содержимым списка. setListSearch({ create: false })
</SheetDescription> }
</SheetHeader> }}
<ScrollArea className="flex-1 px-4"> title="Новый список"
<div className="grid auto-rows-min gap-4 py-2 pb-4"> description="После создания откроется страница со содержимым списка."
<Field> form={createForm}
onSubmit={async (values) => {
await create.mutateAsync(values)
}}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpenState(false)}
>
Отмена
</Button>
<LoadingButton type="submit" isLoading={create.isPending}>
Создать
</LoadingButton>
</>
}
>
<Field
data-invalid={Boolean(createForm.formState.errors.name) || undefined}
>
<FieldLabel htmlFor="list-name">Имя</FieldLabel> <FieldLabel htmlFor="list-name">Имя</FieldLabel>
<Input <Input
id="list-name" id="list-name"
value={name} placeholder="spamhaus-drop"
onChange={(e) => setName(e.target.value)} {...createForm.register('name')}
aria-invalid={
Boolean(createForm.formState.errors.name) || undefined
}
/> />
<FieldError errors={[createForm.formState.errors.name]} />
</Field> </Field>
<Field> <Field>
<FieldLabel>Источник</FieldLabel> <FieldLabel>Источник</FieldLabel>
<Controller
control={createForm.control}
name="source"
render={({ field }) => (
<Select <Select
items={[...CREATE_SOURCE_ITEMS]} items={[...CREATE_SOURCE_ITEMS]}
value={source} value={field.value}
onValueChange={(v) => { onValueChange={(v) => {
if (!v) return if (!v) return
setSource(v as CreateSource) field.onChange(v)
setExtra('') createForm.setValue('extra', '')
setCommunityId('')
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
@@ -303,38 +406,40 @@ function ListsPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)}
/>
</Field> </Field>
{source === 'json_url' ? ( {createSource === 'json_url' ? (
<Field> <Field
data-invalid={Boolean(createForm.formState.errors.extra) || undefined}
>
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel> <FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
<Input <Input
id="list-url" id="list-url"
value={extra}
placeholder="https://…" placeholder="https://…"
onChange={(e) => { {...createForm.register('extra')}
const v = e.target.value aria-invalid={
setExtra(v) Boolean(createForm.formState.errors.extra) || undefined
if (guessListSourceFromInput(v) === 'json_url') {
setSource('json_url')
} }
}}
/> />
<FieldError errors={[createForm.formState.errors.extra]} />
</Field> </Field>
) : null} ) : null}
{source === 'evobgp_community' ? ( {createSource === 'evobgp_community' ? (
<Field> <Field
data-invalid={Boolean(createForm.formState.errors.extra) || undefined}
>
<FieldLabel>BGP community</FieldLabel> <FieldLabel>BGP community</FieldLabel>
<Autocomplete <Autocomplete
items={communityItems} items={communityItems}
value={extra} value={createExtra}
onValueChange={(v) => { onValueChange={(v) => {
setExtra(v) createForm.setValue('extra', v)
// Base UI {value,label}: input stores label by default — // Base UI {value,label}: input stores label by default —
// keep UUID separately for config.community_id. // UUID резолвится на submit через resolveCommunityId.
// Preview: https://reui.io/preview/base/components/c-autocomplete-9 // Preview: https://reui.io/preview/base/components/c-autocomplete-9
// Docs: https://reui.io/docs/components/base/autocomplete // Docs: https://reui.io/docs/components/base/autocomplete
// Base UI: https://base-ui.com/react/components/autocomplete // Base UI: https://base-ui.com/react/components/autocomplete
setCommunityId(resolveCommunityId(v))
}} }}
itemToStringValue={(item) => item.label} itemToStringValue={(item) => item.label}
> >
@@ -348,9 +453,7 @@ function ListsPage() {
/> />
<AutocompleteContent> <AutocompleteContent>
<AutocompleteEmpty> <AutocompleteEmpty>
{communitiesQ.isLoading {communitiesQ.isLoading ? 'Загрузка…' : 'Нет совпадений'}
? 'Загрузка…'
: 'Нет совпадений'}
</AutocompleteEmpty> </AutocompleteEmpty>
<AutocompleteList> <AutocompleteList>
{(item) => ( {(item) => (
@@ -361,24 +464,10 @@ function ListsPage() {
</AutocompleteList> </AutocompleteList>
</AutocompleteContent> </AutocompleteContent>
</Autocomplete> </Autocomplete>
<FieldError errors={[createForm.formState.errors.extra]} />
</Field> </Field>
) : null} ) : null}
</div> </FormSheet>
</ScrollArea>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Отмена
</Button>
<LoadingButton
disabled={!canCreate}
isLoading={create.isPending}
onClick={() => create.mutate()}
>
Создать
</LoadingButton>
</SheetFooter>
</SheetContent>
</Sheet>
</PageShell> </PageShell>
) )
} }
+218 -78
View File
@@ -1,7 +1,12 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { parseListEntry } from '@evofw/shared'
import { import {
CircleAlertIcon,
ListIcon, ListIcon,
ShieldIcon, ShieldIcon,
UsersIcon, UsersIcon,
@@ -9,10 +14,17 @@ import {
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table' import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
import { PageShell, DetailPanel } from '@/components/reui-kit' import { PageShell, DetailPanel } from '@/components/reui-kit'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { FormSheet } from '@/components/form-sheet'
import { LoadingButton } from '@/components/loading-button'
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable' import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
import { import {
agentsQueryOptions, agentsQueryOptions,
@@ -23,7 +35,7 @@ import {
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { Checkbox } from '@evofw/ui/components/checkbox' import { Checkbox } from '@evofw/ui/components/checkbox'
import { Field, FieldLabel } from '@evofw/ui/components/field' import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input' import { Input } from '@evofw/ui/components/input'
import { Switch } from '@evofw/ui/components/switch' import { Switch } from '@evofw/ui/components/switch'
import { import {
@@ -66,6 +78,68 @@ export const Route = createFileRoute('/_auth/rules/$setId')({
type SourceKind = 'list' | 'cidr' | 'hostname' type SourceKind = 'list' | 'cidr' | 'hostname'
const ruleSchema = z
.object({
action: z.enum(['allow', 'deny']),
source: z.enum(['list', 'cidr', 'hostname']),
listId: z.string(),
value: z.string(),
})
.superRefine((v, ctx) => {
if (v.source === 'list') {
if (!v.listId) {
ctx.addIssue({
code: 'custom',
path: ['listId'],
message: 'Выберите список',
})
}
return
}
const raw = v.value.trim()
if (!raw) {
ctx.addIssue({
code: 'custom',
path: ['value'],
message: v.source === 'cidr' ? 'Укажите CIDR' : 'Укажите DNS-имя',
})
return
}
let parsed: ReturnType<typeof parseListEntry> | null = null
try {
parsed = parseListEntry(raw)
} catch {
parsed = null
}
if (v.source === 'cidr' && (!parsed || (parsed.kind !== 'cidr' && parsed.kind !== 'ip'))) {
ctx.addIssue({
code: 'custom',
path: ['value'],
message: 'Ожидается CIDR, например 203.0.113.0/24',
})
}
if (v.source === 'hostname' && (!parsed || parsed.kind !== 'hostname')) {
ctx.addIssue({
code: 'custom',
path: ['value'],
message: 'Ожидается DNS-имя, например bad.example.com',
})
}
})
type RuleValues = z.infer<typeof ruleSchema>
const RULE_ACTION_ITEMS = [
{ value: 'deny', label: 'deny — блокировать' },
{ value: 'allow', label: 'allow — пропускать' },
] as const
const RULE_SOURCE_ITEMS = [
{ value: 'cidr', label: 'CIDR / IP' },
{ value: 'hostname', label: 'DNS-имя' },
{ value: 'list', label: 'IP-список' },
] as const
/** /**
* Policy set detail — Sortable rules (ReUI PRO) + agents. * Policy set detail — Sortable rules (ReUI PRO) + agents.
* Preview: https://reui.io/preview/base/components/c-sortable-5 · settings-8 · settings-3 * Preview: https://reui.io/preview/base/components/c-sortable-5 · settings-8 · settings-3
@@ -80,14 +154,16 @@ function PolicySetDetailPage() {
const listsQ = useQuery(listsQueryOptions()) const listsQ = useQuery(listsQueryOptions())
const [ruleOpen, setRuleOpen] = useState(false) const [ruleOpen, setRuleOpen] = useState(false)
const [action, setAction] = useState<'allow' | 'deny'>('deny')
const [source, setSource] = useState<SourceKind>('cidr')
const [listId, setListId] = useState('')
const [cidr, setCidr] = useState('')
const [hostname, setHostname] = useState('')
const [selectedAgents, setSelectedAgents] = useState<string[] | null>(null) const [selectedAgents, setSelectedAgents] = useState<string[] | null>(null)
const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null) const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null)
const ruleForm = useForm<RuleValues>({
resolver: zodResolver(ruleSchema),
defaultValues: { action: 'deny', source: 'cidr', listId: '', value: '' },
mode: 'onBlur',
})
const ruleSource = ruleForm.watch('source')
const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? [] const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? []
const approvedAgents = useMemo( const approvedAgents = useMemo(
@@ -144,14 +220,16 @@ function PolicySetDetailPage() {
}) })
const createRule = useMutation({ const createRule = useMutation({
mutationFn: () => { mutationFn: (values: RuleValues) => {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
set_id: setId, set_id: setId,
action, action: values.action,
}
if (values.source === 'list') body.list_id = values.listId
if (values.source === 'cidr') body.cidr = values.value.trim()
if (values.source === 'hostname') {
body.hostname = values.value.trim().toLowerCase()
} }
if (source === 'list') body.list_id = listId
if (source === 'cidr') body.cidr = cidr.trim()
if (source === 'hostname') body.hostname = hostname.trim()
return apiFetch('/api/v1/rules', { return apiFetch('/api/v1/rules', {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -160,9 +238,7 @@ function PolicySetDetailPage() {
onSuccess: () => { onSuccess: () => {
toast.success('Правило создано') toast.success('Правило создано')
setRuleOpen(false) setRuleOpen(false)
setCidr('') ruleForm.reset()
setHostname('')
setListId('')
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] }) void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
}, },
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
@@ -255,13 +331,6 @@ function PolicySetDetailPage() {
state: { rowSelection: agentRowSelection }, state: { rowSelection: agentRowSelection },
}) })
const canCreate =
source === 'list'
? Boolean(listId)
: source === 'cidr'
? Boolean(cidr.trim())
: Boolean(hostname.trim())
if (setQ.isLoading) { if (setQ.isLoading) {
return ( return (
<PageShell> <PageShell>
@@ -271,6 +340,31 @@ function PolicySetDetailPage() {
) )
} }
if (setQ.isError) {
return (
<PageShell>
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>
{setQ.error?.message ?? 'Не удалось загрузить набор правил'}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => void setQ.refetch()}
>
Повторить
</Button>
</AlertDescription>
</Alert>
</PageShell>
)
}
if (!setQ.data) { if (!setQ.data) {
return ( return (
<PageShell> <PageShell>
@@ -411,57 +505,103 @@ function PolicySetDetailPage() {
disabled={removeRule.isPending} disabled={removeRule.isPending}
/> />
<Sheet open={ruleOpen} onOpenChange={setRuleOpen}> <FormSheet
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md"> open={ruleOpen}
<SheetHeader className="shrink-0"> onOpenChange={(open) => {
<SheetTitle>Новое правило</SheetTitle> setRuleOpen(open)
<SheetDescription> if (!open) ruleForm.reset()
Один источник: список, CIDR или DNS-имя (priority — в конец) }}
</SheetDescription> title="Новое правило"
</SheetHeader> description="Один источник: список, CIDR или DNS-имя (priority — в конец)"
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4"> form={ruleForm}
onSubmit={async (values) => {
await createRule.mutateAsync(values)
}}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => setRuleOpen(false)}
>
Отмена
</Button>
<LoadingButton type="submit" isLoading={createRule.isPending}>
Создать
</LoadingButton>
</>
}
>
<Field> <Field>
<FieldLabel>Action</FieldLabel> <FieldLabel>Действие</FieldLabel>
<Controller
control={ruleForm.control}
name="action"
render={({ field }) => (
<Select <Select
value={action} items={RULE_ACTION_ITEMS}
value={field.value}
onValueChange={(v) => { onValueChange={(v) => {
if (v) setAction(v as 'allow' | 'deny') if (v === 'allow' || v === 'deny') field.onChange(v)
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="deny">deny</SelectItem> {RULE_ACTION_ITEMS.map((item) => (
<SelectItem value="allow">allow</SelectItem> <SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
)}
/>
</Field> </Field>
<Field> <Field>
<FieldLabel>Источник</FieldLabel> <FieldLabel>Источник</FieldLabel>
<Controller
control={ruleForm.control}
name="source"
render={({ field }) => (
<Select <Select
value={source} items={[...RULE_SOURCE_ITEMS]}
value={field.value}
onValueChange={(v) => { onValueChange={(v) => {
if (v) setSource(v as SourceKind) if (v === 'list' || v === 'cidr' || v === 'hostname') {
field.onChange(v)
ruleForm.setValue('value', '')
}
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="cidr">CIDR / IP</SelectItem> {RULE_SOURCE_ITEMS.map((item) => (
<SelectItem value="hostname">DNS-имя</SelectItem> <SelectItem key={item.value} value={item.value}>
<SelectItem value="list">IP-список</SelectItem> {item.label}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
)}
/>
</Field> </Field>
{source === 'list' ? ( {ruleSource === 'list' ? (
<Field> <Field
data-invalid={Boolean(ruleForm.formState.errors.listId) || undefined}
>
<FieldLabel>Список</FieldLabel> <FieldLabel>Список</FieldLabel>
<Controller
control={ruleForm.control}
name="listId"
render={({ field }) => (
<Select <Select
items={listSelectItems} items={listSelectItems}
value={listId || null} value={field.value || null}
onValueChange={(v) => setListId(v ?? '')} onValueChange={(v) => field.onChange(v ?? '')}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Выберите список" /> <SelectValue placeholder="Выберите список" />
@@ -474,44 +614,44 @@ function PolicySetDetailPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</Field> )}
) : null}
{source === 'cidr' ? (
<Field>
<FieldLabel htmlFor="cidr">CIDR</FieldLabel>
<Input
id="cidr"
value={cidr}
onChange={(e) => setCidr(e.target.value)}
placeholder="203.0.113.0/24"
/> />
<FieldError errors={[ruleForm.formState.errors.listId]} />
</Field> </Field>
) : null} ) : null}
{source === 'hostname' ? ( {ruleSource === 'cidr' ? (
<Field> <Field
<FieldLabel htmlFor="host">DNS-имя</FieldLabel> data-invalid={Boolean(ruleForm.formState.errors.value) || undefined}
<Input
id="host"
value={hostname}
onChange={(e) => setHostname(e.target.value)}
placeholder="bad.example.com"
/>
</Field>
) : null}
</div>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => setRuleOpen(false)}>
Отмена
</Button>
<Button
disabled={!canCreate || createRule.isPending}
onClick={() => createRule.mutate()}
> >
Создать <FieldLabel htmlFor="rule-cidr">CIDR</FieldLabel>
</Button> <Input
</SheetFooter> id="rule-cidr"
</SheetContent> placeholder="203.0.113.0/24"
</Sheet> {...ruleForm.register('value')}
aria-invalid={
Boolean(ruleForm.formState.errors.value) || undefined
}
/>
<FieldError errors={[ruleForm.formState.errors.value]} />
</Field>
) : null}
{ruleSource === 'hostname' ? (
<Field
data-invalid={Boolean(ruleForm.formState.errors.value) || undefined}
>
<FieldLabel htmlFor="rule-host">DNS-имя</FieldLabel>
<Input
id="rule-host"
placeholder="bad.example.com"
{...ruleForm.register('value')}
aria-invalid={
Boolean(ruleForm.formState.errors.value) || undefined
}
/>
<FieldError errors={[ruleForm.formState.errors.value]} />
</Field>
) : null}
</FormSheet>
</PageShell> </PageShell>
) )
} }
+60 -11
View File
@@ -5,6 +5,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { searchToFilters, searchToFlag, searchToQ } from '@/lib/search-params'
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit' import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
@@ -28,7 +29,24 @@ import {
} from '@evofw/ui/components/sheet' } from '@evofw/ui/components/sheet'
import type { PolicySet } from '@evofw/shared' import type { PolicySet } from '@evofw/shared'
type RulesSearch = {
create?: boolean
q?: string
f?: Filter[]
}
/** URL-состояние страницы: create/q/f (deep-link и refresh сохраняют вид). */
function parseRulesSearch(search: Record<string, unknown>): RulesSearch {
const f = searchToFilters(search.f)
return {
create: searchToFlag(search.create),
q: searchToQ(search.q),
f: f.length > 0 ? f : undefined,
}
}
export const Route = createFileRoute('/_auth/rules/')({ export const Route = createFileRoute('/_auth/rules/')({
validateSearch: parseRulesSearch,
component: PolicySetsPage, component: PolicySetsPage,
}) })
@@ -39,14 +57,37 @@ export const Route = createFileRoute('/_auth/rules/')({
* Create sheet: https://reui.io/preview/base/sheet-8 * Create sheet: https://reui.io/preview/base/sheet-8
*/ */
function PolicySetsPage() { function PolicySetsPage() {
const navigate = useNavigate() const navigate = useNavigate({ from: Route.fullPath })
const qc = useQueryClient() const qc = useQueryClient()
const setsQ = useQuery(policySetsQueryOptions()) const setsQ = useQuery(policySetsQueryOptions())
const [sheetOpen, setSheetOpen] = useState(false) const { create: createParam, q: qParam, f: fParam } = Route.useSearch()
const searchQuery = qParam ?? ''
const filters = fParam ?? []
const [sheetOpenState, setSheetOpenState] = useState(false)
const sheetOpen = sheetOpenState || createParam === true
/** Обновление URL-состояния (q/f) без перезагрузки. */
const setRulesSearch = useCallback(
(next: { q?: string; f?: Filter[]; create?: boolean }) => {
void navigate({
search: (prev) => {
const base = parseRulesSearch(prev as Record<string, unknown>)
return {
create:
next.create !== undefined
? next.create || undefined
: base.create,
q: next.q !== undefined ? searchToQ(next.q) : base.q,
f: next.f !== undefined ? next.f : base.f,
}
},
replace: true,
})
},
[navigate],
)
const [name, setName] = useState('') const [name, setName] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [filters, setFilters] = useState<Filter[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
const create = useMutation({ const create = useMutation({
@@ -63,7 +104,7 @@ function PolicySetsPage() {
toast.success('Набор создан') toast.success('Набор создан')
setName('') setName('')
setDescription('') setDescription('')
setSheetOpen(false) setSheetOpenState(false)
void qc.invalidateQueries({ queryKey: ['policy-sets'] }) void qc.invalidateQueries({ queryKey: ['policy-sets'] })
void navigate({ to: '/rules/$setId', params: { setId: row.id } }) void navigate({ to: '/rules/$setId', params: { setId: row.id } })
}, },
@@ -203,7 +244,7 @@ function PolicySetsPage() {
const canCreate = useCan()('fw:policies:write') const canCreate = useCan()('fw:policies:write')
const addButton = canCreate ? ( const addButton = canCreate ? (
<Button size="sm" onClick={() => setSheetOpen(true)}> <Button size="sm" onClick={() => setSheetOpenState(true)}>
<Plus data-icon="inline-start" /> <Plus data-icon="inline-start" />
Новый набор Новый набор
</Button> </Button>
@@ -225,11 +266,11 @@ function PolicySetsPage() {
getRowId={(r) => r.id} getRowId={(r) => r.id}
filterFields={filterFields} filterFields={filterFields}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={(f) => setRulesSearch({ f })}
onClearFilters={() => setFilters([])} onClearFilters={() => setRulesSearch({ f: [], q: '' })}
getFilterFieldValue={getFilterFieldValue} getFilterFieldValue={getFilterFieldValue}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={(q) => setRulesSearch({ q })}
searchPlaceholder="Поиск наборов…" searchPlaceholder="Поиск наборов…"
getSearchText={getSearchText} getSearchText={getSearchText}
isLoading={setsQ.isLoading} isLoading={setsQ.isLoading}
@@ -260,7 +301,15 @@ function PolicySetsPage() {
disabled={remove.isPending} disabled={remove.isPending}
/> />
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}> <Sheet
open={sheetOpen}
onOpenChange={(open) => {
setSheetOpenState(open)
if (!open && createParam) {
void navigate({ to: '/rules', search: { create: undefined }, replace: true })
}
}}
>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md"> <SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0"> <SheetHeader className="shrink-0">
<SheetTitle>Новый набор</SheetTitle> <SheetTitle>Новый набор</SheetTitle>
@@ -289,7 +338,7 @@ function PolicySetsPage() {
</Field> </Field>
</div> </div>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t"> <SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => setSheetOpen(false)}> <Button variant="outline" onClick={() => setSheetOpenState(false)}>
Отмена Отмена
</Button> </Button>
<Button <Button
+171 -52
View File
@@ -1,8 +1,13 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useEffect, useRef, useState } from 'react' import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useEffect, useState } from 'react'
import { EyeIcon, EyeOffIcon } from 'lucide-react'
import { PageHeader, PageShell } from '@/components/reui-kit' import { PageHeader, PageShell } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import { import {
Frame, Frame,
FrameDescription, FrameDescription,
@@ -15,11 +20,14 @@ import { SettingRow } from '@/components/setting-row'
import { settingsQueryOptions } from '@/queries' import { settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions' import { useCan } from '@/lib/permissions'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldError } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input' import { Input } from '@evofw/ui/components/input'
import { Switch } from '@evofw/ui/components/switch' import { Switch } from '@evofw/ui/components/switch'
/** /**
* Control plane settings — single page: PageShell + Frame + SettingRow. * Control plane settings — single page: PageShell + Frame + SettingRow.
* RHF+zod: валидация полей, dirty-state, reveal токена.
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 * Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
*/ */
@@ -27,26 +35,63 @@ export const Route = createFileRoute('/_auth/settings')({
component: SettingsPage, component: SettingsPage,
}) })
const settingsSchema = z.object({
enroll_seed: z
.string()
.trim()
.min(1, 'Обязательное поле — используется в install.sh'),
evobgp_api_url: z
.string()
.trim()
.refine(
(v) => v === '' || /^https?:\/\//i.test(v),
'URL должен начинаться с http:// или https://',
),
evobgp_api_token: z.string(),
agent_sync_interval_sec: z
.string()
.trim()
.refine((v) => {
const n = Number(v)
return Number.isInteger(n) && n >= 30 && n <= 86400
}, 'Целое число секунд, 30–86400'),
show_quick_actions: z.string(),
})
type SettingsValues = z.infer<typeof settingsSchema>
const EMPTY_VALUES: SettingsValues = {
enroll_seed: '',
evobgp_api_url: '',
evobgp_api_token: '',
agent_sync_interval_sec: '60',
show_quick_actions: 'true',
}
function SettingsPage() { function SettingsPage() {
const qc = useQueryClient() const qc = useQueryClient()
const settingsQ = useQuery(settingsQueryOptions()) const settingsQ = useQuery(settingsQueryOptions())
const canSave = useCan()('fw:settings:admin') const canSave = useCan()('fw:settings:admin')
const [form, setForm] = useState<Record<string, string>>({})
// Seed the form once — a background refetch must not wipe in-progress edits. const form = useForm<SettingsValues>({
const initialized = useRef(false) resolver: zodResolver(settingsSchema),
defaultValues: EMPTY_VALUES,
mode: 'onBlur',
})
// Seed once — background refetch не должен затирать правки в процессе.
useEffect(() => { useEffect(() => {
if (settingsQ.data && !initialized.current) { if (settingsQ.data) {
initialized.current = true form.reset({ ...EMPTY_VALUES, ...settingsQ.data })
setForm(settingsQ.data)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsQ.data]) }, [settingsQ.data])
const save = useMutation({ const save = useMutation({
mutationFn: () => mutationFn: (values: SettingsValues) =>
apiFetch('/api/v1/settings', { apiFetch('/api/v1/settings', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(form), body: JSON.stringify(values),
}), }),
onSuccess: () => { onSuccess: () => {
toast.success('Сохранено') toast.success('Сохранено')
@@ -56,37 +101,25 @@ function SettingsPage() {
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}) })
const showQuickActions = form.show_quick_actions !== 'false' const isDirty = form.formState.isDirty
const fields = [
{
key: 'enroll_seed',
label: 'Enroll seed',
hint: 'X-EvoFW-Seed для install.sh',
},
{
key: 'evobgp_api_url',
label: 'EvoBGP API URL',
hint: 'Источник community prefixes',
},
{
key: 'evobgp_api_token',
label: 'EvoBGP API token',
hint: 'Bearer для интеграции',
},
{
key: 'agent_sync_interval_sec',
label: 'Agent sync interval (sec)',
hint: 'Рекомендуется 30–60',
},
]
return ( return (
<PageShell> <PageShell>
<PageHeader <PageHeader
title="Настройки" title="Настройки"
description="Интеграции и enroll — settings-16" description="Интеграции, enroll и интервал синхронизации"
actions={
isDirty ? (
<Badge variant="warning-light" size="sm">
Есть несохранённые изменения
</Badge>
) : null
}
/> />
<form
onSubmit={form.handleSubmit((values) => save.mutateAsync(values))}
className="flex flex-col gap-4 md:gap-6"
>
<Frame dense spacing="sm"> <Frame dense spacing="sm">
<FrameHeader> <FrameHeader>
<div> <div>
@@ -98,39 +131,85 @@ function SettingsPage() {
</FrameHeader> </FrameHeader>
<FramePanel className="p-0"> <FramePanel className="p-0">
<div className="flex flex-col"> <div className="flex flex-col">
{fields.map((f, i) => (
<SettingRow <SettingRow
key={f.key} title="Enroll seed"
title={f.label} description="X-EvoFW-Seed для install.sh"
description={f.hint} labelFor="enroll_seed"
labelFor={f.key}
last={false} last={false}
> >
<Field data-invalid={Boolean(form.formState.errors.enroll_seed) || undefined}>
<Input <Input
id={f.key} id="enroll_seed"
type={f.key.includes('token') ? 'password' : 'text'} {...form.register('enroll_seed')}
value={form[f.key] ?? ''} aria-invalid={Boolean(form.formState.errors.enroll_seed) || undefined}
onChange={(e) => />
setForm((prev) => ({ ...prev, [f.key]: e.target.value })) <FieldError errors={[form.formState.errors.enroll_seed]} />
</Field>
</SettingRow>
<SettingRow
title="EvoBGP API URL"
description="Источник community prefixes"
labelFor="evobgp_api_url"
last={false}
>
<Field data-invalid={Boolean(form.formState.errors.evobgp_api_url) || undefined}>
<Input
id="evobgp_api_url"
placeholder="https://…"
{...form.register('evobgp_api_url')}
aria-invalid={Boolean(form.formState.errors.evobgp_api_url) || undefined}
/>
<FieldError errors={[form.formState.errors.evobgp_api_url]} />
</Field>
</SettingRow>
<SettingRow
title="EvoBGP API token"
description="Bearer для интеграции"
labelFor="evobgp_api_token"
last={false}
>
<TokenField form={form} />
</SettingRow>
<SettingRow
title="Интервал синхронизации агентов (сек)"
description="Как часто агенты забирают политику (30–86400)"
labelFor="agent_sync_interval_sec"
last={false}
>
<Field data-invalid={Boolean(form.formState.errors.agent_sync_interval_sec) || undefined}>
<Input
id="agent_sync_interval_sec"
inputMode="numeric"
className="max-w-32"
{...form.register('agent_sync_interval_sec')}
aria-invalid={
Boolean(form.formState.errors.agent_sync_interval_sec) ||
undefined
} }
/> />
<FieldError
errors={[form.formState.errors.agent_sync_interval_sec]}
/>
</Field>
</SettingRow> </SettingRow>
))}
<SettingRow <SettingRow
title="Быстрые действия" title="Быстрые действия"
description="Показывать Quick Actions на панели и у агентов" description="Показывать Quick Actions на панели и у агентов"
last last
> >
<Controller
control={form.control}
name="show_quick_actions"
render={({ field }) => (
<Switch <Switch
checked={showQuickActions} checked={field.value !== 'false'}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setForm((prev) => ({ field.onChange(checked ? 'true' : 'false')
...prev,
show_quick_actions: checked ? 'true' : 'false',
}))
} }
aria-label="Быстрые действия" aria-label="Быстрые действия"
/> />
)}
/>
</SettingRow> </SettingRow>
</div> </div>
</FramePanel> </FramePanel>
@@ -138,15 +217,55 @@ function SettingsPage() {
{canSave ? ( {canSave ? (
<div className="flex justify-end"> <div className="flex justify-end">
<LoadingButton <LoadingButton
onClick={() => save.mutate()} type="submit"
isLoading={save.isPending} isLoading={save.isPending}
disabled={!initialized.current} disabled={!settingsQ.isSuccess}
loadingLabel="Сохранение…" loadingLabel="Сохранение…"
> >
Сохранить Сохранить
</LoadingButton> </LoadingButton>
</div> </div>
) : null} ) : null}
</form>
</PageShell> </PageShell>
) )
} }
/** Password-поле с reveal (Eye toggle). */
function TokenField({
form,
}: {
form: ReturnType<typeof useForm<SettingsValues>>
}) {
const [visible, setVisible] = useState(false)
const invalid = Boolean(form.formState.errors.evobgp_api_token)
return (
<Field data-invalid={invalid || undefined}>
<div className="relative">
<Input
id="evobgp_api_token"
type={visible ? 'text' : 'password'}
className="pr-10"
autoComplete="off"
{...form.register('evobgp_api_token')}
/>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="absolute top-1/2 right-1 -translate-y-1/2"
aria-label={visible ? 'Скрыть токен' : 'Показать токен'}
onClick={() => setVisible((v) => !v)}
>
{visible ? (
<EyeOffIcon className="size-3.5" />
) : (
<EyeIcon className="size-3.5" />
)}
</Button>
</div>
<FieldError errors={[form.formState.errors.evobgp_api_token]} />
</Field>
)
}
+99 -27
View File
@@ -1,4 +1,4 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
@@ -21,7 +21,11 @@ import {
FramePanel, FramePanel,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { UpdatedAtLabel } from '@/components/updated-at-label'
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries' import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
import { formatNumber, formatStampDateTime } from '@/lib/format'
import { searchToFilters } from '@/lib/search-params'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { import {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
@@ -38,28 +42,80 @@ type StatRow = {
recorded_at: string recorded_at: string
} }
type StatsSearch = {
f?: Filter[]
}
/** URL-состояние страницы: f (deep-link и refresh сохраняют вид). */
function parseStatsSearch(search: Record<string, unknown>): StatsSearch {
const f = searchToFilters(search.f)
return {
f: f.length > 0 ? f : undefined,
}
}
export const Route = createFileRoute('/_auth/stats')({ export const Route = createFileRoute('/_auth/stats')({
validateSearch: parseStatsSearch,
component: StatsPage, component: StatsPage,
}) })
const chartConfig = { const chartConfig = {
dropped: { label: 'Dropped', color: 'var(--chart-1)' }, dropped: { label: 'Заблокировано', color: 'var(--chart-1)' },
accepted: { label: 'Accepted', color: 'var(--chart-2)' }, accepted: { label: 'Пропущено', color: 'var(--chart-2)' },
} satisfies ChartConfig } satisfies ChartConfig
function StatsPage() { function StatsPage() {
const navigate = useNavigate({ from: Route.fullPath })
const dash = useQuery(dashboardQueryOptions()) const dash = useQuery(dashboardQueryOptions())
const stats = useQuery(recentStatsQueryOptions()) const stats = useQuery(recentStatsQueryOptions())
const [filters, setFilters] = useState<Filter[]>([]) const filters = Route.useSearch().f ?? []
const series = [...(stats.data?.items ?? [])] /** Обновление URL-состояния (f) без перезагрузки. */
.reverse() const setStatsFilters = useCallback(
.slice(-40) (nextFilters: Filter[]) => {
.map((s) => ({ void navigate({
t: s.recorded_at.slice(11, 19), search: (prev) => ({
dropped: s.packets_dropped, ...parseStatsSearch(prev as Record<string, unknown>),
accepted: s.packets_accepted, f: nextFilters,
})) }),
replace: true,
})
},
[navigate],
)
/**
* Часовая агрегация: пик счётчиков за час. Счётчики агентов смешанной
* семантики (absolute/presence), поэтому суммирование сэмплов дало бы
* двойной счёт — берём максимум наблюдавшегося уровня за час.
*/
const series = useMemo(() => {
const byHour = new Map<
string,
{ t: string; label: string; dropped: number; accepted: number }
>()
for (const s of stats.data?.items ?? []) {
const d = new Date(s.recorded_at)
if (Number.isNaN(d.getTime())) continue
const key = `${s.recorded_at.slice(0, 13)}:00:00`
const entry = byHour.get(key) ?? {
t: key,
label: d.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
}),
dropped: 0,
accepted: 0,
}
entry.dropped = Math.max(entry.dropped, s.packets_dropped)
entry.accepted = Math.max(entry.accepted, s.packets_accepted)
byHour.set(key, entry)
}
return [...byHour.values()]
.sort((a, b) => a.t.localeCompare(b.t))
.slice(-48)
.map(({ t: _t, ...rest }) => rest)
}, [stats.data])
const rows: StatRow[] = useMemo( const rows: StatRow[] = useMemo(
() => () =>
@@ -76,7 +132,7 @@ function StatsPage() {
const kpiCards: KpiStatCard[] = [ const kpiCards: KpiStatCard[] = [
{ {
id: 'd', id: 'd',
label: 'Dropped (sum)', label: 'Заблокировано (сумма)',
value: dash.data?.packets_dropped ?? 0, value: dash.data?.packets_dropped ?? 0,
icon: <BanIcon aria-hidden />, icon: <BanIcon aria-hidden />,
iconClassName: 'text-warning', iconClassName: 'text-warning',
@@ -84,14 +140,14 @@ function StatsPage() {
}, },
{ {
id: 'a', id: 'a',
label: 'Accepted (sum)', label: 'Пропущено (сумма)',
value: dash.data?.packets_accepted ?? 0, value: dash.data?.packets_accepted ?? 0,
icon: <CheckCircle2Icon aria-hidden />, icon: <CheckCircle2Icon aria-hidden />,
iconClassName: 'text-success', iconClassName: 'text-success',
}, },
{ {
id: 'o', id: 'o',
label: 'Online agents', label: 'Агентов онлайн',
value: dash.data?.agents_online ?? 0, value: dash.data?.agents_online ?? 0,
icon: <ServerIcon aria-hidden />, icon: <ServerIcon aria-hidden />,
iconClassName: 'text-info', iconClassName: 'text-info',
@@ -102,7 +158,7 @@ function StatsPage() {
() => [ () => [
{ {
key: 'agent_id', key: 'agent_id',
label: 'Agent', label: 'Агент',
type: 'text', type: 'text',
placeholder: 'agent id…', placeholder: 'agent id…',
}, },
@@ -119,7 +175,9 @@ function StatsPage() {
() => [ () => [
{ {
accessorKey: 'agent_id', accessorKey: 'agent_id',
header: 'Agent', header: ({ column }) => (
<DataGridColumnHeader column={column} title="Агент" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-xs"> <span className="font-mono text-xs">
{row.original.agent_id.slice(0, 8)} {row.original.agent_id.slice(0, 8)}
@@ -128,23 +186,35 @@ function StatsPage() {
}, },
{ {
accessorKey: 'recorded_at', accessorKey: 'recorded_at',
header: 'Time', header: ({ column }) => (
<DataGridColumnHeader column={column} title="Время" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-xs">{row.original.recorded_at}</span> <span className="text-xs tabular-nums">
{formatStampDateTime(row.original.recorded_at)}
</span>
), ),
}, },
{ {
accessorKey: 'packets_dropped', accessorKey: 'packets_dropped',
header: 'Drop', header: ({ column }) => (
<DataGridColumnHeader column={column} title="Заблокировано" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="tabular-nums">{row.original.packets_dropped}</span> <span className="text-warning tabular-nums">
{formatNumber(row.original.packets_dropped)}
</span>
), ),
}, },
{ {
accessorKey: 'packets_accepted', accessorKey: 'packets_accepted',
header: 'Accept', header: ({ column }) => (
<DataGridColumnHeader column={column} title="Пропущено" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="tabular-nums">{row.original.packets_accepted}</span> <span className="text-success tabular-nums">
{formatNumber(row.original.packets_accepted)}
</span>
), ),
}, },
], ],
@@ -156,12 +226,13 @@ function StatsPage() {
<PageHeader <PageHeader
title="Статистика" title="Статистика"
description="История apply-report counters" description="История apply-report counters"
actions={<UpdatedAtLabel updatedAt={stats.dataUpdatedAt} />}
/> />
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} /> <KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
<Frame dense spacing="sm"> <Frame dense spacing="sm">
<FrameHeader> <FrameHeader>
<FrameTitle>Тренд (последние samples)</FrameTitle> <FrameTitle>Активность по часам (пик за час)</FrameTitle>
</FrameHeader> </FrameHeader>
<FramePanel> <FramePanel>
{series.length === 0 ? ( {series.length === 0 ? (
@@ -203,12 +274,13 @@ function StatsPage() {
getRowId={(r) => r.id} getRowId={(r) => r.id}
filterFields={filterFields} filterFields={filterFields}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={setStatsFilters}
onClearFilters={() => setFilters([])} onClearFilters={() => setStatsFilters([])}
getFilterFieldValue={getFilterFieldValue} getFilterFieldValue={getFilterFieldValue}
isLoading={stats.isLoading} isLoading={stats.isLoading}
pageSize={20}
emptyState={{ emptyState={{
title: 'Нет samples', title: 'Нет данных',
description: 'Агенты ещё не отправили apply-report.', description: 'Агенты ещё не отправили apply-report.',
}} }}
/> />
+4 -1
View File
@@ -4,7 +4,10 @@
"reui": { "reui": {
"type": "remote", "type": "remote",
"url": "https://mcp.reui.io/api/mcp", "url": "https://mcp.reui.io/api/mcp",
"enabled": true "enabled": true,
"headers": {
"Authorization": "Bearer {env:REUI_LICENSE_KEY}"
}
} }
} }
} }
+1
View File
@@ -23,6 +23,7 @@
"@radix-ui/react-tabs": "^1.1.14", "@radix-ui/react-tabs": "^1.1.14",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
+195
View File
@@ -0,0 +1,195 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@evofw/ui/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@evofw/ui/components/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@evofw/ui/components/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{/* cmdk-компоненты требуют корень Command — без него useSyncExternalStore падает */}
<Command className="overflow-hidden">{children}</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+34
View File
@@ -151,6 +151,12 @@ importers:
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
cn:
specifier: ^0.4.0
version: 0.4.0
date-fns: date-fns:
specifier: ^4.4.0 specifier: ^4.4.0
version: 4.4.0 version: 4.4.0
@@ -277,6 +283,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
date-fns: date-fns:
specifier: ^4.4.0 specifier: ^4.4.0
version: 4.4.0 version: 4.4.0
@@ -2704,6 +2713,17 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
[email protected]:
resolution: {integrity: sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg==}
engines: {node: '>=20'}
hasBin: true
[email protected]: [email protected]:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
@@ -7813,6 +7833,20 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@radix-ui/react-compose-refs': 1.1.3(@types/[email protected])([email protected])
'@radix-ui/react-dialog': 1.1.20(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-id': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-primitive': 2.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
react: 19.2.7
react-dom: 19.2.7([email protected])
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
color-name: 1.1.3 color-name: 1.1.3