Compare commits

...
7 Commits
Author SHA1 Message Date
Denozordec 927e27640a feat(docs): update speaker installation instructions and logging details
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 1m27s
quality / go (push) Successful in 1m18s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 3m43s
CD / publish (push) Successful in 3m11s
- Enhanced the speaker installation documentation to clarify the use of TCP port 179 and the logging commands for monitoring BIRD and evobgp-agent.
- Updated the speaker form dialog to include additional information about MikroTik connections and logging commands.
- Modified the BIRD configuration to include logging to stderr for better visibility during operations.
- Adjusted the Docker Compose configuration to ensure proper network settings and sysctl configurations for BGP functionality.
2026-08-21 16:11:28 +07:00
DenozordecandCursor 5255cd2d30 fix(web): regenerate OpenAPI types after speaker install schema
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 27s
quality / web (push) Successful in 1m21s
CD / quality (push) Successful in 2m2s
CD / publish (push) Successful in 6m33s
Синхронизирован api.gen.ts с docs/openapi.yaml, чтобы CI check-openapi-gen не падал после BgpSpeakerCreated и install.docker_commands.

Co-authored-by: Cursor <[email protected]>
2026-08-21 14:46:03 +07:00
DenozordecandCursor 3723ba7ed1 feat(httpapi): return replica docker install commands on speaker create
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Failing after 21s
quality / web (push) Successful in 55s
quality / go (push) Successful in 1m2s
quality / bird2 (push) Successful in 16s
CD / quality (push) Failing after 2m49s
CD / publish (push) Skipped
После создания реплики 201 отдаёт agent_secret, node_token и install.docker_commands (bird2 + agent + Traefik DNS-01). UI показывает шаг установки вместо закрытия диалога, чтобы секрет больше не терялся.

