Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5255cd2d30 | ||
|
|
3723ba7ed1 | ||
|
|
c5148ac4a0 | ||
|
|
e6e319a275 | ||
|
|
869b13cb57 | ||
|
|
e190785d4f |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,210 @@ 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).
|
||||
</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: 'Вид' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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
+52
-6
@@ -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"
|
||||
|
||||
|
||||
+51
-37
@@ -8,32 +8,50 @@ 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.
|
||||
|
||||
## HTTPS на ноде (DNS-01)
|
||||
|
||||
Сертификат **не** выписывает Control Plane и **не** Cloudflare Origin CA. Его выпускает **Traefik на самой реплике** (`evobgp-edge`), resolver `letsencrypt`, **ACME DNS-01** через Cloudflare.
|
||||
|
||||
| Кто | Что делает |
|
||||
|-----|------------|
|
||||
| Оператор | 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` |
|
||||
| **179** | BGP peers | Data plane |
|
||||
| **80** | любой | редирект HTTP → HTTPS (не HTTP-01 ACME) |
|
||||
|
||||
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 | Состав |
|
||||
|---------|--------|
|
||||
@@ -41,19 +59,11 @@ Runbook для реплик **bird2 + evobgp-agent** на отдельных VPS
|
||||
| `plain` | bird2 + agent на хосте без Traefik (только lab) |
|
||||
| `fallback` | + `sync-bundle` polling (`scripts/sync-bundle.sh`) |
|
||||
|
||||
Файлы: [docker-compose.remote-speaker.yaml](../deploy/compose/docker-compose.remote-speaker.yaml).
|
||||
Команда из UI — самодостаточный yaml **без profiles** (эквивалент production).
|
||||
|
||||
## Firewall
|
||||
## Подготовка VPS
|
||||
|
||||
| Порт | Кто | Зачем |
|
||||
|------|-----|-------|
|
||||
| **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 |
|
||||
|
||||
## Подготовка VPS (перед `docker compose up`)
|
||||
|
||||
`bird2` — **`network_mode: host`**. Docker **не может** задать `net.ipv4.ip_forward` в таком контейнере; включите на **хосте**:
|
||||
`bird2` — **`network_mode: host`**. Docker **не может** задать `net.ipv4.ip_forward` в таком контейнере. Команда из UI включает sysctl; для постоянства:
|
||||
|
||||
```bash
|
||||
sysctl -w net.ipv4.ip_forward=1
|
||||
@@ -66,17 +76,18 @@ sysctl --system
|
||||
## Безопасность (три участка)
|
||||
|
||||
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
|
||||
|
||||
@@ -95,7 +106,8 @@ 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 |
|
||||
@@ -105,16 +117,18 @@ Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / ес
|
||||
|
||||
- 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 на реплике |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,103 @@ 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",
|
||||
} {
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// 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.
|
||||
|
||||
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
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
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
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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",
|
||||
} {
|
||||
if !strings.Contains(cmd, want) {
|
||||
t.Errorf("docker_commands missing %q", want)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user