Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8524a25373 | ||
|
|
08c01b3cdd | ||
|
|
27c1ba7e23 | ||
|
|
19af67f8a3 | ||
|
|
8a8ea95da6 | ||
|
|
65434857bb | ||
|
|
3d4edbad8b | ||
|
|
28f1f35f16 | ||
|
|
0b426fa7ae | ||
|
|
98e4dcdefb | ||
|
|
0f7daf77e3 |
@@ -1,19 +1,20 @@
|
||||
---
|
||||
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
|
||||
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.
|
||||
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 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
|
||||
- **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
|
||||
|
||||
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/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/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
{ "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
|
||||
|
||||
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## 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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
## code-block
|
||||
|
||||
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<CodeBlock code={code} language="tsx" />
|
||||
|
||||
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||
<CodeBlockLanguage />
|
||||
<CodeBlockCopyButton className="ml-auto" />
|
||||
</CodeBlockHeader>
|
||||
<CodeBlockExpandButton />
|
||||
</CodeBlock>
|
||||
```
|
||||
|
||||
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
- **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)`.
|
||||
- **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).
|
||||
|
||||
## The @reui registry
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## 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)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -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`)
|
||||
|
||||
**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:
|
||||
|
||||
- `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"`, ...).
|
||||
- `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.
|
||||
|
||||
**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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
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.
|
||||
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,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)
|
||||
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||
- **`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. 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.
|
||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||
- **`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.
|
||||
- **`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
|
||||
|
||||
`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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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>`).
|
||||
- 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
|
||||
|
||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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`. Never run a fabricated install command.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
---
|
||||
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
|
||||
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.
|
||||
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 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
|
||||
- **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
|
||||
|
||||
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/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/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
{ "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
|
||||
|
||||
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## 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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
## code-block
|
||||
|
||||
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<CodeBlock code={code} language="tsx" />
|
||||
|
||||
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||
<CodeBlockLanguage />
|
||||
<CodeBlockCopyButton className="ml-auto" />
|
||||
</CodeBlockHeader>
|
||||
<CodeBlockExpandButton />
|
||||
</CodeBlock>
|
||||
```
|
||||
|
||||
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
- **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)`.
|
||||
- **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).
|
||||
|
||||
## The @reui registry
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## 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)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -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`)
|
||||
|
||||
**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:
|
||||
|
||||
- `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"`, ...).
|
||||
- `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.
|
||||
|
||||
**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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
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.
|
||||
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,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)
|
||||
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||
- **`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. 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.
|
||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||
- **`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.
|
||||
- **`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
|
||||
|
||||
`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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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>`).
|
||||
- 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
|
||||
|
||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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`. Never run a fabricated install command.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||
|
||||
@@ -31,7 +31,7 @@ alwaysApply: true
|
||||
|
||||
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
|
||||
|
||||
**Registry (актуально):** 20 free components — `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.claude/skills/reui` (v `668fb463eb`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||
**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 без миграции.
|
||||
|
||||
|
||||
+4
-10
@@ -4,22 +4,16 @@ globs: ["**/*.tsx","**/*.ts"]
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI 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 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
|
||||
- **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
|
||||
|
||||
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/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/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)
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
---
|
||||
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
|
||||
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.
|
||||
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 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
|
||||
- **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
|
||||
|
||||
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/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/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
{ "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
|
||||
|
||||
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## 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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
## code-block
|
||||
|
||||
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<CodeBlock code={code} language="tsx" />
|
||||
|
||||
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||
<CodeBlockLanguage />
|
||||
<CodeBlockCopyButton className="ml-auto" />
|
||||
</CodeBlockHeader>
|
||||
<CodeBlockExpandButton />
|
||||
</CodeBlock>
|
||||
```
|
||||
|
||||
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
- **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)`.
|
||||
- **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).
|
||||
|
||||
## The @reui registry
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## 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)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -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`)
|
||||
|
||||
**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:
|
||||
|
||||
- `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"`, ...).
|
||||
- `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.
|
||||
|
||||
**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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
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.
|
||||
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,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)
|
||||
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||
- **`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. 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.
|
||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||
- **`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.
|
||||
- **`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
|
||||
|
||||
`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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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>`).
|
||||
- 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
|
||||
|
||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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`. Never run a fabricated install command.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||
|
||||
@@ -37,5 +37,8 @@ deploy/docker/docker-bake.override.hcl
|
||||
*.tsbuildinfo
|
||||
# Local MCP configs (may contain REUI license Bearer)
|
||||
.cursor/mcp.json
|
||||
.zcode/config.json
|
||||
.zcode/plans/
|
||||
.mcp.json
|
||||
.codegraph/daemon.pid
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
---
|
||||
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
|
||||
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.
|
||||
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 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
|
||||
- **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
|
||||
|
||||
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/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/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
{ "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
|
||||
|
||||
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## 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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
## code-block
|
||||
|
||||
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<CodeBlock code={code} language="tsx" />
|
||||
|
||||
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||
<CodeBlockLanguage />
|
||||
<CodeBlockCopyButton className="ml-auto" />
|
||||
</CodeBlockHeader>
|
||||
<CodeBlockExpandButton />
|
||||
</CodeBlock>
|
||||
```
|
||||
|
||||
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
- **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)`.
|
||||
- **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).
|
||||
|
||||
## The @reui registry
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## 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)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -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`)
|
||||
|
||||
**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:
|
||||
|
||||
- `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"`, ...).
|
||||
- `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.
|
||||
|
||||
**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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
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.
|
||||
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,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)
|
||||
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||
- **`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. 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.
|
||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||
- **`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.
|
||||
- **`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
|
||||
|
||||
`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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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>`).
|
||||
- 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
|
||||
|
||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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`. Never run a fabricated install command.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,10 @@ pnpm --filter @evofw/web build
|
||||
## ReUI PRO
|
||||
|
||||
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`).
|
||||
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
|
||||
|
||||
|
||||
@@ -212,6 +212,30 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
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 } }>(
|
||||
'/agents/:id/clone-from/:sourceId',
|
||||
async (req) => {
|
||||
|
||||
@@ -152,4 +152,32 @@ describe('bulk agent operations', () => {
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"@tanstack/react-virtual": "^3.14.7",
|
||||
"@tanstack/router-plugin": "^1.120.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cn": "^0.4.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
PanelRight,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import {
|
||||
agentHasTrafficSample,
|
||||
agentTrafficAccepted,
|
||||
@@ -13,6 +18,13 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
@@ -21,7 +33,7 @@ import {
|
||||
} from '@evofw/ui/components/item'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
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.
|
||||
@@ -48,20 +60,22 @@ export function AgentCard({
|
||||
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 =
|
||||
agent.default_action === 'drop' ? 'Drop' : 'Accept'
|
||||
agent.default_action === 'drop' ? 'Блокировать' : 'Пропускать'
|
||||
const subtitle = [
|
||||
agent.hostname,
|
||||
platformLabel(agent.platform),
|
||||
`gen ${agent.policy_generation}`,
|
||||
`поколение ${agent.policy_generation}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Traffic',
|
||||
label: 'Трафик',
|
||||
value: traffic,
|
||||
valueClass:
|
||||
traffic === '—'
|
||||
@@ -79,7 +93,7 @@ export function AgentCard({
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Seen',
|
||||
label: 'Активность',
|
||||
value: seen,
|
||||
valueClass: 'text-muted-foreground',
|
||||
valueNode: seen,
|
||||
@@ -91,6 +105,7 @@ export function AgentCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(agent.id)}
|
||||
aria-label={`Открыть агента ${agent.name}`}
|
||||
className={cn(
|
||||
'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',
|
||||
@@ -113,7 +128,10 @@ export function AgentCard({
|
||||
<h3 className="truncate text-sm leading-tight font-semibold">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<StatusBadge status={agent.status} />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AgentOnlineDot agent={agent} />
|
||||
<StatusBadge status={agent.status} />
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
agent.default_action === 'drop'
|
||||
@@ -173,16 +191,35 @@ export function AgentCard({
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive absolute top-3 right-3 z-10"
|
||||
aria-label="Удалить"
|
||||
onClick={() => onDelete(agent.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3 z-10"
|
||||
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)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,10 +21,8 @@ import {
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentPlatformIcon, platformLabel } from '@/components/agents/agent-platform-icon'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import {
|
||||
agentTrafficAccepted,
|
||||
agentTrafficDropped,
|
||||
@@ -38,6 +36,7 @@ import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
||||
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
||||
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
@@ -47,6 +46,7 @@ import {
|
||||
agentQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/format'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
@@ -79,6 +79,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
const installRef = useRef<HTMLDivElement>(null)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [revokeOpen, setRevokeOpen] = useState(false)
|
||||
const [resetStatsOpen, setResetStatsOpen] = useState(false)
|
||||
const [fwTab, setFwTab] = useState('host')
|
||||
|
||||
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, 'blocked-ips'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -135,8 +137,19 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription>
|
||||
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>
|
||||
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => void agentQ.refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@@ -146,8 +159,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
const headerDesc = [
|
||||
a.hostname,
|
||||
platformLabel(a.platform),
|
||||
`gen ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
|
||||
`поколение ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
@@ -162,7 +175,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
actions={
|
||||
<>
|
||||
<AgentPlatformIcon platform={a.platform} />
|
||||
<StatusBadge status={a.status} />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AgentOnlineDot agent={a} />
|
||||
<StatusBadge status={a.status} />
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
a.default_action === 'drop'
|
||||
@@ -180,17 +196,17 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
onClick={() => approve.mutate()}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
Approve
|
||||
Утвердить
|
||||
</Button>
|
||||
) : null}
|
||||
{a.status === 'approved' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => revoke.mutate()}
|
||||
onClick={() => setRevokeOpen(true)}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
Revoke
|
||||
Отозвать
|
||||
</Button>
|
||||
) : null}
|
||||
{a.install_curl ? (
|
||||
@@ -199,11 +215,11 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
toast.success('Команда установки скопирована')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Install
|
||||
Установка
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
@@ -231,14 +247,14 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
toast.success('Команда установки скопирована')
|
||||
installRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
Install curl
|
||||
Команда установки
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
@@ -262,7 +278,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
{a.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка apply</AlertTitle>
|
||||
<AlertTitle>Ошибка применения политики</AlertTitle>
|
||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -273,7 +289,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
id: 'traffic',
|
||||
icon: <ActivityIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
label: 'Traffic',
|
||||
label: 'Трафик',
|
||||
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
|
||||
hint: 'накопительно',
|
||||
variant: 'warning',
|
||||
@@ -283,7 +299,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={resetStats.isPending}
|
||||
onClick={() => resetStats.mutate()}
|
||||
onClick={() => setResetStatsOpen(true)}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
@@ -293,15 +309,18 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
id: 'kernel',
|
||||
icon: <CpuIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Kernel',
|
||||
label: 'Ядро',
|
||||
description: a.last_apply_kernel_method ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
label: 'Last apply',
|
||||
description: a.last_apply_at ?? '—',
|
||||
label: 'Последнее применение',
|
||||
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}
|
||||
onValueChange={setFwTab}
|
||||
tabs={[
|
||||
{ id: 'host', label: 'Host firewall' },
|
||||
{ id: 'acl', label: 'Port ACL' },
|
||||
{ id: 'hits', label: 'Blocked' },
|
||||
{ id: 'host', label: 'Хост-фаервол' },
|
||||
{ id: 'acl', label: 'Правила портов' },
|
||||
{ id: 'hits', label: 'Заблокированное' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="host" className="mt-3">
|
||||
@@ -374,6 +393,32 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
open={cloneOpen}
|
||||
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 type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
||||
import { Check, Copy, PanelRight, Trash2 } from 'lucide-react'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
DataGridMutedCell,
|
||||
DataGridPrimaryCell,
|
||||
@@ -20,13 +24,15 @@ import {
|
||||
agentTrafficAccepted,
|
||||
agentTrafficDropped,
|
||||
} 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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@evofw/ui/components/tooltip'
|
||||
import { formatPackets, formatShortDateTime } from '@/lib/format'
|
||||
import { formatPackets } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* Fleet triage DataGrid — firewall ops density.
|
||||
@@ -54,7 +60,17 @@ export type AgentFleetDataGridProps = {
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
emptyAction?: ReactNode
|
||||
filteredEmptyMessage?: string
|
||||
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
|
||||
onApprove: (id: string) => void
|
||||
approvePending?: boolean
|
||||
@@ -81,7 +97,12 @@ export function AgentFleetDataGrid({
|
||||
error,
|
||||
onRetry,
|
||||
emptyAction,
|
||||
filteredEmptyMessage,
|
||||
toolbarExtra,
|
||||
enableRowSelection,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
selectionToolbar,
|
||||
onSelect,
|
||||
onApprove,
|
||||
approvePending,
|
||||
@@ -98,6 +119,18 @@ export function AgentFleetDataGrid({
|
||||
|
||||
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',
|
||||
size: 260,
|
||||
@@ -136,12 +169,12 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
{
|
||||
id: 'default_action',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
size: 130,
|
||||
minSize: 120,
|
||||
maxSize: 150,
|
||||
accessorFn: (row) => row.default_action,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Default" />
|
||||
<DataGridColumnHeader column={column} title="Политика" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const drop = row.original.default_action === 'drop'
|
||||
@@ -151,19 +184,19 @@ export function AgentFleetDataGrid({
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{drop ? 'Drop' : 'Accept'}
|
||||
{drop ? 'Блокировать' : 'Пропускать'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
size: 110,
|
||||
minSize: 100,
|
||||
maxSize: 130,
|
||||
accessorFn: (row) => row.last_apply_status ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Apply" />
|
||||
<DataGridColumnHeader column={column} title="Применение" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
@@ -176,7 +209,7 @@ export function AgentFleetDataGrid({
|
||||
}
|
||||
>
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
error
|
||||
Ошибка
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm">
|
||||
@@ -188,9 +221,10 @@ export function AgentFleetDataGrid({
|
||||
if (!a.last_apply_status && !a.last_apply_at) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
const status = a.last_apply_status ?? 'ok'
|
||||
return (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{a.last_apply_status ?? 'ok'}
|
||||
{status === 'applied' ? 'Применено' : status}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
@@ -203,7 +237,7 @@ export function AgentFleetDataGrid({
|
||||
accessorFn: (row) =>
|
||||
agentTrafficDropped(row) + agentTrafficAccepted(row),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Traffic" />
|
||||
<DataGridColumnHeader column={column} title="Трафик" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
@@ -224,39 +258,44 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
maxSize: 180,
|
||||
size: 150,
|
||||
minSize: 130,
|
||||
maxSize: 190,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Seen" />
|
||||
<DataGridColumnHeader column={column} title="Активность" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
const short = formatShortDateTime(a.last_seen_at)
|
||||
if (!a.last_seen_at || short === '—') {
|
||||
if (!a.last_seen_at) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<DataGridPrimaryCell
|
||||
accent="default"
|
||||
title={short}
|
||||
subtitle={a.last_seen_ip ?? undefined}
|
||||
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal [&>span:first-child]:tabular-nums"
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AgentOnlineDot agent={a} />
|
||||
<DataGridPrimaryCell
|
||||
accent="default"
|
||||
title={formatRelativeTime(a.last_seen_at)}
|
||||
subtitle={a.last_seen_ip ?? undefined}
|
||||
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gen',
|
||||
size: 70,
|
||||
minSize: 60,
|
||||
maxSize: 90,
|
||||
size: 110,
|
||||
minSize: 100,
|
||||
maxSize: 130,
|
||||
accessorFn: (row) => row.policy_generation,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Gen" />
|
||||
<DataGridColumnHeader column={column} title="Поколение" />
|
||||
),
|
||||
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}
|
||||
</span>
|
||||
),
|
||||
@@ -277,7 +316,7 @@ export function AgentFleetDataGrid({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Approve"
|
||||
aria-label="Утвердить"
|
||||
disabled={approvePending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -291,7 +330,7 @@ export function AgentFleetDataGrid({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Copy install"
|
||||
aria-label="Скопировать команду установки"
|
||||
onClick={(e) => handleCopy(a.install_curl!, e)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
@@ -325,7 +364,7 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
},
|
||||
],
|
||||
[approvePending, handleCopy, onApprove, onDelete, onSelect],
|
||||
[approvePending, handleCopy, onApprove, onDelete, onSelect, enableRowSelection],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -346,6 +385,10 @@ export function AgentFleetDataGrid({
|
||||
getSearchText={getSearchText}
|
||||
tableLayout={{ width: 'fixed', columnsResizable: true }}
|
||||
onRowClick={(row) => onSelect(row.id)}
|
||||
enableRowSelection={enableRowSelection}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={onRowSelectionChange}
|
||||
selectionToolbar={selectionToolbar}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
@@ -355,10 +398,11 @@ export function AgentFleetDataGrid({
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
toolbarExtra={toolbarExtra}
|
||||
filteredEmptyMessage={filteredEmptyMessage}
|
||||
emptyState={{
|
||||
title: 'Нет агентов',
|
||||
description:
|
||||
'Создайте агента — он появится в списке как Invited с командой установки.',
|
||||
'Создайте агента — он появится в списке со статусом «Приглашён» и командой установки.',
|
||||
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 { Trash2 } from 'lucide-react'
|
||||
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 {
|
||||
agentOverridesQueryOptions,
|
||||
@@ -8,8 +12,9 @@ import {
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { LoadingButton } from '@/components/loading-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 { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||
import {
|
||||
@@ -40,6 +45,29 @@ type OverrideSheetProps = {
|
||||
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({
|
||||
agentId,
|
||||
open,
|
||||
@@ -50,18 +78,21 @@ export function AgentOverrideSheet({
|
||||
...agentOverridesQueryOptions(agentId),
|
||||
enabled: open,
|
||||
})
|
||||
const [cidr, setCidr] = useState('')
|
||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
||||
const form = useForm<OverrideValues>({
|
||||
resolver: zodResolver(overrideSchema),
|
||||
defaultValues: { cidr: '', action: 'deny' },
|
||||
mode: 'onBlur',
|
||||
})
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: (values: OverrideValues) =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/overrides`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cidr, action }),
|
||||
body: JSON.stringify(values),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Override добавлен — подхватится на следующей итерации sync')
|
||||
setCidr('')
|
||||
form.reset({ cidr: '', action: 'deny' })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
},
|
||||
@@ -96,42 +127,58 @@ export function AgentOverrideSheet({
|
||||
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="flex flex-col gap-4 py-2 pb-4">
|
||||
<div className="grid gap-3">
|
||||
<Field>
|
||||
<form
|
||||
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>
|
||||
<Input
|
||||
id="ov-cidr"
|
||||
placeholder="1.2.3.4/32"
|
||||
value={cidr}
|
||||
onChange={(e) => setCidr(e.target.value)}
|
||||
{...form.register('cidr')}
|
||||
aria-invalid={Boolean(form.formState.errors.cidr) || undefined}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.cidr]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Действие</FieldLabel>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => {
|
||||
if (v) setAction(v as 'allow' | 'deny')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="action"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={[...OVERRIDE_ACTION_ITEMS]}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'allow' || v === 'deny') field.onChange(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OVERRIDE_ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
size="sm"
|
||||
isLoading={add.isPending}
|
||||
className="self-start"
|
||||
disabled={!cidr.trim() || add.isPending}
|
||||
onClick={() => add.mutate()}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</LoadingButton>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
|
||||
@@ -52,24 +52,24 @@ export function fleetKpiCards(
|
||||
return [
|
||||
{
|
||||
id: 'pending',
|
||||
label: 'Pending',
|
||||
label: 'Ожидают',
|
||||
value: counts.pending,
|
||||
hint: 'approve backlog',
|
||||
hint: 'одобрение',
|
||||
icon: icons.pending,
|
||||
iconClassName: 'text-warning',
|
||||
variant: counts.pending > 0 ? 'warning' : 'default',
|
||||
},
|
||||
{
|
||||
id: 'invited',
|
||||
label: 'Invited',
|
||||
label: 'Приглашённые',
|
||||
value: counts.invited,
|
||||
hint: 'ожидают install',
|
||||
hint: 'ждут установки',
|
||||
icon: icons.invited,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{
|
||||
id: 'approved',
|
||||
label: 'Approved',
|
||||
label: 'Одобренные',
|
||||
value: counts.approved,
|
||||
hint: 'в парке',
|
||||
icon: icons.approved,
|
||||
@@ -77,9 +77,9 @@ export function fleetKpiCards(
|
||||
},
|
||||
{
|
||||
id: 'stale',
|
||||
label: 'Offline / stale',
|
||||
label: 'Не на связи',
|
||||
value: counts.stale,
|
||||
hint: '>24ч без seen',
|
||||
hint: 'нет seen >24ч',
|
||||
icon: icons.stale,
|
||||
iconClassName: 'text-muted-foreground',
|
||||
variant: counts.stale > 0 ? 'warning' : 'default',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { NavUser } from '@/components/layout/nav-user'
|
||||
import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -24,17 +25,23 @@ function NavSection({
|
||||
label,
|
||||
items,
|
||||
pathname,
|
||||
can,
|
||||
}: {
|
||||
label: string
|
||||
items: readonly NavItem[]
|
||||
pathname: string
|
||||
can: (permission: string) => boolean
|
||||
}) {
|
||||
const visible = items.filter(
|
||||
(item) => !item.permission || can(item.permission),
|
||||
)
|
||||
if (visible.length === 0) return null
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
{visible.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
@@ -56,6 +63,7 @@ function NavSection({
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const can = useCan()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -69,6 +77,7 @@ export function AppSidebar() {
|
||||
label={section.label}
|
||||
items={navItemsForSection(section.id)}
|
||||
pathname={pathname}
|
||||
can={can}
|
||||
/>
|
||||
))}
|
||||
</SidebarContent>
|
||||
|
||||
@@ -18,6 +18,8 @@ interface ConfirmDialogProps {
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel?: string
|
||||
/** 'default' для позитивных действий (утвердить), 'destructive' — для опасных. */
|
||||
confirmVariant?: 'default' | 'destructive'
|
||||
cancelLabel?: string
|
||||
onConfirm: () => void
|
||||
disabled?: boolean
|
||||
@@ -30,6 +32,7 @@ export function ConfirmDialog({
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Удалить',
|
||||
confirmVariant = 'destructive',
|
||||
cancelLabel = 'Отмена',
|
||||
onConfirm,
|
||||
disabled,
|
||||
@@ -46,7 +49,7 @@ export function ConfirmDialog({
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||
<AlertDialogAction variant={confirmVariant} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,55 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ListIcon,
|
||||
ListPlusIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
ShieldIcon,
|
||||
ShieldPlusIcon,
|
||||
} from 'lucide-react'
|
||||
import { NAV_ITEMS } from '@/lib/nav'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evofw/ui/components/dialog'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
agentsQueryOptions,
|
||||
listsQueryOptions,
|
||||
policySetsQueryOptions,
|
||||
} from '@/queries'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@evofw/ui/components/item'
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
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 }) {
|
||||
void hotkeyOnly
|
||||
return <SearchMenuDialog />
|
||||
}
|
||||
|
||||
function SearchMenuDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
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(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
setOpen((prev) => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,99 +57,164 @@ export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
const go = (run: () => void) => {
|
||||
setOpen(false)
|
||||
void navigate({ to })
|
||||
run()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hotkeyOnly ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Поиск"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
const navItems = NAV_ITEMS.filter(
|
||||
(item) => !item.permission || can(item.permission),
|
||||
)
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Поиск</DialogTitle>
|
||||
<DialogDescription>
|
||||
Переход к разделам приложения
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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">
|
||||
<div className="relative flex items-center gap-3 border-b px-4 py-2">
|
||||
<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)} />}
|
||||
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 (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Палитра команд"
|
||||
description="Поиск разделов, агентов, списков и действий"
|
||||
className="max-w-xl"
|
||||
>
|
||||
<CommandInput placeholder="Поиск разделов, агентов, списков…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Ничего не найдено.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Действия">
|
||||
{can('fw:agents:write') ? (
|
||||
<CommandItem
|
||||
value="Добавить агента agent invite"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/agents',
|
||||
search: { view: 'cards', add: true },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<PlusIcon aria-hidden />
|
||||
Добавить агента
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{can('fw:lists:write') ? (
|
||||
<CommandItem
|
||||
value="Создать список blocklist"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({ to: '/lists', search: { create: true } }),
|
||||
)
|
||||
}
|
||||
>
|
||||
<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 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<item.icon aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{item.label}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))
|
||||
)}
|
||||
</ItemGroup>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
<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,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
type FilterI18nConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import {
|
||||
Frame,
|
||||
@@ -51,6 +52,26 @@ type DataGridTableLayout = NonNullable<
|
||||
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 {
|
||||
id: string
|
||||
label: string
|
||||
@@ -78,8 +99,13 @@ export interface ResourcePageProps<T extends object> {
|
||||
onRetry?: () => void
|
||||
primaryAction?: ReactNode
|
||||
emptyState?: { title: string; description?: string; action?: ReactNode }
|
||||
/** Текст для пустого результата после фильтров/поиска (по вкладкам). */
|
||||
filteredEmptyMessage?: string
|
||||
pageSize?: number
|
||||
enableRowSelection?: boolean
|
||||
/** Controlled selection — page owns state, so bulk-действия видят выбор. */
|
||||
rowSelection?: RowSelectionState
|
||||
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
|
||||
selectionToolbar?: (ctx: {
|
||||
selectedIds: string[]
|
||||
selectedCount: number
|
||||
@@ -139,8 +165,11 @@ export function ResourcePage<T extends object>({
|
||||
onRetry,
|
||||
primaryAction,
|
||||
emptyState,
|
||||
filteredEmptyMessage,
|
||||
pageSize = 10,
|
||||
enableRowSelection = false,
|
||||
rowSelection: rowSelectionProp,
|
||||
onRowSelectionChange: onRowSelectionChangeProp,
|
||||
selectionToolbar,
|
||||
toolbarExtra,
|
||||
hideHeader = false,
|
||||
@@ -156,7 +185,30 @@ export function ResourcePage<T extends object>({
|
||||
const activeTab = controlledTab ?? internalTab
|
||||
|
||||
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>(
|
||||
() => columnPinningProp ?? {},
|
||||
)
|
||||
@@ -224,8 +276,8 @@ export function ResourcePage<T extends object>({
|
||||
const selectedCount = selectedIds.length
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setRowSelection({})
|
||||
}, [])
|
||||
handleRowSelectionChange({})
|
||||
}, [handleRowSelectionChange])
|
||||
|
||||
const tableLayout = useMemo(
|
||||
() => ({
|
||||
@@ -253,7 +305,7 @@ export function ResourcePage<T extends object>({
|
||||
// без drag-handles — enableColumnResizing остаётся false.
|
||||
enableColumnResizing: false,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
onPaginationChange: setPagination,
|
||||
onColumnPinningChange: setColumnPinning,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -357,7 +409,8 @@ export function ResourcePage<T extends object>({
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
||||
const emptyMessage =
|
||||
filteredEmptyMessage ?? 'Нет записей по выбранным фильтрам.'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -431,6 +484,7 @@ export function ResourcePage<T extends object>({
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
i18n={FILTERS_I18N}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
@@ -480,9 +534,9 @@ export function ResourcePage<T extends object>({
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
info="{from}–{to} из {count}"
|
||||
previousPageLabel="Назад"
|
||||
nextPageLabel="Вперёд"
|
||||
/>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
|
||||
@@ -54,21 +54,21 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Активен',
|
||||
synced: 'Синхронизировано',
|
||||
pending_push: 'Ожидает отправки',
|
||||
pending: 'Pending',
|
||||
invited: 'Invited',
|
||||
approved: 'Approved',
|
||||
revoked: 'Revoked',
|
||||
pending: 'Ожидает одобрения',
|
||||
invited: 'Приглашён',
|
||||
approved: 'Одобрен',
|
||||
revoked: 'Отозван',
|
||||
enabled: 'Включён',
|
||||
disabled: 'Выключен',
|
||||
allow: 'allow',
|
||||
deny: 'deny',
|
||||
allow: 'Разрешить',
|
||||
deny: 'Блокировать',
|
||||
conflict: 'Конфликт',
|
||||
error: 'Ошибка',
|
||||
ok: 'OK',
|
||||
up: 'OK',
|
||||
warning: 'Предупреждение',
|
||||
degraded: 'Slow',
|
||||
down: 'Down',
|
||||
degraded: 'Замедление',
|
||||
down: 'Недоступен',
|
||||
expired: 'Истёк',
|
||||
static: 'Ручной',
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -34,6 +34,8 @@ const timeFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
const relativeFmt = new Intl.RelativeTimeFormat('ru-RU', { numeric: 'auto' })
|
||||
|
||||
/** Compact packet counter: 1,2K / 3,4M */
|
||||
export function formatPackets(n: number | undefined | null): string {
|
||||
if (n === undefined || n === null) return '—'
|
||||
@@ -72,3 +74,20 @@ export function formatStampDateTime(iso: string | null | undefined): string {
|
||||
export function formatTime(d: Date): string {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export type NavItem = {
|
||||
keywords: string[]
|
||||
icon: typeof ServerIcon
|
||||
section: NavSectionId
|
||||
/** Read-право для показа пункта (useCan); undefined — виден всем с доступом к app. */
|
||||
permission?: string
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: readonly NavItem[] = [
|
||||
@@ -32,6 +34,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
keywords: ['dashboard', 'панель', 'обзор'],
|
||||
icon: LayoutDashboardIcon,
|
||||
section: 'overview',
|
||||
permission: 'fw:dashboard:read',
|
||||
},
|
||||
{
|
||||
to: '/agents',
|
||||
@@ -39,6 +42,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
keywords: ['agents', 'агенты', 'nodes'],
|
||||
icon: ServerIcon,
|
||||
section: 'ops',
|
||||
permission: 'fw:agents:read',
|
||||
},
|
||||
{
|
||||
to: '/lists',
|
||||
@@ -46,6 +50,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
keywords: ['lists', 'списки', 'blocklist'],
|
||||
icon: ListIcon,
|
||||
section: 'ops',
|
||||
permission: 'fw:lists:read',
|
||||
},
|
||||
{
|
||||
to: '/rules',
|
||||
@@ -53,6 +58,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
|
||||
icon: ShieldIcon,
|
||||
section: 'ops',
|
||||
permission: 'fw:policies:read',
|
||||
},
|
||||
{
|
||||
to: '/stats',
|
||||
@@ -60,6 +66,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
keywords: ['stats', 'статистика', 'packets'],
|
||||
icon: BarChart3Icon,
|
||||
section: 'ops',
|
||||
permission: 'fw:stats:read',
|
||||
},
|
||||
{
|
||||
to: '/settings',
|
||||
@@ -67,6 +74,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
keywords: ['settings', 'настройки'],
|
||||
icon: SettingsIcon,
|
||||
section: 'system',
|
||||
permission: 'fw:settings:read',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -12,12 +12,14 @@ export const dashboardQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
export const agentsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['agents'],
|
||||
queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
export const agentQueryOptions = (id: string) =>
|
||||
@@ -272,4 +274,5 @@ export const recentStatsQueryOptions = () =>
|
||||
recorded_at: string
|
||||
}[]
|
||||
}>('/api/v1/stats/recent'),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { RowSelectionState } from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
Plus,
|
||||
ShieldIcon,
|
||||
TableIcon,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
@@ -27,13 +29,10 @@ import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
|
||||
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
|
||||
@@ -45,8 +44,17 @@ import {
|
||||
} from '@/components/agents/agents-fleet-kpis'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
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 { 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 { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
@@ -66,44 +74,71 @@ import type { Agent } from '@evofw/shared'
|
||||
type AgentsSearch = {
|
||||
agent?: string
|
||||
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 = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'invited', label: 'Invited' },
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
{ id: 'approved', label: 'Approved' },
|
||||
{ id: 'revoked', label: 'Revoked' },
|
||||
{ id: 'invited', label: 'Приглашённые' },
|
||||
{ id: 'pending', label: 'Ожидают' },
|
||||
{ id: 'approved', label: 'Одобренные' },
|
||||
{ id: 'revoked', label: 'Отозванные' },
|
||||
] 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() {
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const { agent: detailAgentId, view } = Route.useSearch()
|
||||
const { agent: detailAgentId, view, add: addParam } = Route.useSearch()
|
||||
const qc = useQueryClient()
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
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 [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [approveAllOpen, setApproveAllOpen] = useState(false)
|
||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
|
||||
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 showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
|
||||
@@ -119,6 +154,23 @@ function AgentsPage() {
|
||||
next.agent !== undefined
|
||||
? next.agent || undefined
|
||||
: 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
|
||||
},
|
||||
replace: true,
|
||||
@@ -156,7 +208,7 @@ function AgentsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const approveAllPending = useMutation({
|
||||
const approveBulk = useMutation({
|
||||
mutationFn: (ids: string[]) =>
|
||||
apiFetch('/api/v1/agents/approve-bulk', {
|
||||
method: 'POST',
|
||||
@@ -170,9 +222,39 @@ function AgentsPage() {
|
||||
),
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Все pending одобрены')
|
||||
onSuccess: (_data, ids) => {
|
||||
toast.success(`Одобрено агентов: ${ids.length}`)
|
||||
setRowSelection({})
|
||||
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) => {
|
||||
rollback?.()
|
||||
@@ -205,6 +287,77 @@ function AgentsPage() {
|
||||
() => items.filter((a) => a.status === 'pending').map((a) => a.id),
|
||||
[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(
|
||||
() =>
|
||||
@@ -216,10 +369,22 @@ function AgentsPage() {
|
||||
}).map((card) => ({
|
||||
...card,
|
||||
onSelect: () => {
|
||||
if (card.id === 'pending') setActiveTab('pending')
|
||||
else if (card.id === 'invited') setActiveTab('invited')
|
||||
else if (card.id === 'approved') setActiveTab('approved')
|
||||
else if (card.id === 'stale') setActiveTab('approved')
|
||||
if (card.id === 'pending') setSearch({ tab: 'pending' })
|
||||
else if (card.id === 'invited') setSearch({ tab: 'invited' })
|
||||
else if (card.id === 'approved') setSearch({ tab: 'approved' })
|
||||
else if (card.id === 'stale') {
|
||||
setSearch({
|
||||
tab: 'all',
|
||||
f: [
|
||||
{
|
||||
id: 'stale',
|
||||
field: 'online',
|
||||
operator: 'is',
|
||||
values: ['offline'],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
},
|
||||
})),
|
||||
[counts],
|
||||
@@ -238,10 +403,10 @@ function AgentsPage() {
|
||||
label: 'Статус',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'invited', label: 'invited' },
|
||||
{ value: 'approved', label: 'approved' },
|
||||
{ value: 'pending', label: 'pending' },
|
||||
{ value: 'revoked', label: 'revoked' },
|
||||
{ value: 'invited', label: 'Приглашён' },
|
||||
{ value: 'pending', label: 'Ожидает одобрения' },
|
||||
{ value: 'approved', label: 'Одобрен' },
|
||||
{ value: 'revoked', label: 'Отозван' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -249,8 +414,17 @@ function AgentsPage() {
|
||||
label: 'Платформа',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'linux', label: 'linux' },
|
||||
{ value: 'mikrotik', label: 'mikrotik' },
|
||||
{ value: 'linux', label: 'Linux' },
|
||||
{ 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 === 'status') return item.status
|
||||
if (field === 'platform') return item.platform
|
||||
if (field === 'online') return isAgentOnline(item) ? 'online' : 'offline'
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
@@ -332,25 +507,25 @@ function AgentsPage() {
|
||||
{
|
||||
id: 'add',
|
||||
title: 'Добавить агента',
|
||||
description: 'Invite + install one-liner',
|
||||
description: 'Приглашение и команда установки',
|
||||
icon: <Plus aria-hidden />,
|
||||
iconClassName: 'text-primary [&_svg]:text-current',
|
||||
badgeLabel: 'Открыть',
|
||||
onSelect: () => setCreateOpen(true),
|
||||
onSelect: () => setCreateOpenState(true),
|
||||
},
|
||||
{
|
||||
id: 'pending',
|
||||
title: 'Pending',
|
||||
description: `${counts.pending} ждут approve`,
|
||||
title: 'Ожидают одобрения',
|
||||
description: `${counts.pending} ждут подтверждения`,
|
||||
icon: <Inbox aria-hidden />,
|
||||
iconClassName: 'text-warning [&_svg]:text-current',
|
||||
badgeLabel: 'Показать',
|
||||
onSelect: () => setActiveTab('pending'),
|
||||
onSelect: () => setSearch({ tab: 'pending' }),
|
||||
},
|
||||
{
|
||||
id: 'rules',
|
||||
title: 'Наборы правил',
|
||||
description: 'Политика firewall',
|
||||
description: 'Политика фаервола',
|
||||
to: '/rules',
|
||||
icon: <ShieldIcon aria-hidden />,
|
||||
iconClassName: 'text-info [&_svg]:text-current',
|
||||
@@ -367,6 +542,26 @@ function AgentsPage() {
|
||||
[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(
|
||||
(id: string) => {
|
||||
setSearch({ agent: id })
|
||||
@@ -384,9 +579,8 @@ function AgentsPage() {
|
||||
)
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters([])
|
||||
setSearchQuery('')
|
||||
}, [])
|
||||
setSearch({ f: [], q: '' })
|
||||
}, [setSearch])
|
||||
|
||||
const handleCopyInstall = useCallback(
|
||||
(curl: string) => {
|
||||
@@ -421,7 +615,7 @@ function AgentsPage() {
|
||||
)
|
||||
|
||||
const addButton = canWrite ? (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Добавить агента
|
||||
</Button>
|
||||
@@ -431,8 +625,13 @@ function AgentsPage() {
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Агенты"
|
||||
description="Ops console: invite, install, approve"
|
||||
actions={addButton}
|
||||
description="Приглашение, установка и одобрение агентов"
|
||||
actions={
|
||||
<>
|
||||
<UpdatedAtLabel updatedAt={agentsQ.dataUpdatedAt} />
|
||||
{addButton}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
|
||||
@@ -443,16 +642,16 @@ function AgentsPage() {
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Attention — pending approve</FrameTitle>
|
||||
<FrameTitle>Требуют одобрения</FrameTitle>
|
||||
<FrameDescription>
|
||||
{counts.pending} агент(ов) ждут одобрения (DNA approval inbox)
|
||||
{counts.pending} агент(ов) ждут одобрения
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setActiveTab('pending')}
|
||||
onClick={() => setSearch({ tab: 'pending' })}
|
||||
>
|
||||
Показать
|
||||
</Button>
|
||||
@@ -460,12 +659,12 @@ function AgentsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
approveAllPending.isPending || pendingIds.length === 0
|
||||
approveBulk.isPending || pendingIds.length === 0
|
||||
}
|
||||
onClick={() => approveAllPending.mutate(pendingIds)}
|
||||
onClick={() => setApproveAllOpen(true)}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
Approve all
|
||||
Утвердить всех
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -473,15 +672,33 @@ function AgentsPage() {
|
||||
</Frame>
|
||||
) : null}
|
||||
|
||||
{counts.applyErrors > 0 ? (
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибки apply</AlertTitle>
|
||||
<AlertDescription>
|
||||
У {counts.applyErrors} агент(ов) есть last_apply_error — откройте
|
||||
карточку для деталей.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{applyErrorAgents.length > 0 ? (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Ошибки применения политики</FrameTitle>
|
||||
<FrameDescription>
|
||||
У {applyErrorAgents.length} агент(ов) последняя попытка apply
|
||||
завершилась ошибкой — откройте карточку для деталей.
|
||||
</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}
|
||||
|
||||
{view === 'table' ? (
|
||||
@@ -489,22 +706,27 @@ function AgentsPage() {
|
||||
data={items}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onFiltersChange={(f) => setSearch({ f })}
|
||||
onClearFilters={handleClearFilters}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSearchChange={(q) => setSearch({ q })}
|
||||
getSearchText={getSearchText}
|
||||
tabs={[...AGENT_TABS]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onTabChange={(tab) => setSearch({ tab })}
|
||||
tabFilter={tabFilter}
|
||||
isLoading={agentsQ.isLoading}
|
||||
isError={agentsQ.isError}
|
||||
error={agentsQ.error}
|
||||
onRetry={() => void agentsQ.refetch()}
|
||||
emptyAction={addButton}
|
||||
filteredEmptyMessage={filteredEmptyMessage}
|
||||
toolbarExtra={viewToggle}
|
||||
enableRowSelection={canWrite}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
selectionToolbar={selectionToolbar}
|
||||
onSelect={handleSelectAgent}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
@@ -520,13 +742,13 @@ function AgentsPage() {
|
||||
<AgentsFleetChrome
|
||||
tabs={countedTabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onTabChange={(tab) => setSearch({ tab })}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onFiltersChange={(f) => setSearch({ f })}
|
||||
onClearFilters={handleClearFilters}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSearchChange={(q) => setSearch({ q })}
|
||||
toolbarExtra={viewToggle}
|
||||
>
|
||||
<AgentCardsGrid
|
||||
@@ -542,7 +764,7 @@ function AgentsPage() {
|
||||
}
|
||||
emptyDescription={
|
||||
items.length === 0
|
||||
? 'Создайте агента — он появится в каталоге как Invited с командой установки.'
|
||||
? 'Создайте агента — он появится в каталоге со статусом «Приглашён» и командой установки.'
|
||||
: undefined
|
||||
}
|
||||
emptyAction={items.length === 0 ? addButton : undefined}
|
||||
@@ -551,7 +773,13 @@ function AgentsPage() {
|
||||
</QueryState>
|
||||
)}
|
||||
|
||||
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<AddAgentSheet
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpenState(open)
|
||||
if (!open && addParam) setSearch({ add: false })
|
||||
}}
|
||||
/>
|
||||
|
||||
<AgentDetailSheet
|
||||
agentId={detailAgentId ?? null}
|
||||
@@ -578,6 +806,45 @@ function AgentsPage() {
|
||||
}}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from '@/queries'
|
||||
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
|
||||
import { agentTrafficDropped } from '@/components/agents/agent-traffic'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
export const Route = createFileRoute('/_auth/')({
|
||||
@@ -75,7 +76,7 @@ function DashboardPage() {
|
||||
},
|
||||
{
|
||||
id: 'dropped',
|
||||
label: 'Dropped',
|
||||
label: 'Заблокировано',
|
||||
value: d?.packets_dropped ?? '—',
|
||||
hint: 'сумма counters',
|
||||
to: '/stats',
|
||||
@@ -85,7 +86,7 @@ function DashboardPage() {
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
label: 'Accepted',
|
||||
label: 'Пропущено',
|
||||
value: d?.packets_accepted ?? '—',
|
||||
hint: 'сумма counters',
|
||||
to: '/stats',
|
||||
@@ -109,7 +110,7 @@ function DashboardPage() {
|
||||
{
|
||||
id: 'agents',
|
||||
title: 'Агенты',
|
||||
description: 'Enroll и approve',
|
||||
description: 'Подключение и одобрение агентов',
|
||||
to: '/agents',
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
@@ -117,7 +118,7 @@ function DashboardPage() {
|
||||
{
|
||||
id: 'lists',
|
||||
title: 'Списки',
|
||||
description: 'Blocklists / sources',
|
||||
description: 'Блок-листы и источники',
|
||||
to: '/lists',
|
||||
icon: <ListIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
@@ -125,7 +126,7 @@ function DashboardPage() {
|
||||
{
|
||||
id: 'rules',
|
||||
title: 'Правила',
|
||||
description: 'Allow / deny policy',
|
||||
description: 'Политика блокировки',
|
||||
to: '/rules',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
@@ -139,6 +140,7 @@ function DashboardPage() {
|
||||
<PageHeader
|
||||
title="Панель управления"
|
||||
description="Обзор агентов и пакетной статистики"
|
||||
actions={<UpdatedAtLabel updatedAt={dash.dataUpdatedAt} />}
|
||||
/>
|
||||
<OpsDashboard
|
||||
isLoading={dash.isLoading}
|
||||
|
||||
@@ -137,12 +137,14 @@ function ListDetailPage() {
|
||||
}),
|
||||
})
|
||||
}
|
||||
const text = addValue.trim()
|
||||
if (!text) throw new Error('Введите значение')
|
||||
// Backend auto-classifies each token via parseListEntry (ip / cidr / hostname)
|
||||
if (!addParse || addParse.valid === 0) {
|
||||
throw new Error('Нет валидных записей')
|
||||
}
|
||||
// Отправляем только валидные уникальные токены; backend классифицирует
|
||||
// каждый через parseListEntry (ip / cidr / hostname)
|
||||
return apiFetch(`/api/v1/lists/${id}/entries`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ values: [text] }),
|
||||
body: JSON.stringify({ values: addParse.values }),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -291,10 +293,35 @@ function ListDetailPage() {
|
||||
return [...kinds]
|
||||
}, [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 =
|
||||
addMode === 'nested'
|
||||
? Boolean(addListRef)
|
||||
: Boolean(addValue.trim())
|
||||
: Boolean(addParse && addParse.valid > 0)
|
||||
|
||||
if (listQ.isLoading) {
|
||||
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) {
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -566,18 +616,38 @@ function ListDetailPage() {
|
||||
placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'}
|
||||
onChange={(e) => setAddValue(e.target.value)}
|
||||
/>
|
||||
{detectedKinds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Определено:
|
||||
</span>
|
||||
{detectedKinds.map((kind) => (
|
||||
<StatusBadge key={kind} status={kind} />
|
||||
))}
|
||||
{addParse && addParse.valid > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{detectedKinds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Определено:
|
||||
</span>
|
||||
{detectedKinds.map((kind) => (
|
||||
<StatusBadge key={kind} status={kind} />
|
||||
))}
|
||||
</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() ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Не распознано — проверьте формат перед добавлением.
|
||||
<p className="text-destructive text-xs">
|
||||
Нет валидных записей — проверьте формат (IP, CIDR или
|
||||
домен).
|
||||
</p>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Plus, RefreshCwIcon } from 'lucide-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 { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||
import {
|
||||
@@ -16,6 +19,12 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
searchToFilters,
|
||||
searchToFlag,
|
||||
searchToQ,
|
||||
searchToTab,
|
||||
} from '@/lib/search-params'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteContent,
|
||||
@@ -25,6 +34,8 @@ import {
|
||||
AutocompleteList,
|
||||
} from '@/components/reui/autocomplete'
|
||||
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 { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
@@ -44,14 +55,61 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} 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/')({
|
||||
validateSearch: parseListsSearch,
|
||||
component: ListsPage,
|
||||
})
|
||||
|
||||
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 = [
|
||||
{ value: 'static', label: 'Ручной' },
|
||||
{ value: 'json_url', label: 'JSON по URL' },
|
||||
@@ -66,24 +124,51 @@ const CREATE_SOURCE_ITEMS = [
|
||||
* Docs: https://reui.io/blocks
|
||||
*/
|
||||
function ListsPage() {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const qc = useQueryClient()
|
||||
const canWrite = useCan()('fw:lists:write')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [source, setSource] = useState<CreateSource>('static')
|
||||
const [extra, setExtra] = useState('')
|
||||
/** Resolved EvoBGP community UUID (Base UI Autocomplete stores label in input). */
|
||||
const [communityId, setCommunityId] = useState('')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const { create: createParam, q: qParam, f: fParam } = Route.useSearch()
|
||||
const activeTab = Route.useSearch().tab ?? 'all'
|
||||
const searchQuery = qParam ?? ''
|
||||
const filters = fParam ?? []
|
||||
const [createOpenState, setCreateOpenState] = useState(false)
|
||||
const createOpen = createOpenState || createParam === true
|
||||
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 communitiesQ = useQuery({
|
||||
...evobgpCommunitiesQueryOptions(),
|
||||
enabled: createOpen && source === 'evobgp_community',
|
||||
enabled: createOpen && createSource === 'evobgp_community',
|
||||
})
|
||||
|
||||
const communityItems = useMemo(
|
||||
@@ -111,25 +196,22 @@ function ListsPage() {
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (values: CreateListValues) => {
|
||||
const config: Record<string, unknown> = {}
|
||||
if (source === 'json_url') {
|
||||
config.url = extra.trim()
|
||||
} else if (source === 'evobgp_community') {
|
||||
config.community_id = communityId || resolveCommunityId(extra)
|
||||
if (values.source === 'json_url') {
|
||||
config.url = values.extra.trim()
|
||||
} else if (values.source === 'evobgp_community') {
|
||||
config.community_id = resolveCommunityId(values.extra)
|
||||
}
|
||||
return apiFetch<{ id: string }>('/api/v1/lists', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, type: source, config }),
|
||||
body: JSON.stringify({ name: values.name, type: values.source, config }),
|
||||
})
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
toast.success('Список создан')
|
||||
setName('')
|
||||
setExtra('')
|
||||
setCommunityId('')
|
||||
setSource('static')
|
||||
setCreateOpen(false)
|
||||
createForm.reset()
|
||||
setCreateOpenState(false)
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
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 (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
@@ -202,7 +277,7 @@ function ListsPage() {
|
||||
Обновить
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Создать
|
||||
</Button>
|
||||
@@ -219,16 +294,16 @@ function ListsPage() {
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={listFilterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
onFiltersChange={(f) => setListSearch({ f })}
|
||||
onClearFilters={() => setListSearch({ f: [], q: '' })}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSearchChange={(q) => setListSearch({ q })}
|
||||
searchPlaceholder="Поиск списков…"
|
||||
getSearchText={getSearchText}
|
||||
tabs={[...LIST_TABS]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onTabChange={(tab) => setListSearch({ tab })}
|
||||
tabFilter={tabFilter}
|
||||
isLoading={listsQ.isLoading}
|
||||
isError={listsQ.isError}
|
||||
@@ -241,7 +316,7 @@ function ListsPage() {
|
||||
title: 'Нет списков',
|
||||
description: 'Создайте первый список для политики firewall.',
|
||||
action: (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Создать
|
||||
</Button>
|
||||
@@ -262,123 +337,137 @@ function ListsPage() {
|
||||
disabled={removeList.isPending}
|
||||
/>
|
||||
|
||||
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новый список</SheetTitle>
|
||||
<SheetDescription>
|
||||
После создания откроется страница со содержимым списка.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="list-name">Имя</FieldLabel>
|
||||
<Input
|
||||
id="list-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
<Select
|
||||
items={[...CREATE_SOURCE_ITEMS]}
|
||||
value={source}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return
|
||||
setSource(v as CreateSource)
|
||||
setExtra('')
|
||||
setCommunityId('')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CREATE_SOURCE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{source === 'json_url' ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
||||
<Input
|
||||
id="list-url"
|
||||
value={extra}
|
||||
placeholder="https://…"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setExtra(v)
|
||||
if (guessListSourceFromInput(v) === 'json_url') {
|
||||
setSource('json_url')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'evobgp_community' ? (
|
||||
<Field>
|
||||
<FieldLabel>BGP community</FieldLabel>
|
||||
<Autocomplete
|
||||
items={communityItems}
|
||||
value={extra}
|
||||
onValueChange={(v) => {
|
||||
setExtra(v)
|
||||
// Base UI {value,label}: input stores label by default —
|
||||
// keep UUID separately for config.community_id.
|
||||
// Preview: https://reui.io/preview/base/components/c-autocomplete-9
|
||||
// Docs: https://reui.io/docs/components/base/autocomplete
|
||||
// Base UI: https://base-ui.com/react/components/autocomplete
|
||||
setCommunityId(resolveCommunityId(v))
|
||||
}}
|
||||
itemToStringValue={(item) => item.label}
|
||||
>
|
||||
<AutocompleteInput
|
||||
placeholder={
|
||||
communitiesQ.isError
|
||||
? 'ID вручную (EvoBGP недоступен)'
|
||||
: 'Поиск community…'
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteEmpty>
|
||||
{communitiesQ.isLoading
|
||||
? 'Загрузка…'
|
||||
: 'Нет совпадений'}
|
||||
</AutocompleteEmpty>
|
||||
<AutocompleteList>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.value} value={item}>
|
||||
{item.label}
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
<FormSheet
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpenState(open)
|
||||
if (!open && createParam) {
|
||||
setListSearch({ create: false })
|
||||
}
|
||||
}}
|
||||
title="Новый список"
|
||||
description="После создания откроется страница со содержимым списка."
|
||||
form={createForm}
|
||||
onSubmit={async (values) => {
|
||||
await create.mutateAsync(values)
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpenState(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton
|
||||
disabled={!canCreate}
|
||||
isLoading={create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
<LoadingButton type="submit" isLoading={create.isPending}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field
|
||||
data-invalid={Boolean(createForm.formState.errors.name) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="list-name">Имя</FieldLabel>
|
||||
<Input
|
||||
id="list-name"
|
||||
placeholder="spamhaus-drop"
|
||||
{...createForm.register('name')}
|
||||
aria-invalid={
|
||||
Boolean(createForm.formState.errors.name) || undefined
|
||||
}
|
||||
/>
|
||||
<FieldError errors={[createForm.formState.errors.name]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
<Controller
|
||||
control={createForm.control}
|
||||
name="source"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={[...CREATE_SOURCE_ITEMS]}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return
|
||||
field.onChange(v)
|
||||
createForm.setValue('extra', '')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CREATE_SOURCE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
{createSource === 'json_url' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(createForm.formState.errors.extra) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
||||
<Input
|
||||
id="list-url"
|
||||
placeholder="https://…"
|
||||
{...createForm.register('extra')}
|
||||
aria-invalid={
|
||||
Boolean(createForm.formState.errors.extra) || undefined
|
||||
}
|
||||
/>
|
||||
<FieldError errors={[createForm.formState.errors.extra]} />
|
||||
</Field>
|
||||
) : null}
|
||||
{createSource === 'evobgp_community' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(createForm.formState.errors.extra) || undefined}
|
||||
>
|
||||
<FieldLabel>BGP community</FieldLabel>
|
||||
<Autocomplete
|
||||
items={communityItems}
|
||||
value={createExtra}
|
||||
onValueChange={(v) => {
|
||||
createForm.setValue('extra', v)
|
||||
// Base UI {value,label}: input stores label by default —
|
||||
// UUID резолвится на submit через resolveCommunityId.
|
||||
// Preview: https://reui.io/preview/base/components/c-autocomplete-9
|
||||
// Docs: https://reui.io/docs/components/base/autocomplete
|
||||
// Base UI: https://base-ui.com/react/components/autocomplete
|
||||
}}
|
||||
itemToStringValue={(item) => item.label}
|
||||
>
|
||||
<AutocompleteInput
|
||||
placeholder={
|
||||
communitiesQ.isError
|
||||
? 'ID вручную (EvoBGP недоступен)'
|
||||
: 'Поиск community…'
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteEmpty>
|
||||
{communitiesQ.isLoading ? 'Загрузка…' : 'Нет совпадений'}
|
||||
</AutocompleteEmpty>
|
||||
<AutocompleteList>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.value} value={item}>
|
||||
{item.label}
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
<FieldError errors={[createForm.formState.errors.extra]} />
|
||||
</Field>
|
||||
) : null}
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
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 {
|
||||
CircleAlertIcon,
|
||||
ListIcon,
|
||||
ShieldIcon,
|
||||
UsersIcon,
|
||||
@@ -9,10 +14,17 @@ import {
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
||||
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 { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
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 {
|
||||
agentsQueryOptions,
|
||||
@@ -23,7 +35,7 @@ import {
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
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 { Switch } from '@evofw/ui/components/switch'
|
||||
import {
|
||||
@@ -66,6 +78,68 @@ export const Route = createFileRoute('/_auth/rules/$setId')({
|
||||
|
||||
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.
|
||||
* 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 [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 [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 approvedAgents = useMemo(
|
||||
@@ -144,14 +220,16 @@ function PolicySetDetailPage() {
|
||||
})
|
||||
|
||||
const createRule = useMutation({
|
||||
mutationFn: () => {
|
||||
mutationFn: (values: RuleValues) => {
|
||||
const body: Record<string, unknown> = {
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
@@ -160,9 +238,7 @@ function PolicySetDetailPage() {
|
||||
onSuccess: () => {
|
||||
toast.success('Правило создано')
|
||||
setRuleOpen(false)
|
||||
setCidr('')
|
||||
setHostname('')
|
||||
setListId('')
|
||||
ruleForm.reset()
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -255,13 +331,6 @@ function PolicySetDetailPage() {
|
||||
state: { rowSelection: agentRowSelection },
|
||||
})
|
||||
|
||||
const canCreate =
|
||||
source === 'list'
|
||||
? Boolean(listId)
|
||||
: source === 'cidr'
|
||||
? Boolean(cidr.trim())
|
||||
: Boolean(hostname.trim())
|
||||
|
||||
if (setQ.isLoading) {
|
||||
return (
|
||||
<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) {
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -411,57 +505,103 @@ function PolicySetDetailPage() {
|
||||
disabled={removeRule.isPending}
|
||||
/>
|
||||
|
||||
<Sheet open={ruleOpen} onOpenChange={setRuleOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новое правило</SheetTitle>
|
||||
<SheetDescription>
|
||||
Один источник: список, CIDR или DNS-имя (priority — в конец)
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<FormSheet
|
||||
open={ruleOpen}
|
||||
onOpenChange={(open) => {
|
||||
setRuleOpen(open)
|
||||
if (!open) ruleForm.reset()
|
||||
}}
|
||||
title="Новое правило"
|
||||
description="Один источник: список, CIDR или DNS-имя (priority — в конец)"
|
||||
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>
|
||||
<FieldLabel>Действие</FieldLabel>
|
||||
<Controller
|
||||
control={ruleForm.control}
|
||||
name="action"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={action}
|
||||
items={RULE_ACTION_ITEMS}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
if (v) setAction(v as 'allow' | 'deny')
|
||||
if (v === 'allow' || v === 'deny') field.onChange(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
{RULE_ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
<Controller
|
||||
control={ruleForm.control}
|
||||
name="source"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={source}
|
||||
items={[...RULE_SOURCE_ITEMS]}
|
||||
value={field.value}
|
||||
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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cidr">CIDR / IP</SelectItem>
|
||||
<SelectItem value="hostname">DNS-имя</SelectItem>
|
||||
<SelectItem value="list">IP-список</SelectItem>
|
||||
{RULE_SOURCE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{source === 'list' ? (
|
||||
<Field>
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
{ruleSource === 'list' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(ruleForm.formState.errors.listId) || undefined}
|
||||
>
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
<Controller
|
||||
control={ruleForm.control}
|
||||
name="listId"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={listSelectItems}
|
||||
value={listId || null}
|
||||
onValueChange={(v) => setListId(v ?? '')}
|
||||
value={field.value || null}
|
||||
onValueChange={(v) => field.onChange(v ?? '')}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Выберите список" />
|
||||
@@ -474,44 +614,44 @@ function PolicySetDetailPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</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"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'hostname' ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="host">DNS-имя</FieldLabel>
|
||||
<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()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[ruleForm.formState.errors.listId]} />
|
||||
</Field>
|
||||
) : null}
|
||||
{ruleSource === 'cidr' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(ruleForm.formState.errors.value) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="rule-cidr">CIDR</FieldLabel>
|
||||
<Input
|
||||
id="rule-cidr"
|
||||
placeholder="203.0.113.0/24"
|
||||
{...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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
@@ -28,7 +29,24 @@ import {
|
||||
} from '@evofw/ui/components/sheet'
|
||||
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/')({
|
||||
validateSearch: parseRulesSearch,
|
||||
component: PolicySetsPage,
|
||||
})
|
||||
|
||||
@@ -39,14 +57,37 @@ export const Route = createFileRoute('/_auth/rules/')({
|
||||
* Create sheet: https://reui.io/preview/base/sheet-8
|
||||
*/
|
||||
function PolicySetsPage() {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const qc = useQueryClient()
|
||||
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 [description, setDescription] = useState('')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
|
||||
const create = useMutation({
|
||||
@@ -63,7 +104,7 @@ function PolicySetsPage() {
|
||||
toast.success('Набор создан')
|
||||
setName('')
|
||||
setDescription('')
|
||||
setSheetOpen(false)
|
||||
setSheetOpenState(false)
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
|
||||
},
|
||||
@@ -203,7 +244,7 @@ function PolicySetsPage() {
|
||||
|
||||
const canCreate = useCan()('fw:policies:write')
|
||||
const addButton = canCreate ? (
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
<Button size="sm" onClick={() => setSheetOpenState(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Новый набор
|
||||
</Button>
|
||||
@@ -225,11 +266,11 @@ function PolicySetsPage() {
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
onFiltersChange={(f) => setRulesSearch({ f })}
|
||||
onClearFilters={() => setRulesSearch({ f: [], q: '' })}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSearchChange={(q) => setRulesSearch({ q })}
|
||||
searchPlaceholder="Поиск наборов…"
|
||||
getSearchText={getSearchText}
|
||||
isLoading={setsQ.isLoading}
|
||||
@@ -260,7 +301,15 @@ function PolicySetsPage() {
|
||||
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">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новый набор</SheetTitle>
|
||||
@@ -289,7 +338,7 @@ function PolicySetsPage() {
|
||||
</Field>
|
||||
</div>
|
||||
<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
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
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 { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
@@ -15,11 +20,14 @@ import { SettingRow } from '@/components/setting-row'
|
||||
import { settingsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
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 { Switch } from '@evofw/ui/components/switch'
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -27,26 +35,63 @@ export const Route = createFileRoute('/_auth/settings')({
|
||||
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() {
|
||||
const qc = useQueryClient()
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
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 initialized = useRef(false)
|
||||
const form = useForm<SettingsValues>({
|
||||
resolver: zodResolver(settingsSchema),
|
||||
defaultValues: EMPTY_VALUES,
|
||||
mode: 'onBlur',
|
||||
})
|
||||
|
||||
// Seed once — background refetch не должен затирать правки в процессе.
|
||||
useEffect(() => {
|
||||
if (settingsQ.data && !initialized.current) {
|
||||
initialized.current = true
|
||||
setForm(settingsQ.data)
|
||||
if (settingsQ.data) {
|
||||
form.reset({ ...EMPTY_VALUES, ...settingsQ.data })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsQ.data])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: (values: SettingsValues) =>
|
||||
apiFetch('/api/v1/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(form),
|
||||
body: JSON.stringify(values),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Сохранено')
|
||||
@@ -56,97 +101,171 @@ function SettingsPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const showQuickActions = form.show_quick_actions !== 'false'
|
||||
|
||||
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',
|
||||
},
|
||||
]
|
||||
const isDirty = form.formState.isDirty
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Настройки"
|
||||
description="Интеграции и enroll — settings-16"
|
||||
description="Интеграции, enroll и интервал синхронизации"
|
||||
actions={
|
||||
isDirty ? (
|
||||
<Badge variant="warning-light" size="sm">
|
||||
Есть несохранённые изменения
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div>
|
||||
<FrameTitle>Control plane</FrameTitle>
|
||||
<FrameDescription>
|
||||
Auth-portal app id: fw · JWT через AUTH_*
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<div className="flex flex-col">
|
||||
{fields.map((f, i) => (
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => save.mutateAsync(values))}
|
||||
className="flex flex-col gap-4 md:gap-6"
|
||||
>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div>
|
||||
<FrameTitle>Control plane</FrameTitle>
|
||||
<FrameDescription>
|
||||
Auth-portal app id: fw · JWT через AUTH_*
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<div className="flex flex-col">
|
||||
<SettingRow
|
||||
key={f.key}
|
||||
title={f.label}
|
||||
description={f.hint}
|
||||
labelFor={f.key}
|
||||
title="Enroll seed"
|
||||
description="X-EvoFW-Seed для install.sh"
|
||||
labelFor="enroll_seed"
|
||||
last={false}
|
||||
>
|
||||
<Input
|
||||
id={f.key}
|
||||
type={f.key.includes('token') ? 'password' : 'text'}
|
||||
value={form[f.key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, [f.key]: e.target.value }))
|
||||
}
|
||||
<Field data-invalid={Boolean(form.formState.errors.enroll_seed) || undefined}>
|
||||
<Input
|
||||
id="enroll_seed"
|
||||
{...form.register('enroll_seed')}
|
||||
aria-invalid={Boolean(form.formState.errors.enroll_seed) || undefined}
|
||||
/>
|
||||
<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
|
||||
title="Быстрые действия"
|
||||
description="Показывать Quick Actions на панели и у агентов"
|
||||
last
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="show_quick_actions"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
checked={field.value !== 'false'}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(checked ? 'true' : 'false')
|
||||
}
|
||||
aria-label="Быстрые действия"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
))}
|
||||
<SettingRow
|
||||
title="Быстрые действия"
|
||||
description="Показывать Quick Actions на панели и у агентов"
|
||||
last
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
{canSave ? (
|
||||
<div className="flex justify-end">
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
isLoading={save.isPending}
|
||||
disabled={!settingsQ.isSuccess}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
<Switch
|
||||
checked={showQuickActions}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
show_quick_actions: checked ? 'true' : 'false',
|
||||
}))
|
||||
}
|
||||
aria-label="Быстрые действия"
|
||||
/>
|
||||
</SettingRow>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
{canSave ? (
|
||||
<div className="flex justify-end">
|
||||
<LoadingButton
|
||||
onClick={() => save.mutate()}
|
||||
isLoading={save.isPending}
|
||||
disabled={!initialized.current}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</form>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
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 {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
@@ -38,28 +42,80 @@ type StatRow = {
|
||||
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')({
|
||||
validateSearch: parseStatsSearch,
|
||||
component: StatsPage,
|
||||
})
|
||||
|
||||
const chartConfig = {
|
||||
dropped: { label: 'Dropped', color: 'var(--chart-1)' },
|
||||
accepted: { label: 'Accepted', color: 'var(--chart-2)' },
|
||||
dropped: { label: 'Заблокировано', color: 'var(--chart-1)' },
|
||||
accepted: { label: 'Пропущено', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
function StatsPage() {
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const dash = useQuery(dashboardQueryOptions())
|
||||
const stats = useQuery(recentStatsQueryOptions())
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const filters = Route.useSearch().f ?? []
|
||||
|
||||
const series = [...(stats.data?.items ?? [])]
|
||||
.reverse()
|
||||
.slice(-40)
|
||||
.map((s) => ({
|
||||
t: s.recorded_at.slice(11, 19),
|
||||
dropped: s.packets_dropped,
|
||||
accepted: s.packets_accepted,
|
||||
}))
|
||||
/** Обновление URL-состояния (f) без перезагрузки. */
|
||||
const setStatsFilters = useCallback(
|
||||
(nextFilters: Filter[]) => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...parseStatsSearch(prev as Record<string, unknown>),
|
||||
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(
|
||||
() =>
|
||||
@@ -76,7 +132,7 @@ function StatsPage() {
|
||||
const kpiCards: KpiStatCard[] = [
|
||||
{
|
||||
id: 'd',
|
||||
label: 'Dropped (sum)',
|
||||
label: 'Заблокировано (сумма)',
|
||||
value: dash.data?.packets_dropped ?? 0,
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
@@ -84,14 +140,14 @@ function StatsPage() {
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
label: 'Accepted (sum)',
|
||||
label: 'Пропущено (сумма)',
|
||||
value: dash.data?.packets_accepted ?? 0,
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
},
|
||||
{
|
||||
id: 'o',
|
||||
label: 'Online agents',
|
||||
label: 'Агентов онлайн',
|
||||
value: dash.data?.agents_online ?? 0,
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
@@ -102,7 +158,7 @@ function StatsPage() {
|
||||
() => [
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: 'Agent',
|
||||
label: 'Агент',
|
||||
type: 'text',
|
||||
placeholder: 'agent id…',
|
||||
},
|
||||
@@ -119,7 +175,9 @@ function StatsPage() {
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'agent_id',
|
||||
header: 'Agent',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Агент" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.agent_id.slice(0, 8)}
|
||||
@@ -128,23 +186,35 @@ function StatsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: 'recorded_at',
|
||||
header: 'Time',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Время" />
|
||||
),
|
||||
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',
|
||||
header: 'Drop',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Заблокировано" />
|
||||
),
|
||||
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',
|
||||
header: 'Accept',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Пропущено" />
|
||||
),
|
||||
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
|
||||
title="Статистика"
|
||||
description="История apply-report counters"
|
||||
actions={<UpdatedAtLabel updatedAt={stats.dataUpdatedAt} />}
|
||||
/>
|
||||
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Тренд (последние samples)</FrameTitle>
|
||||
<FrameTitle>Активность по часам (пик за час)</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{series.length === 0 ? (
|
||||
@@ -203,12 +274,13 @@ function StatsPage() {
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
onFiltersChange={setStatsFilters}
|
||||
onClearFilters={() => setStatsFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
isLoading={stats.isLoading}
|
||||
pageSize={20}
|
||||
emptyState={{
|
||||
title: 'Нет samples',
|
||||
title: 'Нет данных',
|
||||
description: 'Агенты ещё не отправили apply-report.',
|
||||
}}
|
||||
/>
|
||||
|
||||
+4
-1
@@ -4,7 +4,10 @@
|
||||
"reui": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.reui.io/api/mcp",
|
||||
"enabled": true
|
||||
"enabled": true,
|
||||
"headers": {
|
||||
"Authorization": "Bearer {env:REUI_LICENSE_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.14",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Generated
+34
@@ -151,6 +151,12 @@ importers:
|
||||
class-variance-authority:
|
||||
specifier: ^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:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
@@ -277,6 +283,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^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:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
@@ -2704,6 +2713,17 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
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]:
|
||||
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
||||
|
||||
@@ -7813,6 +7833,20 @@ snapshots:
|
||||
|
||||
[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]:
|
||||
dependencies:
|
||||
color-name: 1.1.3
|
||||
|
||||
Reference in New Issue
Block a user