Co-authored-by: Cursor <[email protected]>
2026-08-21 13:51:06 +07:00
DenozordecandCursor c5148ac4a0 chore(reui): update ReUI agent skill to 3bdbad788a
quality / changes (push) Successful in 6s
quality / openapi (push) Skipped
quality / web (push) Skipped
CD / quality (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Successful in 33s
Обновить локальный ReUI skill и правила Cursor до версии 3bdbad788a.

Co-authored-by: Cursor <[email protected]>
2026-08-19 20:04:45 +07:00
Denozordec e6e319a275 refactor(web): improve job kind handling in schedule components
CD / quality (push) Successful in 1m49s
quality / changes (push) Successful in 9s
quality / openapi (push) Successful in 25s
quality / web (push) Successful in 1m6s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Successful in 3m2s
- Removed hardcoded job kind checks in schedule components and replaced them with a utility function `isRefreshJobKind` for better maintainability.
- Updated the `ui-labels` module to include additional job kinds and their corresponding labels.
- Adjusted the `DashboardRecentJobsGrid`, `OperationsJobsGrid`, and `ScheduleJobsGrid` components to reflect these changes, enhancing the clarity and consistency of job kind representation.
2026-08-19 09:45:31 +07:00
DenozordecandCursor 869b13cb57 fix(web): open module create in FormDrawer
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 22s
quality / web (push) Successful in 56s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 2m53s
Создание модуля перенесено в боковую Sheet, как community и редактирование. /modules/new и быстрые ссылки открывают список с ?create=true.

Co-authored-by: Cursor <[email protected]>
2026-08-18 22:09:41 +07:00
DenozordecandCursor e190785d4f feat(web): add module create form on /modules/new
quality / commitlint (push) Skipped
quality / changes (push) Successful in 6s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 22s
quality / web (push) Successful in 58s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 3m1s
Форма POST /v1/modules вместо заглушки: тип, название, расписание и DoH для DOMAINS. После 201 переход на карточку; кнопка «Создать» только при bgp:modules:write.

Co-authored-by: Cursor <[email protected]>
2026-08-18 20:17:51 +07:00
44 changed files with 1649 additions and 242 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **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 `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# 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 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -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
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **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 `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# 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 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -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
+4 -4
View File
@@ -6,18 +6,18 @@ 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.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `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 `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -64,7 +64,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 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **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 `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# 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 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -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
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **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 `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# 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 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -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
@@ -167,7 +167,7 @@ export function DashboardModulesGrid({
description="Поиск, сортировка и быстрый переход к настройке"
className="min-w-0"
actions={
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
<PlusIcon />
Создать
</Button>
@@ -16,7 +16,8 @@ const ACTIONS: QuickActionItem[] = [
id: 'new-module',
title: 'Создать модуль',
description: 'Новый модуль маршрутизации и источники префиксов.',
to: '/modules/new',
to: '/modules',
search: { create: true },
icon: <Plus aria-hidden />,
iconClassName: 'text-primary',
},
@@ -29,7 +29,6 @@ export function DashboardRecentJobsGrid({
cell: ({ row }) => (
<DataGridPrimaryCell
title={jobKindRu(row.original.kind)}
accent="mono"
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
@@ -128,7 +128,7 @@ export function LookupAddStep({
<AlertTitle>Нет подходящего модуля</AlertTitle>
<AlertDescription>
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules" search={{ create: true }} />}>
Перейти к модулям
</Button>
</AlertDescription>
@@ -0,0 +1,289 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import { dohProfileShortLabel } from '@/lib/modules/helpers'
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
import { useCreateModuleMutation } from '@/queries/modules'
import type {
BgpCommunity,
DohProfile,
DohResolverPolicy,
ModuleCreate,
ModuleRow,
ModuleType,
} from '@/types/api'
interface ModuleCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
communities: BgpCommunity[]
dohProfiles: DohProfile[]
onCreated?: (mod: ModuleRow) => void
}
/** @see https://reui.io/preview/base/form-7 */
/** @see https://reui.io/preview/base/sheet-8 */
const MODULE_TYPE_ITEMS: { value: ModuleType; label: string }[] = [
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') },
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
]
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
{ value: 'failover', label: dohPolicyRu('failover') },
{ value: 'union', label: dohPolicyRu('union') },
]
export function ModuleCreateDialog({
open,
onOpenChange,
communities,
dohProfiles,
onCreated,
}: ModuleCreateDialogProps) {
const createMutation = useCreateModuleMutation()
const [type, setType] = useState<ModuleType>('IP_RANGES')
const [name, setName] = useState('')
const [enabled, setEnabled] = useState(true)
const [priority, setPriority] = useState('0')
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
const [cronExpr, setCronExpr] = useState('')
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
const isDomains = type === 'DOMAINS'
useEffect(() => {
if (!open) return
setType('IP_RANGES')
setName('')
setEnabled(true)
setPriority('0')
setRefreshIntervalSec('')
setCronExpr('')
setDefaultCommunityId(null)
setDohResolverPolicy('primary_only')
setDohProfileIds([])
}, [open])
function toggleDohProfile(id: string, checked: boolean) {
setDohProfileIds((prev) => {
if (checked) {
if (prev.includes(id)) return prev
return [...prev, id]
}
return prev.filter((x) => x !== id)
})
}
async function save() {
const trimmedName = name.trim()
if (!trimmedName) {
toast.error('Укажите название модуля')
return
}
const priorityNum = Number(priority)
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
toast.error('Приоритет должен быть целым числом')
return
}
let refresh: number | undefined
if (refreshIntervalSec.trim() !== '') {
const n = Number(refreshIntervalSec)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
toast.error('Интервал обновления должен быть целым числом ≥ 0')
return
}
refresh = n
}
const body: ModuleCreate = {
type,
name: trimmedName,
enabled,
priority: priorityNum,
}
if (refresh !== undefined) {
body.refresh_interval_sec = refresh
}
const cron = cronExpr.trim()
if (cron) {
body.cron_expr = cron
}
if (defaultCommunityId) {
body.default_community_id = defaultCommunityId
}
if (isDomains) {
body.doh_resolver_policy = dohResolverPolicy
body.doh_profile_ids = dohProfileIds
}
try {
const created = await createMutation.mutateAsync(body)
onOpenChange(false)
onCreated?.(created)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новый модуль"
description="Тип задаётся один раз. Записи добавляются на карточке модуля."
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
Создать
</LoadingButton>
</>
}
>
<SelectField
id="mod-create-type"
label="Тип"
items={MODULE_TYPE_ITEMS}
value={type}
onValueChange={(v) => {
if (v) setType(v)
}}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-name">Название</Label>
<Input
id="mod-create-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Имя модуля"
/>
</div>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="mod-create-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
Выключенный модуль не участвует в обновлении и применении.
</p>
</div>
<Checkbox
id="mod-create-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-priority">Приоритет</Label>
<Input
id="mod-create-priority"
type="number"
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
<Input
id="mod-create-interval"
type="number"
min={0}
placeholder="пусто = по умолчанию"
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
<Input
id="mod-create-cron"
placeholder="0 * * * *"
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
/>
</div>
<CommunitySelect
id="mod-create-community"
label="Community по умолчанию"
value={defaultCommunityId}
onValueChange={setDefaultCommunityId}
communities={communities}
nullable
/>
{isDomains ? (
<>
<SelectField
id="mod-create-doh-policy"
label="Политика DoH"
items={DOH_POLICY_ITEMS}
value={dohResolverPolicy}
onValueChange={(v) => {
if (v) setDohResolverPolicy(v)
}}
/>
<div className="flex flex-col gap-2">
<Label>DoH профили</Label>
{dohProfiles.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
) : (
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
{dohProfiles.map((p) => {
const checked = dohProfileIds.includes(p.id)
return (
<label
key={p.id}
htmlFor={`mod-create-doh-${p.id}`}
className="flex cursor-pointer items-start gap-3"
>
<Checkbox
id={`mod-create-doh-${p.id}`}
checked={checked}
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
className="mt-0.5"
/>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">
{dohProfileShortLabel(p.id, dohProfiles)}
</span>
<span className="text-muted-foreground truncate text-xs" title={p.url}>
{p.url}
</span>
</span>
</label>
)
})}
</div>
)}
</div>
</>
) : null}
</FormDrawer>
)
}
@@ -36,9 +36,8 @@ const SPEAKER_TABS = [
]
const ROLE_OPTIONS = [
{ value: 'primary', label: 'Основной' },
{ value: 'secondary', label: 'Резервный' },
{ value: 'speaker', label: 'Спикер' },
{ value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер' },
]
function createDefaultSpeakerFilters(): Filter[] {
@@ -1,15 +1,19 @@
import { useEffect, useState } from 'react'
import { Copy, TriangleAlert } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Textarea } from '@evobgp/ui/components/textarea'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useCreateSpeakerMutation } from '@/queries/network'
import type { BgpSpeakerCreate } from '@/types/api'
import type { BgpSpeakerCreate, SpeakerRow } from '@/types/api'
interface SpeakerFormDialogProps {
open: boolean
@@ -35,8 +39,20 @@ function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string)
return JSON.stringify(meta)
}
function tlsIncomplete(
agentDomain: string,
letsencryptEmail: string,
cfToken: string,
panelIP: string,
): boolean {
return !agentDomain.trim() || !letsencryptEmail.trim() || !cfToken.trim() || !panelIP.trim()
}
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
const createMutation = useCreateSpeakerMutation()
const { isCopied, copyToClipboard } = useCopyToClipboard({
onCopy: () => toast.success('Команда скопирована'),
})
const [endpoint, setEndpoint] = useState('')
const [role, setRole] = useState('replica')
@@ -44,6 +60,13 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
const [nodeIpv4, setNodeIpv4] = useState('')
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
const [bgpSourceManual, setBgpSourceManual] = useState(false)
const [letsencryptEmail, setLetsencryptEmail] = useState('')
const [cfDnsToken, setCfDnsToken] = useState('')
const [panelIP, setPanelIP] = useState('')
const [created, setCreated] = useState<SpeakerRow | null>(null)
const isReplica = role === 'replica'
const installCommands = created?.install?.docker_commands ?? ''
useEffect(() => {
if (!open) return
@@ -53,6 +76,10 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
setNodeIpv4('')
setBgpSourceIpv4('')
setBgpSourceManual(false)
setLetsencryptEmail('')
setCfDnsToken('')
setPanelIP('')
setCreated(null)
}, [open])
function handleEndpointChange(value: string) {
@@ -81,82 +108,211 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
endpoint: ep,
role: role.trim() || 'replica',
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
control_plane_url: window.location.origin,
}
if (isReplica) {
if (letsencryptEmail.trim()) body.letsencrypt_email = letsencryptEmail.trim()
if (cfDnsToken.trim()) body.cf_dns_api_token = cfDnsToken.trim()
if (panelIP.trim()) body.panel_ip_whitelist = panelIP.trim()
}
try {
await createMutation.mutateAsync(body)
const row = await createMutation.mutateAsync(body)
if (row.install?.docker_commands) {
setCreated(row)
return
}
onOpenChange(false)
} catch {
// toast in mutation
}
}
const showingInstall = created !== null && Boolean(installCommands)
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новый спикер"
description="BIRD-агент на ноде реплики или плоскости управления"
className="sm:max-w-md"
title={showingInstall ? 'Установка на ноду' : 'Новый спикер'}
description={
showingInstall
? 'Секреты показываются один раз. Скопируйте команду на VPS реплики.'
: 'BIRD-агент на ноде реплики или плоскости управления'
}
className={showingInstall ? 'sm:max-w-2xl' : 'sm:max-w-md'}
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
Создать
</LoadingButton>
</>
showingInstall ? (
<>
<Button
variant="outline"
type="button"
onClick={() => copyToClipboard(installCommands)}
>
<Copy />
{isCopied ? 'Скопировано' : 'Копировать команду'}
</Button>
<Button type="button" onClick={() => onOpenChange(false)}>
Готово
</Button>
</>
) : (
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
Создать
</LoadingButton>
</>
)
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
<Input
id="speaker-endpoint"
placeholder="https://node.example.com:8443"
value={endpoint}
onChange={(e) => handleEndpointChange(e.target.value)}
/>
</div>
<SelectField
id="speaker-role"
label="Роль"
items={[
{ value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер (плоскость)' },
]}
value={role}
onValueChange={(v) => setRole(v ?? 'replica')}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
<Input
id="speaker-agent-domain"
placeholder="bird-agent.example.com"
value={agentDomain}
onChange={(e) => setAgentDomain(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
<Input
id="speaker-node-ipv4"
placeholder="203.0.113.10"
value={nodeIpv4}
onChange={(e) => handleNodeIpv4Change(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
<Input
id="speaker-bgp-source"
placeholder="203.0.113.10"
value={bgpSourceIpv4}
onChange={(e) => {
setBgpSourceManual(true)
setBgpSourceIpv4(e.target.value)
}}
/>
</div>
{created && installCommands ? (
<SpeakerInstallStep created={created} commands={installCommands} />
) : (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
<Input
id="speaker-endpoint"
placeholder="https://node.example.com"
value={endpoint}
onChange={(e) => handleEndpointChange(e.target.value)}
/>
</div>
<SelectField
id="speaker-role"
label="Роль"
items={[
{ value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер (плоскость)' },
]}
value={role}
onValueChange={(v) => setRole(v ?? 'replica')}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
<Input
id="speaker-agent-domain"
placeholder="bird-agent.example.com"
value={agentDomain}
onChange={(e) => setAgentDomain(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
<Input
id="speaker-node-ipv4"
placeholder="203.0.113.10"
value={nodeIpv4}
onChange={(e) => handleNodeIpv4Change(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
<Input
id="speaker-bgp-source"
placeholder="203.0.113.10"
value={bgpSourceIpv4}
onChange={(e) => {
setBgpSourceManual(true)
setBgpSourceIpv4(e.target.value)
}}
/>
</div>
{isReplica ? (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-le-email">Email Let's Encrypt</Label>
<Input
id="speaker-le-email"
type="email"
placeholder="[email protected]"
value={letsencryptEmail}
onChange={(e) => setLetsencryptEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-cf-token">Cloudflare DNS API token</Label>
<Input
id="speaker-cf-token"
type="password"
autoComplete="off"
placeholder="Zone:DNS:Edit"
value={cfDnsToken}
onChange={(e) => setCfDnsToken(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-panel-ip">IP панели (whitelist)</Label>
<Input
id="speaker-panel-ip"
placeholder="203.0.113.1/32"
value={panelIP}
onChange={(e) => setPanelIP(e.target.value)}
/>
</div>
{tlsIncomplete(agentDomain, letsencryptEmail, cfDnsToken, panelIP) ? (
<Alert variant="warning">
<TriangleAlert />
<AlertTitle>Traefik не выпустит сертификат</AlertTitle>
<AlertDescription>
Нужны домен агента, email LE, Cloudflare token и IP панели. Иначе в
команде останутся плейсхолдеры CHANGE_ME_*.
</AlertDescription>
</Alert>
) : null}
</>
) : null}
</>
)}
</FormDrawer>
)
}
function SpeakerInstallStep({
created,
commands,
}: {
created: SpeakerRow
commands: string
}) {
return (
<>
<Alert variant="warning">
<TriangleAlert />
<AlertTitle>Сохраните сейчас</AlertTitle>
<AlertDescription>
agent_secret и node_token больше не будут показаны. Traefik на ноде выпускает
сертификат через DNS-01 (Cloudflare). MikroTik стучится на IP ноды:179; 80/443
только агент панели. Логи: docker compose logs -f bird2 evobgp-agent.
</AlertDescription>
</Alert>
<div className="flex flex-col gap-1">
<Label>ID спикера</Label>
<code className="break-all font-mono text-xs">{created.id}</code>
</div>
{created.agent_secret ? (
<div className="flex flex-col gap-1">
<Label>agent_secret</Label>
<code className="break-all font-mono text-xs">{created.agent_secret}</code>
</div>
) : null}
{created.node_token ? (
<div className="flex flex-col gap-1">
<Label>node_token</Label>
<code className="break-all font-mono text-xs">{created.node_token}</code>
</div>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-docker-commands">Docker-команды</Label>
<Textarea
id="speaker-docker-commands"
readOnly
value={commands}
className="min-h-64 font-mono text-xs"
/>
</div>
</>
)
}
@@ -43,7 +43,6 @@ export function OperationsJobsGrid({
cell: ({ row }) => (
<DataGridPrimaryCell
title={jobKindRu(row.original.kind)}
accent="mono"
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ??
@@ -1,21 +1,28 @@
import { Link } from '@tanstack/react-router'
import { Boxes, Plus } from 'lucide-react'
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
import { Button } from '@evobgp/ui/components/button'
/** empty-state-3 pattern for first module. */
export function ProjectsEmptyState() {
export function ProjectsEmptyState({
canCreate = true,
onCreate,
}: {
canCreate?: boolean
onCreate?: () => void
}) {
return (
<IllustratedEmptyState
icon={Boxes}
title="Создайте первый модуль"
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
action={
<Button size="sm" render={<Link to="/modules/new" />}>
<Plus />
Новый модуль
</Button>
canCreate && onCreate ? (
<Button size="sm" onClick={onCreate}>
<Plus />
Новый модуль
</Button>
) : undefined
}
/>
)
@@ -16,7 +16,7 @@ import {
SelectValue,
} from '@evobgp/ui/components/select'
import { cn } from '@evobgp/ui/lib/utils'
import { jobKindRu } from '@/lib/ui-labels'
import { isRefreshJobKind, jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
import { ScheduleCalendarView } from './schedule-calendar-view'
@@ -34,7 +34,7 @@ function jobTimestamp(job: JobRow): string | undefined {
}
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
if (filter === 'refresh') return job.kind === 'module_refresh'
if (filter === 'refresh') return isRefreshJobKind(job.kind)
if (filter === 'failed')
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
return true
@@ -4,6 +4,7 @@ import { FrameDataGrid } from '@/components/reui-kit'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { isRefreshJobKind } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
import { ScheduleJobsGrid } from './schedule-jobs-grid'
@@ -11,7 +12,7 @@ import { ScheduleJobsGrid } from './schedule-jobs-grid'
type JobTab = 'all' | 'refresh' | 'failed'
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
if (tab === 'refresh') return items.filter((j) => j.kind === 'module_refresh')
if (tab === 'refresh') return items.filter((j) => isRefreshJobKind(j.kind))
if (tab === 'failed')
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
return items
@@ -20,7 +21,7 @@ function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
function tabCounts(items: JobRow[]) {
return {
all: items.length,
refresh: items.filter((j) => j.kind === 'module_refresh').length,
refresh: items.filter((j) => isRefreshJobKind(j.kind)).length,
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
.length,
}
@@ -21,7 +21,7 @@ export function ScheduleJobsGrid({
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} accent="mono" />,
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} />,
meta: { headerTitle: 'Вид' },
},
{
+21 -1
View File
@@ -30,9 +30,28 @@ export function moduleTypeRu(type: string): string {
const JOB_KIND_RU: Record<string, string> = {
module_refresh: 'Обновление модуля',
tenant_refresh: 'Обновление тенанта',
peer_reconcile: 'Согласование пиров',
deploy_apply: 'Применение на спикеры',
apply: 'Применение конфигурации',
revision_rollback: 'Откат ревизии',
rollback: 'Откат ревизии',
bird_reload: 'Перезагрузка BIRD',
postgres_metrics_refresh: 'Метрики PostgreSQL',
postgres_slow_query_aggregate: 'Медленные запросы PostgreSQL',
postgres_table_bloat_estimate: 'Bloat таблиц PostgreSQL',
postgres_index_usage_analyze: 'Использование индексов PostgreSQL',
postgres_autovacuum_lag_detect: 'Отставание autovacuum',
postgres_vacuum: 'VACUUM PostgreSQL',
postgres_vacuum_analyze: 'VACUUM ANALYZE PostgreSQL',
postgres_analyze: 'ANALYZE PostgreSQL',
postgres_reindex: 'REINDEX PostgreSQL',
postgres_cleanup: 'Очистка PostgreSQL',
maintenance_policy_run: 'Политика обслуживания',
}
export function isRefreshJobKind(kind: string): boolean {
return kind === 'module_refresh' || kind === 'tenant_refresh'
}
export function jobKindRu(kind: string): string {
@@ -86,9 +105,10 @@ export function bgpSessionStateRu(state: string | null | undefined): string {
export function speakerRoleRu(role: string | null | undefined): string {
switch (role) {
case 'master':
return 'Основной'
case 'primary':
return 'Основной'
case 'replica':
return 'Реплика'
case 'secondary':
return 'Резервный'
case 'speaker':
+13
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { apiJSON, apiMutate } from '@/lib/api-client'
import { overviewKeys } from '@/queries/overview'
import type {
ModuleCreate,
ModulePatch,
ModuleRow,
ModulesResponse,
@@ -64,6 +65,18 @@ export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
})
}
export function useCreateModuleMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ModuleCreate) => apiMutate<ModuleRow>('/v1/modules', 'POST', body),
onSuccess: (data) => {
toast.success('Модуль создан')
invalidateModules(qc, data.id)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать модуль'),
})
}
export function useUpdateModuleMutation() {
const qc = useQueryClient()
return useMutation({
+50 -6
View File
@@ -1,4 +1,4 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Plus, RefreshCw } from 'lucide-react'
@@ -6,18 +6,45 @@ import { Button } from '@evobgp/ui/components/button'
import { FrameDataGrid } from '@/components/reui-kit'
import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
import { ModuleCreateDialog } from '@/components/modules/module-create-dialog'
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteModules } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth'
import {
directoriesCommunitiesQueryOptions,
directoriesDohQueryOptions,
} from '@/queries/directories'
import { modulesListQueryOptions } from '@/queries/modules'
function parseCreateFlag(value: unknown): boolean {
return value === true || value === '1' || value === 'true'
}
export const Route = createFileRoute('/_auth/modules/')({
component: ModulesListComponent,
validateSearch: (search: Record<string, unknown>): { create?: boolean } => {
if (parseCreateFlag(search.create)) return { create: true }
return {}
},
})
function ModulesListComponent() {
const { create } = Route.useSearch()
const navigate = Route.useNavigate()
const query = useQuery(modulesListQueryOptions())
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteModules(sessionQ.data)
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
const createOpen = canWrite && create === true
function setCreateOpen(open: boolean) {
void navigate({ search: open ? { create: true } : {}, replace: true })
}
return (
<div className="flex flex-col gap-6">
@@ -42,10 +69,12 @@ function ModulesListComponent() {
<FrameDataGrid
title="Все модули"
actions={
<Button size="sm" render={<Link to="/modules/new" />}>
<Plus />
Создать
</Button>
canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus />
Создать
</Button>
) : undefined
}
>
<QueryState
@@ -54,7 +83,12 @@ function ModulesListComponent() {
isError={query.isError}
error={query.error}
empty={query.data?.items?.length === 0}
emptyContent={<ProjectsEmptyState />}
emptyContent={
<ProjectsEmptyState
canCreate={canWrite}
onCreate={() => setCreateOpen(true)}
/>
}
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()}
>
@@ -66,6 +100,16 @@ function ModulesListComponent() {
)}
</QueryState>
</FrameDataGrid>
<ModuleCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
communities={communitiesQ.data?.items ?? []}
dohProfiles={dohQ.data?.items ?? []}
onCreated={(mod) => {
void navigate({ to: '/modules/$moduleId', params: { moduleId: mod.id } })
}}
/>
</div>
)
}
+4 -39
View File
@@ -1,42 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Button } from '@evobgp/ui/components/button'
import { PageHeader } from '@/components/page-header'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/modules/new')({
component: NewModuleComponent,
beforeLoad: () => {
throw redirect({ to: '/modules', search: { create: true } })
},
})
function NewModuleComponent() {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<PageHeader
title="Новый модуль"
description="Создание модуля — через API или будущая форма"
/>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Создание через API</FrameTitle>
<FrameDescription>
Форма в UI появится позже. Сейчас модуль можно создать запросом ниже.
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3 text-sm text-muted-foreground">
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{`POST /v1/modules
{ "type": "DOMAINS", "name": "Мой список" }`}
</pre>
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
Назад к списку
</Button>
</FramePanel>
</Frame>
</div>
)
}
+32 -5
View File
@@ -763,7 +763,7 @@ export interface paths {
put?: never;
/**
* Зарегистрировать спикер
* @description Реплика, canary и т.д.
* @description Реплика или master. Для replica 201 содержит agent_secret, node_token и install.docker_commands (bird2 + agent + Traefik LE DNS-01) один раз.
*/
post: operations["createSpeaker"];
delete?: never;
@@ -2733,11 +2733,38 @@ export interface components {
role: string;
/** @description URL agent или https://AGENT_DOMAIN */
endpoint: string;
/** @description JSON-объект. Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4), agent_domain, agent_secret (генерируется при создании если пуст). */
meta_json?: string;
/** @description JSON-объект (строка или object). Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4), agent_domain, agent_secret (генерируется при создании если пуст). */
meta_json?: string | Record<string, never>;
/** @description Email ACME для Traefik на ноде. Только для генерации install.docker_commands, не сохраняется. */
letsencrypt_email?: string;
/** @description Cloudflare DNS API token (Zone:DNS:Edit) для LE DNS-01. Только для install-сниппета, не сохраняется. */
cf_dns_api_token?: string;
/** @description CIDR/IP панели для Traefik ipallowlist. Только для install-сниппета, не сохраняется. */
panel_ip_whitelist?: string;
/**
* Format: uri
* @description Публичный HTTPS URL панели (EVOBGP_CONTROL_PLANE_URL на реплике). Если пуст из Origin / X-Forwarded-Host.
*/
control_plane_url?: string;
} & {
[key: string]: unknown;
};
/** @description Одноразовый пакет установки реплики (только POST /v1/speakers 201). */
SpeakerInstall: {
/** @description Bash: sysctl, heredoc docker-compose.yaml (bird2 + agent + Traefik DNS-01) и docker compose up -d. */
docker_commands?: string;
/** @description Тело docker-compose.yaml без heredoc (превью). */
compose_yaml?: string;
};
BgpSpeakerCreated: components["schemas"]["BgpSpeaker"] & {
/** @description Bearer для Panel→Node (EVOBGP_AGENT_SECRET). Только в 201. */
agent_secret?: string;
/** @description API-ключ role=node (EVOBGP_NODE_TOKEN). Только в 201. */
node_token?: string;
/** @description Ed25519 pubkey для verify-bundle на ноде. */
bundle_pubkey_base64?: string;
install?: components["schemas"]["SpeakerInstall"];
};
BgpSpeakerPatch: {
role?: string;
endpoint?: string;
@@ -4576,13 +4603,13 @@ export interface operations {
};
};
responses: {
/** @description Ресурс создан. */
/** @description Ресурс создан. Для replica — одноразовый install-сниппет. */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BgpSpeaker"];
"application/json": components["schemas"]["BgpSpeakerCreated"];
};
};
default: components["responses"]["DefaultProblem"];
+12 -1
View File
@@ -293,13 +293,24 @@ export type SpeakerRow = {
last_dispatch_error?: string | null
meta_json?: Record<string, unknown>
agent_secret?: string
node_token?: string
bundle_pubkey_base64?: string
install?: SpeakerInstall
live?: SpeakerLiveStatus
}
export type SpeakerInstall = {
docker_commands?: string
compose_yaml?: string
}
export type SpeakersResponse = Page<SpeakerRow>
export type BgpSpeakerCreate = {
endpoint: string
role?: string
meta_json?: string
meta_json?: string | Record<string, string>
letsencrypt_email?: string
cf_dns_api_token?: string
panel_ip_whitelist?: string
control_plane_url?: string
}
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>
File diff suppressed because one or more lines are too long
+1
View File
@@ -1,5 +1,6 @@
# Default BIRD 2 config for EvoBGP Docker stack (operator extends with include "bird.d/*.conf";).
router id 192.0.2.1;
log stderr all;
protocol device {
}
@@ -7,9 +7,12 @@
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls up -d
#
# Profiles:
# production (default) — bird2 host + agent + evobgp-edge
# production (default) — bird2 (speaker-net, 179:179) + agent + evobgp-edge
# plain — bird2 + agent без Traefik (lab)
# fallback — + sync-bundle polling
#
# BGP TCP/179 as on the control plane. Overlay sets router id / local.
# Logs: docker compose logs -f bird2 evobgp-agent
name: evobgp-remote-speaker
@@ -24,13 +27,18 @@ services:
profiles: ["production", "plain", "fallback"]
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
network_mode: host
cap_add:
- NET_ADMIN
# sysctls нельзя с network_mode: host — включите ip_forward на VPS (см. docs/remote-speakers.md)
sysctls:
net.ipv4.ip_forward: "1"
net.ipv6.conf.all.forwarding: "1"
ports:
- "179:179/tcp"
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird
networks:
- speaker-net
logging: *default-logging
evobgp-agent:
+52 -6
View File
@@ -1801,11 +1801,55 @@ components:
type: string
description: URL agent или https://AGENT_DOMAIN
meta_json:
oneOf:
- type: string
- type: object
description: >
JSON-объект (строка или object). Ключи node_ipv4, bird_bgp_source_ipv4
(default = node_ipv4), agent_domain, agent_secret (генерируется при создании если пуст).
letsencrypt_email:
type: string
description: Email ACME для Traefik на ноде. Только для генерации install.docker_commands, не сохраняется.
cf_dns_api_token:
type: string
description: Cloudflare DNS API token (Zone:DNS:Edit) для LE DNS-01. Только для install-сниппета, не сохраняется.
panel_ip_whitelist:
type: string
description: CIDR/IP панели для Traefik ipallowlist. Только для install-сниппета, не сохраняется.
control_plane_url:
type: string
format: uri
description: Публичный HTTPS URL панели (EVOBGP_CONTROL_PLANE_URL на реплике). Если пуст — из Origin / X-Forwarded-Host.
additionalProperties: true
SpeakerInstall:
type: object
description: Одноразовый пакет установки реплики (только POST /v1/speakers 201).
properties:
docker_commands:
type: string
description: >
JSON-объект. Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4),
agent_domain, agent_secret (генерируется при создании если пуст).
additionalProperties: true
Bash: sysctl, heredoc docker-compose.yaml (bird2 + agent + Traefik DNS-01) и docker compose up -d.
compose_yaml:
type: string
description: Тело docker-compose.yaml без heredoc (превью).
BgpSpeakerCreated:
allOf:
- $ref: "#/components/schemas/BgpSpeaker"
- type: object
properties:
agent_secret:
type: string
description: Bearer для Panel→Node (EVOBGP_AGENT_SECRET). Только в 201.
node_token:
type: string
description: API-ключ role=node (EVOBGP_NODE_TOKEN). Только в 201.
bundle_pubkey_base64:
type: string
description: Ed25519 pubkey для verify-bundle на ноде.
install:
$ref: "#/components/schemas/SpeakerInstall"
BgpSpeakerPatch:
type: object
@@ -3360,7 +3404,9 @@ paths:
post:
tags: [Speakers]
summary: Зарегистрировать спикер
description: Реплика, canary и т.д.
description: >
Реплика или master. Для replica 201 содержит agent_secret, node_token и
install.docker_commands (bird2 + agent + Traefik LE DNS-01) — один раз.
operationId: createSpeaker
parameters:
- $ref: "#/components/parameters/TenantId"
@@ -3373,11 +3419,11 @@ paths:
$ref: "#/components/schemas/BgpSpeakerCreate"
responses:
"201":
description: Ресурс создан.
description: Ресурс создан. Для replica — одноразовый install-сниппет.
content:
application/json:
schema:
$ref: "#/components/schemas/BgpSpeaker"
$ref: "#/components/schemas/BgpSpeakerCreated"
default:
$ref: "#/components/responses/DefaultProblem"
+67 -40
View File
@@ -8,52 +8,64 @@ Runbook для реплик **bird2 + evobgp-agent** на отдельных VPS
|-----------|--------|
| Panel → Node:PORT | CP POST `https://AGENT_DOMAIN/v1/agent/sync` |
| SECRET_KEY | `agent_secret` (Bearer) |
| Copy compose | Web UI → карточка спикера |
| Copy compose | Web UI → после создания реплики: docker-команды (bird2 + agent + Traefik) |
| Push Xray JSON | Wake-up → pull signed bundle → verify Ed25519 → apply |
Подробнее: [architecture.md](architecture.md).
## Быстрый старт
1. **CP (microvps-full):** зафиксируйте `EVOBGP_BUNDLE_SEED_HEX` (32 байта hex) — стабильный ключ подписи бандлов.
2. **Web UI → Сеть → Спикеры:** создайте спикер `role=replica`, укажите **Agent domain**, **IP ноды**, **BGP source** (по умолчанию = IP ноды).
3. Сохраните **`agent_secret`** (показывается один раз) и скопируйте **docker-compose** из UI.
4. Выдайте **node API-ключ** ([access.md](access.md)) для `EVOBGP_NODE_TOKEN`.
5. `GET /v1/bundle/signing-public-key``EVOBGP_BUNDLE_PUBKEY_BASE64` на реплике.
6. На VPS реплики:
```bash
cd deploy/compose
cp .env.remote-speaker.example .env.remote-speaker
cp .env.remote-speaker-tls.example .env.remote-speaker-tls
# заполните переменные из UI
docker compose -f docker-compose.remote-speaker.yaml \
--env-file .env.remote-speaker --env-file .env.remote-speaker-tls \
--profile production up -d
```
7. **CP:** `EVOBGP_NODE_DISPATCH_ENABLED=1` — Panel шлёт wake-up после publish.
8. Cloudflare: `AGENT_DOMAIN` → IP VPS, **DNS only** (как Web UI в [quickstart.md](quickstart.md)).
1. **CP (microvps-full):** зафиксируйте `EVOBGP_BUNDLE_SEED_HEX` (32 байта hex) — стабильный ключ подписи бандлов. `EVOBGP_NODE_DISPATCH_ENABLED=1`.
2. Cloudflare: A/AAAA `AGENT_DOMAIN` → публичный IP VPS реплики, режим **DNS only** (серый облачко), как Web UI в [quickstart.md](quickstart.md).
3. **Web UI → Сеть → Спикеры:** создайте спикер `role=replica`. Укажите **домен агента**, **IP ноды**, **BGP source** (по умолчанию = IP ноды), **email Let's Encrypt**, **Cloudflare DNS API token** (`Zone:DNS:Edit`), **IP панели** (CIDR whitelist).
4. В диалоге «Установка на ноду» скопируйте **docker-команды** (секреты `agent_secret` и `node_token` показываются **один раз**). Репозиторий EvoBGP на ноде не нужен: команда пишет `/opt/evobgp-speaker/docker-compose.yaml` (bird2 + agent + Traefik DNS-01) и делает `docker compose up -d`.
5. Если образы из приватного реестра — на VPS заранее `docker login git.shx.one`.
6. Не делайте `docker compose down -v` на реплике без бэкапа тома `evobgp_speaker_traefik_letsencrypt` (`acme.json`).
## Compose-профили
Эталонный compose в репозитории (lab / ручной запуск): [docker-compose.remote-speaker.yaml](../deploy/compose/docker-compose.remote-speaker.yaml). Prod-установка с панели — paste из UI.
| Profile | Состав |
|---------|--------|
| `production` | bird2 (host) + agent + Traefik LE |
| `plain` | bird2 + agent на хосте без Traefik (только lab) |
| `fallback` | + `sync-bundle` polling (`scripts/sync-bundle.sh`) |
## HTTPS на ноде (DNS-01)
Файлы: [docker-compose.remote-speaker.yaml](../deploy/compose/docker-compose.remote-speaker.yaml).
Сертификат **не** выписывает Control Plane и **не** Cloudflare Origin CA. Его выпускает **Traefik на самой реплике** (`evobgp-edge`), resolver `letsencrypt`, **ACME DNS-01** через Cloudflare.
## Firewall
| Кто | Что делает |
|-----|------------|
| Оператор | DNS only: `AGENT_DOMAIN` → IP VPS |
| Traefik на **ноде** | `dnschallenge=true`, `provider=cloudflare` |
| `CF_DNS_API_TOKEN` | В env **реплики** (вшит в команду из UI). Traefik создаёт TXT `_acme-challenge.<AGENT_DOMAIN>` |
| Let's Encrypt | Проверяет TXT, отдаёт сертификат |
| Том | `evobgp_speaker_traefik_letsencrypt``/letsencrypt/acme.json` |
| CP → нода | `https://AGENT_DOMAIN/v1/agent/*` + `Authorization: Bearer <agent_secret>` + Traefik `ipallowlist` (`PANEL_IP_WHITELIST`) |
Порты:
| Порт | Кто | Зачем |
|------|-----|-------|
| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch, health, **`GET /v1/agent/bird/protocols`** (live peer sessions) |
| **179** | BGP peers | Data plane |
| **80** | ACME | Traefik → 443 |
| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch, health, `GET /v1/agent/bird/protocols` |
| **179** | BGP peers | Data plane — Docker `ports: 179:179/tcp`, как на панели |
| **80** | любой | редирект HTTP → HTTPS (не HTTP-01 ACME) |
## Подготовка VPS (перед `docker compose up`)
В панели хостера / security group откройте **TCP 179** (скрипт compose это не делает). Overlay (`bird_bgp_source_ipv4` / `node_ipv4`) задаёт `router id`; host-сеть bird2 не используется.
`bird2`**`network_mode: host`**. Docker **не может** задать `net.ipv4.ip_forward` в таком контейнере; включите на **хосте**:
DNS-01 ходит **исходящим** к Cloudflare API и Let's Encrypt; inbound 80 для выпуска сертификата не нужен. Agent слушает `:8443` только во внутренней docker-сети; снаружи — Traefik 443.
Токен Cloudflare для панели (`evobgp-edge` на CP) в процесс API **не проброшен** — для реплики его задают в форме создания.
Profile `plain` в файле репозитория — только lab без Traefik.
## Compose-профили (файл в репозитории)
| Profile | Состав |
|---------|--------|
| `production` | bird2 (`speaker-net`, `179:179`) + agent + Traefik LE |
| `plain` | bird2 + agent без Traefik (lab; agent на хосте) |
| `fallback` | + `sync-bundle` polling (`scripts/sync-bundle.sh`) |
Команда из UI — самодостаточный yaml **без profiles** (эквивалент production).
## Подготовка VPS
`bird2` в docker-сети с `ports: 179:179/tcp` и `sysctls` ip_forward (как панель). Команда из UI дополнительно включает sysctl на хосте:
```bash
sysctl -w net.ipv4.ip_forward=1
@@ -63,20 +75,33 @@ echo 'net.ipv6.conf.all.forwarding=1' >> /etc/sysctl.d/99-evobgp-bird.conf
sysctl --system
```
## Логи на реплике
BIRD пишет в stderr (`log stderr all`), agent — в stdout. На VPS:
```bash
cd /opt/evobgp-speaker
docker compose logs -f bird2
docker compose logs -f evobgp-agent
```
До первого apply бандла с `protocol bgp` порт 179 может быть CLOSED (нет listener). После sync в логах agent: `sync start` / `sync ok` / `sync failed`.
## Безопасность (три участка)
1. **CP → реплика:** HTTPS (LE) + Traefik ipallowlist + `agent_secret`.
2. **Реплика → CP:** HTTPS + роль `node` (только bundle/latest/enroll).
2. **Реплика → CP:** HTTPS + роль `node` (только bundle/latest/enroll). Ключ создаётся вместе со спикером.
3. **Конфиг:** Ed25519 `bundle.sig`, SHA-256 manifest, `bird -p`, LKG на ноде.
Prod checklist:
- [ ] `EVOBGP_CONTROL_PLANE_URL=https://...`
- [ ] `EVOBGP_CONTROL_PLANE_URL=https://...` (в команде из UI)
- [ ] `EVOBGP_NODE_DISPATCH_ENABLED=1` на CP
- [ ] `EVOBGP_BUNDLE_SEED_HEX` на CP (не менять после выдачи pubkey репликам)
- [ ] Уникальные `agent_secret` и node token на спикер
- [ ] Не использовать profile `plain` в prod
- [ ] Не отключать verify-bundle в agent
- [ ] Не `docker compose down -v` без бэкапа `acme.json`
## Per-speaker BGP source
@@ -94,27 +119,29 @@ Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / ес
| Симптом | Проверка |
|---------|----------|
| `sysctl net.ipv4.ip_forward not allowed in host network` | Уберите sysctls из compose (уже так в main); включите ip_forward на VPS (см. выше) |
| `no service selected` | `--profile production` или `COMPOSE_PROFILES=production` |
| `CHANGE_ME_*` в yaml | В форме не заполнены email LE / CF token / IP панели / домен |
| Traefik отдаёт дефолтный сертификат | DNS only; token `Zone:DNS:Edit`; логи `evobgp-edge`; том acme.json |
| Offline в UI | `GET https://AGENT_DOMAIN/v1/agent/health` с CP; LE cert; whitelist |
| dispatch error | CP logs job meta; firewall 443; `agent_secret` |
| verify-bundle fail | pubkey совпадает с CP seed; пересоберите pubkey после смены seed |
| BGP не поднимается | bird2 `network_mode: host`; peers; MD5 BGP отдельно от HTTP sync |
| BGP не поднимается / сканер CLOSED | `179:179` в compose; SG хостера; `docker compose logs bird2`; пир MikroTik на IP ноды; бандл применён (`sync ok`) |
## Ограничения (scale-review)
- Peers **не** фильтруются по `speaker_id` — один tenant-wide peers fragment на все реплики.
- Разные peer-наборы per site — отдельная итерация pipeline.
- Если Panel не достучится до agent — включите profile `fallback` (polling).
- Если Panel не достучится до agent — включите profile `fallback` (polling) в файле репозитория.
## Связанные env
| Переменная | Где |
|------------|-----|
| `EVOBGP_NODE_DISPATCH_ENABLED=1` | CP |
| `EVOBGP_AGENT_SECRET` | реплика |
| `EVOBGP_NODE_TOKEN` | реплика |
| `EVOBGP_AGENT_SECRET` | реплика (из UI, один раз) |
| `EVOBGP_NODE_TOKEN` | реплика (API-ключ role=node, из UI) |
| `EVOBGP_FIREWALL_FAILOVER_ENABLED=1` | реплика (опционально: отдавать `/v1/firewall/blocklist` при недоступности CP) |
| `EVOBGP_FIREWALL_STATE_FILE` | реплика (default `/var/lib/evobgp-agent/firewall-state.json`) |
| `EVOBGP_BUNDLE_PUBKEY_BASE64` | реплика |
| `EVOBGP_BUNDLE_PUBKEY_BASE64` | реплика (в команде из UI) |
| `PANEL_IP_WHITELIST` | Traefik на реплике |
| `CF_DNS_API_TOKEN` | Traefik на реплике |
| `LETSENCRYPT_EMAIL` | Traefik на реплике |
+21 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -117,6 +118,9 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
if timeout <= 0 {
timeout = 45 * time.Second
}
revID := strings.TrimSpace(req.RevisionID)
log.Printf("agentserver: sync start speaker_id=%s revision_id=%q control_plane=%s",
strings.TrimSpace(s.cfg.SpeakerID), revID, controlPlaneHost(s.cfg.ControlPlaneURL))
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
@@ -124,7 +128,7 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
BaseURL: s.cfg.ControlPlaneURL,
Token: s.cfg.NodeToken,
SpeakerID: s.cfg.SpeakerID,
RevisionID: strings.TrimSpace(req.RevisionID),
RevisionID: revID,
PubKeyB64: s.cfg.PubKeyB64,
PubKeyHex: s.cfg.PubKeyHex,
ExtractDir: s.cfg.ExtractDir,
@@ -134,13 +138,16 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
Timeout: timeout,
})
if err != nil {
log.Printf("agentserver: sync: %v", err)
log.Printf("agentserver: sync failed speaker_id=%s revision_id=%q err=%v",
strings.TrimSpace(s.cfg.SpeakerID), revID, err)
writeProblem(w, http.StatusBadGateway, upstreamErrorDetail)
return
}
if s.cfg.OnSyncSuccess != nil {
s.cfg.OnSyncSuccess(res.RevisionID)
}
log.Printf("agentserver: sync ok speaker_id=%s applied_revision_id=%s",
strings.TrimSpace(s.cfg.SpeakerID), res.RevisionID)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"applied_revision_id": res.RevisionID,
@@ -148,6 +155,18 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
})
}
func controlPlaneHost(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || strings.TrimSpace(u.Host) == "" {
return raw
}
return u.Host
}
func (s *Server) authorize(r *http.Request) bool {
secret := strings.TrimSpace(s.cfg.Secret)
if secret == "" {
+2 -1
View File
@@ -72,7 +72,8 @@ func RenderMainBirdConf(opts MainBirdConfOptions) (string, error) {
}
b.WriteString("router id ")
b.WriteString(strings.TrimSpace(opts.RouterID))
b.WriteString(";\n\n")
b.WriteString(";\n")
b.WriteString("log stderr all;\n\n")
for _, inc := range opts.Includes {
inc = strings.TrimSpace(inc)
if inc == "" {
@@ -1,4 +1,5 @@
router id 192.0.2.1;
log stderr all;
include "bird.d/evobgp_filters_v4.conf";
include "bird.d/evobgp_filters_v6.conf";
@@ -2,6 +2,7 @@
# Standard EvoBGP layout: main skeleton + bird.d fragments (matches StandardIncludeFragments).
router id 192.0.2.1;
log stderr all;
include "bird.d/evobgp_filters_v4.conf";
include "bird.d/evobgp_filters_v6.conf";
+5 -2
View File
@@ -999,9 +999,12 @@ func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
return
}
frags := rev.PreviewFragments
if overlaid, err := pipeline.OverlayFragmentsForSpeaker(s.store, a.TenantID, sid, rid, frags); err == nil {
frags = overlaid
overlaid, err := pipeline.OverlayFragmentsForSpeaker(s.store, a.TenantID, sid, rid, frags)
if err != nil {
writeInternalError(w, "bundle overlay", err)
return
}
frags = overlaid
tgz, err := bundle.BuildGzippedTar(rid, sid, frags, s.bundlePriv)
if err != nil {
writeInternalError(w, "internal", err)
+36 -2
View File
@@ -1129,11 +1129,25 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.Speaker
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
var req speakerCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
metaStr, err := metaJSONRawToString(req.MetaJSON)
if err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "meta_json must be a JSON object or string")
return
}
role := strings.TrimSpace(req.Role)
if role == "" {
role = "replica"
}
body := store.Speaker{
Role: role,
Endpoint: strings.TrimSpace(req.Endpoint),
MetaJSON: metaStr,
}
if err := normalizeSpeakerCreate(&body); err != nil {
writeStoreErr(w, err)
return
@@ -1148,6 +1162,26 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
resp["agent_secret"] = meta.AgentSecret
}
if strings.ToLower(strings.TrimSpace(x.Role)) != "master" {
createdKey, kerr := s.store.CreateAPIKey(a.TenantID, &store.APIKeyCreate{
Name: "speaker:" + x.ID,
Role: "node",
})
if kerr != nil {
_ = s.store.DeleteSpeaker(a.TenantID, x.ID)
writeStoreErr(w, kerr)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
s.recordCRUDAudit(r, a, "bgp.api_key.create", "Created API key "+createdKey.Name, createdKey.ID, map[string]any{"api_key_id": createdKey.ID, "role": createdKey.Role, "speaker_id": x.ID})
resp["node_token"] = createdKey.Token
s.attachReplicaInstall(resp, x, createdKey.Token, req, r)
}
writeJSON(w, http.StatusCreated, resp)
}
+88
View File
@@ -1,17 +1,32 @@
package httpapi
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"evobgp/internal/nodedispatch"
"evobgp/internal/speakerinstall"
"evobgp/internal/store"
)
var errSpeakerMetaJSONType = errors.New("meta_json must be a JSON object or string")
type speakerCreateRequest struct {
Role string `json:"role"`
Endpoint string `json:"endpoint"`
MetaJSON json.RawMessage `json:"meta_json"`
LetsEncryptEmail string `json:"letsencrypt_email"`
CFDNSAPIToken string `json:"cf_dns_api_token"`
PanelIPWhitelist string `json:"panel_ip_whitelist"`
ControlPlaneURL string `json:"control_plane_url"`
}
func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
if sp == nil {
return map[string]any{}
@@ -123,6 +138,79 @@ func normalizeSpeakerCreate(in *store.Speaker) error {
return nil
}
func metaJSONRawToString(raw json.RawMessage) (string, error) {
t := bytes.TrimSpace(raw)
if len(t) == 0 {
return "{}", nil
}
switch t[0] {
case '"':
var s string
if err := json.Unmarshal(t, &s); err != nil {
return "", err
}
s = strings.TrimSpace(s)
if s == "" {
return "{}", nil
}
return s, nil
case '{':
return string(t), nil
default:
return "", errSpeakerMetaJSONType
}
}
func publicControlPlaneURL(r *http.Request, override string) string {
if s := strings.TrimSpace(override); s != "" {
return strings.TrimRight(s, "/")
}
if origin := strings.TrimSpace(r.Header.Get("Origin")); strings.HasPrefix(origin, "http://") || strings.HasPrefix(origin, "https://") {
return strings.TrimRight(origin, "/")
}
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
if proto == "" {
proto = "https"
}
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
if i := strings.Index(host, ","); i >= 0 {
host = strings.TrimSpace(host[:i])
}
if host == "" {
host = strings.TrimSpace(r.Host)
}
if host == "" {
return ""
}
return proto + "://" + host
}
func (s *Server) attachReplicaInstall(resp map[string]any, sp *store.Speaker, nodeToken string, req speakerCreateRequest, r *http.Request) {
if s == nil || sp == nil || resp == nil {
return
}
meta := store.ParseSpeakerMeta(sp.MetaJSON)
built, err := speakerinstall.Build(speakerinstall.Params{
SpeakerID: sp.ID,
AgentSecret: meta.AgentSecret,
NodeToken: nodeToken,
BundlePubkey: s.BundlePublicKeyBase64(),
ControlPlaneURL: publicControlPlaneURL(r, req.ControlPlaneURL),
AgentDomain: meta.AgentDomain,
LetsEncryptEmail: req.LetsEncryptEmail,
CFDNSAPIToken: req.CFDNSAPIToken,
PanelIPWhitelist: req.PanelIPWhitelist,
})
if err != nil {
return
}
resp["bundle_pubkey_base64"] = s.BundlePublicKeyBase64()
resp["install"] = map[string]any{
"docker_commands": built.DockerCommands,
"compose_yaml": built.ComposeYAML,
}
}
func (s *Server) recordSpeakerDispatch(tenantID string, sp *store.Speaker, res nodedispatch.Result) {
if s == nil || s.store == nil || sp == nil {
return
+112
View File
@@ -33,12 +33,23 @@ func TestPostSpeaker_defaultsFromEndpointIP(t *testing.T) {
if out["agent_secret"] == nil || out["agent_secret"] == "" {
t.Fatal("expected agent_secret on create")
}
if out["node_token"] == nil || out["node_token"] == "" {
t.Fatal("expected node_token on replica create")
}
if out["node_ipv4"] != "203.0.113.55" {
t.Fatalf("node_ipv4: %#v", out["node_ipv4"])
}
if out["bird_bgp_source_ipv4"] != "203.0.113.55" {
t.Fatalf("bird_bgp_source_ipv4: %#v", out["bird_bgp_source_ipv4"])
}
install, _ := out["install"].(map[string]any)
if install == nil {
t.Fatal("expected install on replica create")
}
cmd, _ := install["docker_commands"].(string)
if !strings.Contains(cmd, "traefik") || !strings.Contains(cmd, "dnschallenge") {
t.Fatalf("docker_commands missing traefik dns challenge: %s", cmd[:min(200, len(cmd))])
}
}
func TestDeleteSpeaker(t *testing.T) {
@@ -84,3 +95,104 @@ func TestGetBundleSigningPublicKey(t *testing.T) {
t.Fatalf("missing public_key_base64: %#v", out)
}
}
func TestPostSpeaker_installCommandsAndMetaObject(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
body := `{
"endpoint":"https://bgp-dc2.example.com",
"role":"replica",
"meta_json":{"agent_domain":"bgp-dc2.example.com","node_ipv4":"203.0.113.10"},
"letsencrypt_email":"[email protected]",
"cf_dns_api_token":"cf-token-xyz",
"panel_ip_whitelist":"203.0.113.1/32",
"control_plane_url":"https://cp.example.com"
}`
req := httptest.NewRequest(http.MethodPost, "/v1/speakers", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer edkey")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h := srv.Handler()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
id, _ := out["id"].(string)
if id == "" {
t.Fatal("missing id")
}
secret, _ := out["agent_secret"].(string)
token, _ := out["node_token"].(string)
pub, _ := out["bundle_pubkey_base64"].(string)
if secret == "" || token == "" || pub == "" {
t.Fatalf("missing one-shot secrets: %#v", out)
}
install, _ := out["install"].(map[string]any)
cmd, _ := install["docker_commands"].(string)
for _, want := range []string{
"traefik",
"dnschallenge=true",
"dnschallenge.provider=cloudflare",
"CF_DNS_API_TOKEN",
"cf-token-xyz",
"Host(`bgp-dc2.example.com`)",
secret,
token,
"https://cp.example.com",
`"179:179/tcp"`,
} {
if !strings.Contains(cmd, want) {
t.Errorf("docker_commands missing %q", want)
}
}
get := httptest.NewRequest(http.MethodGet, "/v1/speakers/"+id, nil)
get.Header.Set("Authorization", "Bearer edkey")
grec := httptest.NewRecorder()
h.ServeHTTP(grec, get)
if grec.Code != http.StatusOK {
t.Fatalf("GET status %d body %s", grec.Code, grec.Body.String())
}
got := grec.Body.String()
if strings.Contains(got, secret) || strings.Contains(got, token) || strings.Contains(got, "docker_commands") {
t.Fatalf("GET must not leak install secrets: %s", got)
}
}
func TestPostSpeaker_masterSkipsInstall(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
body := `{"endpoint":"https://127.0.0.1:8080","role":"master"}`
req := httptest.NewRequest(http.MethodPost, "/v1/speakers", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer edkey")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out["node_token"] != nil {
t.Fatalf("master must not mint node_token: %#v", out["node_token"])
}
if out["install"] != nil {
t.Fatalf("master must not include install: %#v", out["install"])
}
}
+8 -4
View File
@@ -19,9 +19,13 @@ func BirdLocalsForSpeaker(st store.Backend, tenantID, speakerID string) birdLoca
return loc
}
meta := store.ParseSpeakerMeta(sp.MetaJSON)
if s := strings.TrimSpace(meta.BirdBgpSourceIPv4); s != "" {
loc.routerID = s
loc.localV4 = s
src := strings.TrimSpace(meta.BirdBgpSourceIPv4)
if src == "" {
src = strings.TrimSpace(meta.NodeIPv4)
}
if src != "" {
loc.routerID = src
loc.localV4 = src
}
if s := strings.TrimSpace(meta.BirdBgpSourceIPv6); s != "" {
loc.localV6 = s
@@ -32,7 +36,7 @@ func BirdLocalsForSpeaker(st store.Backend, tenantID, speakerID string) birdLoca
// OverlayFragmentsForSpeaker re-renders bird.conf and peers fragment with speaker-specific BIRD locals.
func OverlayFragmentsForSpeaker(st store.Backend, tenantID, speakerID, revisionID string, frags map[string]string) (map[string]string, error) {
if frags == nil {
return nil, fmt.Errorf("pipeline: overlay: nil fragments")
frags = map[string]string{}
}
locals := BirdLocalsForSpeaker(st, tenantID, speakerID)
out := make(map[string]string, len(frags))
+45
View File
@@ -40,3 +40,48 @@ func TestOverlayFragmentsForSpeaker_differentRouterID(t *testing.T) {
t.Fatalf("sp2 router: %s", out2["bird.conf"])
}
}
func TestOverlayFragmentsForSpeaker_nodeIPv4Fallback(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
sp, err := m.CreateSpeaker(tenant, &store.Speaker{
Role: "replica",
Endpoint: "https://node.example.com",
MetaJSON: `{"node_ipv4":"198.51.100.9"}`,
})
if err != nil {
t.Fatal(err)
}
out, err := pipeline.OverlayFragmentsForSpeaker(m, tenant, sp.ID, "rev1", nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out["bird.conf"], "198.51.100.9") {
t.Fatalf("expected node_ipv4 as router id, got: %s", out["bird.conf"])
}
}
func TestOverlayFragmentsForSpeaker_sourceOverridesNodeIPv4(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
sp, err := m.CreateSpeaker(tenant, &store.Speaker{
Role: "replica",
Endpoint: "https://node.example.com",
MetaJSON: `{"node_ipv4":"198.51.100.9","bird_bgp_source_ipv4":"203.0.113.40"}`,
})
if err != nil {
t.Fatal(err)
}
out, err := pipeline.OverlayFragmentsForSpeaker(m, tenant, sp.ID, "rev1", map[string]string{})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out["bird.conf"], "203.0.113.40") {
t.Fatalf("source should win: %s", out["bird.conf"])
}
if strings.Contains(out["bird.conf"], "198.51.100.9") {
t.Fatalf("node_ipv4 should not win over source: %s", out["bird.conf"])
}
}
+263
View File
@@ -0,0 +1,263 @@
// Package speakerinstall generates a one-shot docker compose snippet for a replica node
// (bird2 + evobgp-agent + Traefik Let's Encrypt DNS-01), matching
// deploy/compose/docker-compose.remote-speaker.yaml production services.
package speakerinstall
import (
"os"
"strings"
"text/template"
)
const (
defaultRegistry = "git.shx.one/denozord"
defaultImageTag = "latest"
placeholderAgentDomain = "CHANGE_ME_AGENT_DOMAIN"
placeholderLetsEncryptEmail = "CHANGE_ME_LETSENCRYPT_EMAIL"
placeholderCFDNSToken = "CHANGE_ME_CF_DNS_API_TOKEN"
placeholderPanelIP = "CHANGE_ME_PANEL_IP"
placeholderControlPlaneURL = "CHANGE_ME_CONTROL_PLANE_URL"
)
// Params are values baked into the one-shot compose (not stored on the speaker row).
type Params struct {
Registry string
ImageTag string
SpeakerID string
AgentSecret string
NodeToken string
BundlePubkey string
ControlPlaneURL string
AgentDomain string
LetsEncryptEmail string
CFDNSAPIToken string
PanelIPWhitelist string
}
// Result is the pasteable install payload for POST /v1/speakers 201.
type Result struct {
ComposeYAML string
DockerCommands string
}
type renderData struct {
BirdImage string
AgentImage string
SpeakerID string
AgentSecret string
NodeToken string
BundlePubkey string
ControlPlaneURL string
AgentDomain string
LetsEncryptEmail string
CFDNSAPIToken string
PanelIPWhitelist string
}
const composeTemplate = `# EvoBGP replica: bird2 + evobgp-agent + Traefik (Let's Encrypt DNS-01 / Cloudflare).
# Generated by control plane. Do not commit secrets. ACME state: volume evobgp_speaker_traefik_letsencrypt.
# BGP: ports 179:179 like control plane (overlay sets router id). Logs: docker compose logs -f bird2 evobgp-agent
name: evobgp-remote-speaker
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
bird2:
image: {{.BirdImage}}
restart: unless-stopped
cap_add:
- NET_ADMIN
sysctls:
net.ipv4.ip_forward: "1"
net.ipv6.conf.all.forwarding: "1"
ports:
- "179:179/tcp"
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird
networks:
- speaker-net
logging: *default-logging
evobgp-agent:
image: {{.AgentImage}}
restart: unless-stopped
depends_on:
- bird2
cap_add:
- NET_ADMIN
environment:
EVOBGP_AGENT_LISTEN: ":8443"
EVOBGP_AGENT_SECRET: {{yamlQuote .AgentSecret}}
EVOBGP_CONTROL_PLANE_URL: {{yamlQuote .ControlPlaneURL}}
EVOBGP_NODE_TOKEN: {{yamlQuote .NodeToken}}
EVOBGP_SPEAKER_ID: {{yamlQuote .SpeakerID}}
EVOBGP_BUNDLE_PUBKEY_BASE64: {{yamlQuote .BundlePubkey}}
EVOBGP_BIRD_EXTRACT_DIR: /etc/bird
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird
entrypoint: ["/usr/local/bin/evobgp-agent"]
command: ["serve", "-socket=/run/bird/bird.ctl"]
networks:
- speaker-net
labels:
- traefik.enable=true
- traefik.http.routers.evobgp-agent.rule={{traefikHost .AgentDomain}}
- traefik.http.routers.evobgp-agent.entrypoints=websecure
- traefik.http.routers.evobgp-agent.tls=true
- traefik.http.routers.evobgp-agent.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-agent.middlewares=panel-ipwhitelist@docker
- traefik.http.middlewares.panel-ipwhitelist.ipallowlist.sourcerange={{.PanelIPWhitelist}}
- traefik.http.services.evobgp-agent.loadbalancer.server.port=8443
logging: *default-logging
evobgp-edge:
image: traefik:latest
restart: unless-stopped
depends_on:
- evobgp-agent
ports:
- "80:80"
- "443:443"
environment:
DOCKER_API_VERSION: "1.44"
CF_DNS_API_TOKEN: {{yamlQuote .CFDNSAPIToken}}
command:
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --certificatesresolvers.letsencrypt.acme.email={{.LetsEncryptEmail}}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik_letsencrypt:/letsencrypt
networks:
- speaker-net
logging: *default-logging
networks:
speaker-net:
volumes:
bird_etc:
bird_run:
traefik_letsencrypt:
name: evobgp_speaker_traefik_letsencrypt
`
var composeTpl = template.Must(template.New("remote-speaker").Funcs(template.FuncMap{
"yamlQuote": yamlDoubleQuote,
"traefikHost": traefikHostRule,
}).Parse(composeTemplate))
// Build returns compose YAML and bash docker_commands for a replica VPS.
func Build(p Params) (Result, error) {
p = normalize(p)
data := renderData{
BirdImage: p.Registry + "/evobgp-bird2:" + p.ImageTag,
AgentImage: p.Registry + "/evobgp-agent:" + p.ImageTag,
SpeakerID: p.SpeakerID,
AgentSecret: p.AgentSecret,
NodeToken: p.NodeToken,
BundlePubkey: p.BundlePubkey,
ControlPlaneURL: p.ControlPlaneURL,
AgentDomain: p.AgentDomain,
LetsEncryptEmail: p.LetsEncryptEmail,
CFDNSAPIToken: p.CFDNSAPIToken,
PanelIPWhitelist: p.PanelIPWhitelist,
}
var yaml strings.Builder
if err := composeTpl.Execute(&yaml, data); err != nil {
return Result{}, err
}
compose := strings.TrimSpace(yaml.String()) + "\n"
return Result{
ComposeYAML: compose,
DockerCommands: dockerCommands(compose),
}, nil
}
func normalize(p Params) Params {
p.Registry = firstNonEmpty(p.Registry, os.Getenv("EVOBGP_REGISTRY"), defaultRegistry)
p.ImageTag = firstNonEmpty(p.ImageTag, os.Getenv("EVOBGP_IMAGE_TAG"), defaultImageTag)
p.AgentDomain = sanitizeHost(firstNonEmpty(p.AgentDomain, placeholderAgentDomain))
p.LetsEncryptEmail = firstNonEmpty(p.LetsEncryptEmail, placeholderLetsEncryptEmail)
p.CFDNSAPIToken = firstNonEmpty(p.CFDNSAPIToken, placeholderCFDNSToken)
p.PanelIPWhitelist = firstNonEmpty(p.PanelIPWhitelist, placeholderPanelIP)
p.ControlPlaneURL = strings.TrimRight(firstNonEmpty(p.ControlPlaneURL, placeholderControlPlaneURL), "/")
return p
}
func dockerCommands(composeYAML string) string {
var b strings.Builder
b.WriteString(`# EvoBGP replica: bird2 + agent + Traefik (Let's Encrypt DNS-01 / Cloudflare)
# docker login git.shx.one # if images are private
# BGP TCP/179 published like the control plane. Cloud security group must allow 179.
# Logs: cd /opt/evobgp-speaker && docker compose logs -f bird2 evobgp-agent
set -euo pipefail
sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv6.conf.all.forwarding=1
mkdir -p /etc/sysctl.d
printf '%s\n' 'net.ipv4.ip_forward=1' 'net.ipv6.conf.all.forwarding=1' > /etc/sysctl.d/99-evobgp-bird.conf
mkdir -p /opt/evobgp-speaker
cat > /opt/evobgp-speaker/docker-compose.yaml <<'EVOBGP_SPEAKER_COMPOSE_EOF'
`)
b.WriteString(composeYAML)
if !strings.HasSuffix(composeYAML, "\n") {
b.WriteByte('\n')
}
b.WriteString("EVOBGP_SPEAKER_COMPOSE_EOF\n")
b.WriteString("cd /opt/evobgp-speaker && docker compose up -d\n")
return b.String()
}
func traefikHostRule(domain string) string {
return "Host(`" + domain + "`)"
}
func yamlDoubleQuote(s string) string {
escaped := strings.ReplaceAll(s, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
return `"` + escaped + `"`
}
func sanitizeHost(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "https://")
s = strings.TrimPrefix(s, "http://")
if i := strings.IndexAny(s, "/:"); i >= 0 {
s = s[:i]
}
s = strings.ReplaceAll(s, "`", "")
s = strings.ReplaceAll(s, `"`, "")
s = strings.ReplaceAll(s, "'", "")
if s == "" {
return placeholderAgentDomain
}
return s
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
+71
View File
@@ -0,0 +1,71 @@
package speakerinstall
import (
"strings"
"testing"
)
func TestBuild_includesTraefikDNS01(t *testing.T) {
res, err := Build(Params{
SpeakerID: "11111111-1111-1111-1111-111111111111",
AgentSecret: "secret-abc",
NodeToken: "node-tok",
BundlePubkey: "pubkey==",
ControlPlaneURL: "https://cp.example.com",
AgentDomain: "bgp-dc2.example.com",
LetsEncryptEmail: "[email protected]",
CFDNSAPIToken: "cf-token-xyz",
PanelIPWhitelist: "203.0.113.1/32",
})
if err != nil {
t.Fatal(err)
}
cmd := res.DockerCommands
for _, want := range []string{
"traefik",
"dnschallenge=true",
"dnschallenge.provider=cloudflare",
"CF_DNS_API_TOKEN",
"cf-token-xyz",
"Host(`bgp-dc2.example.com`)",
"secret-abc",
"node-tok",
"https://cp.example.com",
"docker compose up -d",
"sysctl -w net.ipv4.ip_forward=1",
"evobgp_speaker_traefik_letsencrypt",
`"179:179/tcp"`,
"net.ipv4.ip_forward: \"1\"",
} {
if !strings.Contains(cmd, want) {
t.Errorf("docker_commands missing %q", want)
}
}
if strings.Contains(cmd, "network_mode: host") {
t.Error("replica bird2 must not use network_mode: host")
}
if strings.Contains(cmd, "?set ") {
t.Error("compose must bake values, not ${VAR:?set VAR}")
}
if res.ComposeYAML == "" {
t.Fatal("compose_yaml empty")
}
}
func TestBuild_placeholdersWhenEmpty(t *testing.T) {
res, err := Build(Params{SpeakerID: "id", AgentSecret: "s", NodeToken: "t", BundlePubkey: "p"})
if err != nil {
t.Fatal(err)
}
for _, ph := range []string{
placeholderAgentDomain,
placeholderLetsEncryptEmail,
placeholderCFDNSToken,
placeholderPanelIP,
placeholderControlPlaneURL,
} {
if !strings.Contains(res.DockerCommands, ph) {
t.Errorf("expected placeholder %s", ph)
}
}
}