Compare commits

...
15 Commits
Author SHA1 Message Date
DenozordecandCursor 5255cd2d30 fix(web): regenerate OpenAPI types after speaker install schema
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 27s
quality / web (push) Successful in 1m21s
CD / quality (push) Successful in 2m2s
CD / publish (push) Successful in 6m33s
Синхронизирован api.gen.ts с docs/openapi.yaml, чтобы CI check-openapi-gen не падал после BgpSpeakerCreated и install.docker_commands.

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 20:17:51 +07:00
Denozordec 3fd05ff833 fix(tests): improve concurrency handling in worker tests and enhance module cloning
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / go (push) Successful in 1m14s
quality / bird2 (push) Successful in 18s
CD / quality (push) Successful in 1m47s
CD / publish (push) Successful in 2m53s
- Added environment variable `EVOBGP_JOB_MAX_CONCURRENT` to control job concurrency in tests.
- Modified worker test to ensure proper synchronization of job processing by holding workers until both jobs are enqueued.
- Updated memory store methods to return cloned module instances, preventing unintended mutations of original modules during operations.
2026-08-18 19:58:39 +07:00
Denozordec e7f24f0be4 feat(docker): update runtime configurations and documentation for evobgp components
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / go (push) Failing after 44s
quality / bird2 (push) Skipped
CD / quality (push) Failing after 55s
CD / publish (push) Skipped
- Changed the inheritance for `evobgp-deploy` and `evobgp-node` targets to use `_go-runtime-birdc`, reflecting the inclusion of the `bird` and `birdc` components.
- Updated README and Dockerfile comments to clarify the runtime environments for various evobgp components, specifying the use of `bird` and `birdc` for parse-check functionality.
- Adjusted quickstart documentation to accurately describe the `evobgp-api` image, highlighting the inclusion of `bird` and `birdc` for enhanced functionality.
2026-08-18 19:47:14 +07:00
Denozordec 1bfe460e4b feat(ci): enhance caching strategy and update workflows for pnpm and Go
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 25s
quality / web (push) Successful in 1m16s
quality / go (push) Successful in 2m46s
quality / bird2 (push) Successful in 17s
CD / quality (push) Successful in 5m0s
CD / publish (push) Successful in 4m22s
- Updated CI workflows to export cache paths for pnpm and Go, improving cache efficiency and reducing package download times.
- Replaced corepack enable step with cache path exports in multiple workflows.
- Adjusted cache paths to utilize environment variables for better flexibility and clarity.
- Enhanced README documentation to reflect changes in caching mechanisms and runner configurations.
2026-08-18 19:13:10 +07:00
Denozordec e55d2c5aba feat(docker): enable relative path support for Docker bake configurations
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 28s
quality / web (push) Successful in 1m2s
quality / go (push) Successful in 1m6s
quality / bird2 (push) Successful in 17s
CD / quality (push) Successful in 3m12s
CD / publish (push) Successful in 5m44s
- Added `BUILDX_BAKE_FILE_RELATIVE_PATHS` environment variable to CI workflows to support relative paths in Docker bake commands.
- Updated `docker-bake.hcl` comments to clarify context resolution from the current working directory.
- Revised README documentation to reflect changes in context resolution and usage of the new environment variable.
2026-08-18 18:45:51 +07:00
Denozordec f63e9b5fd0 fix(docker): update base image references to Docker Hub
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / go (push) Successful in 57s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 1m27s
CD / publish (push) Failing after 4m13s
- Changed base image references in `docker-bake.hcl`, Dockerfiles, and `mirror-base-images.sh` from public ECR to Docker Hub to avoid 429 errors and improve reliability.
- Updated README documentation to reflect the new source for base images, clarifying the mirroring process and behavior when tags already exist.
2026-08-18 18:30:22 +07:00
Denozordec 0148d4ca37 feat(docker): enhance base image mirroring process and update documentation
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 40s
quality / web (push) Successful in 1m27s
quality / go (push) Successful in 1m4s
quality / bird2 (push) Successful in 18s
CD / quality (push) Successful in 3m45s
CD / publish (push) Canceled after 10m39s
- Updated `mirror-base-images.sh` to skip existing tags in Gitea and retry on 429 errors, improving efficiency and reducing unnecessary requests.
- Modified CI workflow to utilize a temporary environment file for mirrored base images, ensuring better handling of existing images.
- Enhanced README documentation to reflect changes in the mirroring process and clarify the behavior when tags already exist.
2026-08-18 18:22:26 +07:00
Denozordec e0ecabb22f feat(dependencies): add conventional-commits-parser and update verification script
quality / changes (push) Successful in 8s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 45s
quality / web (push) Successful in 1m30s
quality / go (push) Successful in 1m7s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 3m59s
CD / publish (push) Failing after 1m2s
- Added `conventional-commits-parser` version 6.4.0 to `package.json` and `pnpm-lock.yaml` for improved commit message parsing.
- Updated the commit verification script to utilize the new parser, enhancing error handling for unparseable commit messages.
- Adjusted CI workflow to run the verification script using `pnpm exec` for consistency in package execution.
2026-08-18 18:13:44 +07:00
Denozordec da301b1a94 feat(dependencies): add @redocly/cli and update CI workflows
quality / commitlint (push) Skipped
quality / changes (push) Successful in 20s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 2m54s
quality / web (push) Successful in 1m22s
quality / go (push) Successful in 3m37s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 8m37s
CD / publish (push) Failing after 1m51s
- Added `@redocly/cli` version 1.34.5 to `package.json` and `pnpm-lock.yaml` for OpenAPI linting.
- Updated CI workflows to reflect changes in job names and processes, including adjustments to the `publish` job in the CD workflow.
- Enhanced documentation to clarify the new CI/CD processes and Docker build configurations.
2026-08-18 17:48:41 +07:00
Denozordec 653bc6cc91 fix(web): update Russian translations for various components
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 56s
CI / release (push) Successful in 4m4s
Обновлены переводы на русский язык для компонентов, включая карточки мониторинга, сетевые панели и настройки. Исправлены описания и метки для улучшения пользовательского интерфейса, а также добавлены новые элементы для поддержки локализации в компонентах, таких как DataGrid и ResourcePage.
2026-08-18 17:00:15 +07:00
101 changed files with 4190 additions and 948 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+1 -1
View File
@@ -28,7 +28,7 @@ alwaysApply: true
| Библиотека | Context7 ID | Версия в проекте | Когда |
|------------|-------------|------------------|-------|
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `npx @redocly/cli lint` |
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `pnpm exec redocly lint` |
---
+4 -4
View File
@@ -86,7 +86,7 @@ alwaysApply: true
## Dependency Management
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
*Проверка:* `go.mod`, `.gitea/workflows/ci.yaml`.
*Проверка:* `go.mod`, `.gitea/workflows/quality.yaml`.
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
*Проверка:* review.
@@ -123,10 +123,10 @@ alwaysApply: true
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
*Проверка:* CI job `bird2`.
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`.
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/quality.yaml`.
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
**TEST-05** | MUST | Изменения OpenAPI — `pnpm exec redocly lint docs/openapi.yaml`.
*Проверка:* CI job `openapi`.
---
@@ -229,7 +229,7 @@ alwaysApply: true
```powershell
go vet ./...
go test ./... -race -count=1
npx @redocly/cli lint docs/openapi.yaml
pnpm exec redocly lint docs/openapi.yaml
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
# birdfmt: go test ./internal/birdfmt/... -count=1
+4 -4
View File
@@ -6,18 +6,18 @@ alwaysApply: false
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+49
View File
@@ -0,0 +1,49 @@
# Build context = repository root (deploy/docker/docker-bake.hcl).
.git
.gitea
.github
.cursor
.claude
.codegraph
.agents
memory-bank
.vscode
.idea
**/.DS_Store
**/Thumbs.db
**/.env
**/.env.*
!**/.env.example
!**/.env.*.example
node_modules
**/node_modules
**/dist
apps/web/src/routeTree.gen.ts
apps/web/playwright-report
apps/web/test-results
*.md
AGENTS.md
CONTRIBUTING.md
LICENSE
docs
.pre-commit-config.yaml
.golangci.yml
.releaserc.json
.commitlintrc.*
redocly.yaml
package-lock.json
data
*.exe
*.test
coverage
.coverage
*.out
.release-version
CHANGELOG.md
deploy/docker/docker-bake.override.hcl
deploy/compose/runtime-logs
+41 -16
View File
@@ -1,38 +1,43 @@
# Gitea Actions
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
| Job | PR | push в main |
|-----|-----|-------------|
| changes, openapi, web, go, bird2 | quality gates | quality gates |
| commitlint | да | — |
| **release** | — | semantic-release + docker push (один run) |
| Workflow | Когда | Что |
|----------|--------|-----|
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, go, bird2, commitlint, docker-check |
Подробнее: [docs/releasing.md](../docs/releasing.md).
## CI (quality gates)
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` `go` / `openapi` и т.д. (см. `ci.yaml`).
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/api.md` / `docs/access.md` / `docs/openapi.yaml`) quality jobs не запускают.
На **pull request****commitlint** (Conventional Commits).
На **pull request****commitlint**. При изменении `deploy/docker/**` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`, `go env GOMODCACHE` / `GOCACHE`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
## Release (job в ci.yaml)
Кэшируется целиком: pnpm store + `node_modules` + corepack; Go modules + GOCACHE + `golangci-lint` в `GOBIN`. При hit: `pnpm install --offline`, `go mod download` без сети. `setup-go cache:` и `golangci-lint-action` не используем — они завязаны на `hashFiles`.
После успешных quality gates на **push в main** job **release**:
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR) и **publish** (CD).
## CD (job publish)
После успешных quality gates на **push в main** job **publish**:
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
3. Зеркало base-образов в `evobgp-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evobgp` (`cleanup: false`).
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
Повтор упавшего **release** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-release).
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
### Секреты
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
### Теги образов
@@ -46,6 +51,8 @@ git.shx.one/<owner>/<имя>:sha-<full-sha>
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
Кэш сборки: `evobgp-buildcache:{go,web,birdc}-buildcache` и `evobgp-buildcache:base-*`.
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
Пример:
@@ -55,3 +62,21 @@ docker pull git.shx.one/myuser/evobgp-api:1.2.3
```
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
## act_runner: cache server
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
В `config.yaml` runner:
```yaml
cache:
enabled: true
dir: "" # по умолчанию $HOME/.cache/actcache
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
port: 8088
```
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
+148
View File
@@ -0,0 +1,148 @@
name: CD
on:
push:
branches: [main, master]
permissions:
contents: read
jobs:
quality:
uses: ./.gitea/workflows/quality.yaml
with:
is_pull_request: false
before_sha: ${{ github.event.before }}
head_sha: ${{ github.sha }}
allow_registry_login: false
secrets:
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
publish:
needs: [quality]
if: >-
always() &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
needs.quality.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
releases: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
persist-credentials: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
node_modules
apps/web/node_modules
packages/ui/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: Install release tooling
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: sh scripts/ci/pnpm-ci.sh
- name: Verify releasable commit messages
run: pnpm exec node scripts/commit/verify-release-commits.mjs
- name: Semantic release
run: pnpm exec semantic-release
env:
GITEA_URL: https://git.shx.one
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Detect new release
id: rel
run: |
set -euo pipefail
version=""
if [ -f .release-version ]; then
version="$(tr -d '[:space:]' < .release-version)"
echo "New release from semantic-release: $version"
else
git fetch --tags --force origin || true
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
if [ -n "${tag:-}" ]; then
version="${tag#v}"
echo "Reuse existing tag $tag on HEAD (release retry)"
fi
fi
if [ -n "${version:-}" ]; then
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "released=true" >> "$GITHUB_OUTPUT"
else
echo "released=false" >> "$GITHUB_OUTPUT"
echo "No releasable commits — skipping image publish"
fi
- name: Set up Docker Buildx
if: steps.rel.outputs.released == 'true'
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
with:
name: evobgp
driver: docker-container
cleanup: false
- name: Prepare image metadata
if: steps.rel.outputs.released == 'true'
id: meta
run: |
set -euo pipefail
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea Registry
if: steps.rel.outputs.released == 'true'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Mirror base images into buildcache
if: steps.rel.outputs.released == 'true'
env:
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
run: sh deploy/docker/mirror-base-images.sh
- name: Build and push images (bake)
if: steps.rel.outputs.released == 'true'
env:
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
CACHE_REF_BIRDC: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:birdc-buildcache
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
working-directory: deploy/docker
run: |
set -euxo pipefail
if [ -f "${MIRROR_ENV_FILE}" ]; then
set -a
# shellcheck disable=SC1090
. "${MIRROR_ENV_FILE}"
set +a
fi
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
-f docker-bake.hcl default --push
+16 -380
View File
@@ -1,387 +1,23 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ---------------------------------------------------------------------------
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
# ---------------------------------------------------------------------------
changes:
runs-on: ubuntu-latest
outputs:
openapi: ${{ steps.detect.outputs.openapi }}
go: ${{ steps.detect.outputs.go }}
web: ${{ steps.detect.outputs.web }}
bird_conf: ${{ steps.detect.outputs.bird_conf }}
docker_go: ${{ steps.detect.outputs.docker_go }}
docker_web: ${{ steps.detect.outputs.docker_web }}
docker_bird: ${{ steps.detect.outputs.docker_bird }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: detect
name: Detect changed paths per module
run: |
set -euo pipefail
openapi=false
go=false
web=false
bird_conf=false
docker_go=false
docker_web=false
docker_bird=false
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
set_all_flags_true() {
openapi=true
go=true
web=true
bird_conf=true
docker_go=true
docker_web=true
docker_bird=true
}
write_outputs() {
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
}
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
FILES="$(git diff --name-only "$base" "$head")"
else
before="${{ github.event.before }}"
after="${{ github.sha }}"
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
FILES="$(git diff --name-only "$before" "$after")"
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
else
set_all_flags_true
write_outputs
echo "No parent commit — full pipeline (all modules)"
exit 0
fi
fi
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
set_all_flags_true
write_outputs
echo "Empty diff — full pipeline fallback"
exit 0
fi
full_pipeline=false
while IFS= read -r f || [ -n "${f:-}" ]; do
[ -z "${f:-}" ] && continue
case "$f" in
# CI/CD инфраструктура — все узлы quality gates
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
full_pipeline=true
;;
docs/openapi.yaml|redocly.yaml)
openapi=true
;;
docs/api.md|docs/access.md)
openapi=true
go=true
;;
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
;;
apps/web/*|packages/ui/*|packages/shared/*)
web=true
;;
deploy/bird/*)
bird_conf=true
go=true
;;
deploy/compose/*|deploy/docker/*)
docker_go=true
docker_web=true
docker_bird=true
go=true
;;
go.mod|go.sum|go.work)
go=true
;;
migrations/*)
go=true
;;
cmd/*|internal/*|*.go)
go=true
bird_conf=true
;;
docs/*)
go=true
;;
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
full_pipeline=true
;;
*)
go=true
;;
esac
done <<< "$FILES"
if $full_pipeline; then
set_all_flags_true
fi
write_outputs
echo "Changed files (first 30):"
printf '%s\n' "$FILES" | head -n 30
echo "--- flags ---"
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
# ---------------------------------------------------------------------------
openapi:
needs: [changes]
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Enable pnpm via corepack
run: corepack enable
- name: Lint OpenAPI (Redocly)
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
- name: Check OpenAPI→TS codegen is fresh
run: |
set -euxo pipefail
pnpm install --frozen-lockfile
chmod +x scripts/check-openapi-gen.sh
sh scripts/check-openapi-gen.sh
# ---------------------------------------------------------------------------
web:
needs: [changes]
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Enable pnpm via corepack
run: corepack enable
- name: pnpm install, typecheck, lint, test, build
run: |
set -euxo pipefail
pnpm install --frozen-lockfile
pnpm --filter @evobgp/web run typecheck
pnpm --filter @evobgp/web run lint
pnpm --filter @evobgp/web run test
pnpm --filter @evobgp/web run build
# ---------------------------------------------------------------------------
go:
needs: [changes]
if: needs.changes.outputs.go == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.24"
cache: true
cache-dependency-path: go.sum
- name: Vet
run: go vet ./...
- name: Lint httpapi (ERR-01 / ARCH-01)
run: sh scripts/lint-httpapi.sh
- name: Check migration pairs (DEP-03)
run: sh scripts/check-migrations-pair.sh
- name: Validate remote speaker compose
run: sh scripts/validate-remote-speaker-compose.sh
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.64.8
install-mode: goinstall
- name: Test
run: go test ./... -race -count=1
- name: Build all commands
run: |
set -euxo pipefail
out="${RUNNER_TEMP}/evobgp-bin"
mkdir -p "$out"
for d in cmd/*/; do
name="$(basename "$d")"
go build -o "$out/$name" "./$d"
done
# ---------------------------------------------------------------------------
bird2:
runs-on: ubuntu-latest
needs: [changes, go]
if: >-
always() &&
needs.changes.result == 'success' &&
needs.go.result != 'failure' &&
(needs.changes.outputs.go == 'true' ||
needs.changes.outputs.bird_conf == 'true' ||
needs.changes.outputs.docker_bird == 'true' ||
needs.changes.outputs.docker_go == 'true')
steps:
- uses: actions/checkout@v4
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
run: |
set -euxo pipefail
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
$SUDO apt-get update -qq
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
bird --version
- name: bird -p on all scenario bird.conf files
env:
WORKSPACE: ${{ github.workspace }}
run: |
set -euxo pipefail
WS="${WORKSPACE:-$PWD}"
cd "$WS"
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
exit 1
fi
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
echo "==> $conf"
bird -c "$WS/$conf" -p
done
# ---------------------------------------------------------------------------
commitlint:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json
- name: Lint commit messages
run: |
set -euxo pipefail
npm ci
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
# ---------------------------------------------------------------------------
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
# ---------------------------------------------------------------------------
release:
needs: [changes, openapi, web, go, bird2]
if: >-
always() &&
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
needs.changes.result == 'success' &&
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
persist-credentials: true
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json
- name: Install release tooling
run: npm ci
- name: Verify releasable commit messages
run: node scripts/commit/verify-release-commits.mjs
- name: Semantic release
run: npx semantic-release
env:
GITEA_URL: https://git.shx.one
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Detect new release
id: rel
run: |
set -euo pipefail
version=""
if [ -f .release-version ]; then
version="$(tr -d '[:space:]' < .release-version)"
echo "New release from semantic-release: $version"
else
# Re-run after a failed docker step: tag already exists, successCmd
# did not write .release-version (semantic-release is a no-op).
git fetch --tags --force origin || true
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
if [ -n "${tag:-}" ]; then
version="${tag#v}"
echo "Reuse existing tag $tag on HEAD (release retry)"
fi
fi
if [ -n "${version:-}" ]; then
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "released=true" >> "$GITHUB_OUTPUT"
else
echo "released=false" >> "$GITHUB_OUTPUT"
echo "No releasable commits — skipping image publish"
fi
- name: Set up Docker Buildx
if: steps.rel.outputs.released == 'true'
uses: docker/setup-buildx-action@v3
- name: Prepare image metadata
if: steps.rel.outputs.released == 'true'
id: meta
run: |
set -euo pipefail
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea Registry
if: steps.rel.outputs.released == 'true'
uses: docker/login-action@v3
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Build and push images (bake)
if: steps.rel.outputs.released == 'true'
env:
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
run: |
set -euxo pipefail
cd "${{ github.workspace }}/deploy/docker"
sh write-bake-override.sh
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
-f docker-bake.hcl -f docker-bake.override.hcl default --push
quality:
uses: ./.gitea/workflows/quality.yaml
with:
is_pull_request: true
base_sha: ${{ github.event.pull_request.base.sha }}
head_sha: ${{ github.event.pull_request.head.sha }}
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
secrets:
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
+428
View File
@@ -0,0 +1,428 @@
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
name: quality
on:
workflow_call:
inputs:
is_pull_request:
type: boolean
required: true
base_sha:
type: string
required: false
default: ""
head_sha:
type: string
required: false
default: ""
before_sha:
type: string
required: false
default: ""
allow_registry_login:
type: boolean
required: false
default: false
secrets:
ACTIONS_PAT:
required: false
permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
outputs:
openapi: ${{ steps.detect.outputs.openapi }}
go: ${{ steps.detect.outputs.go }}
web: ${{ steps.detect.outputs.web }}
bird_conf: ${{ steps.detect.outputs.bird_conf }}
docker_go: ${{ steps.detect.outputs.docker_go }}
docker_web: ${{ steps.detect.outputs.docker_web }}
docker_bird: ${{ steps.detect.outputs.docker_bird }}
steps:
- if: ${{ inputs.is_pull_request }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- if: ${{ inputs.is_pull_request == false }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2
- id: detect
name: Detect changed paths per module
env:
IS_PR: ${{ inputs.is_pull_request }}
BASE_SHA: ${{ inputs.base_sha }}
HEAD_SHA: ${{ inputs.head_sha }}
BEFORE_SHA: ${{ inputs.before_sha }}
run: |
set -euo pipefail
openapi=false
go=false
web=false
bird_conf=false
docker_go=false
docker_web=false
docker_bird=false
set_all_flags_true() {
openapi=true
go=true
web=true
bird_conf=true
docker_go=true
docker_web=true
docker_bird=true
}
write_outputs() {
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
}
if [ "$IS_PR" = "true" ]; then
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
else
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
before="$BEFORE_SHA"
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
FILES="$(git diff --name-only "$before" "$after")"
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
else
set_all_flags_true
write_outputs
echo "No parent commit — full pipeline (all modules)"
exit 0
fi
fi
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
set_all_flags_true
write_outputs
echo "Empty diff — full pipeline fallback"
exit 0
fi
full_pipeline=false
while IFS= read -r f || [ -n "${f:-}" ]; do
[ -z "${f:-}" ] && continue
case "$f" in
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
full_pipeline=true
;;
docs/openapi.yaml|redocly.yaml)
openapi=true
;;
docs/api.md|docs/access.md)
openapi=true
go=true
;;
.cursor/*|.claude/*|.codegraph/*|memory-bank/*)
;;
*.md|AGENTS.md)
;;
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
;;
apps/web/*|packages/ui/*|packages/shared/*)
web=true
;;
deploy/bird/*)
bird_conf=true
go=true
;;
deploy/compose/*|deploy/docker/*|.dockerignore)
docker_go=true
docker_web=true
docker_bird=true
go=true
;;
go.mod|go.sum|go.work)
go=true
;;
migrations/*)
go=true
;;
cmd/*|internal/*|*.go)
go=true
bird_conf=true
;;
docs/*)
;;
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
full_pipeline=true
;;
*)
;;
esac
done <<< "$FILES"
if $full_pipeline; then
set_all_flags_true
fi
write_outputs
echo "Changed files (first 30):"
printf '%s\n' "$FILES" | head -n 30
echo "--- flags ---"
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
openapi:
needs: [changes]
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
node_modules
apps/web/node_modules
packages/ui/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, Redocly, codegen check
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm exec redocly lint docs/openapi.yaml
chmod +x scripts/check-openapi-gen.sh
sh scripts/check-openapi-gen.sh
web:
needs: [changes]
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
node_modules
apps/web/node_modules
packages/ui/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, typecheck, lint, test, build
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm --filter @evobgp/web run typecheck
pnpm --filter @evobgp/web run lint
pnpm --filter @evobgp/web run test
pnpm --filter @evobgp/web run build
go:
needs: [changes]
if: needs.changes.outputs.go == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: "1.24"
cache: false
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: go-hash
run: echo "key=$(sha256sum go.sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.GOMODCACHE }}
${{ env.GOCACHE }}
${{ env.GOBIN }}
${{ env.GOLANGCI_LINT_CACHE }}
key: go-${{ runner.os }}-1.24-gl1.64.8-${{ steps.go-hash.outputs.key }}
restore-keys: |
go-${{ runner.os }}-1.24-gl1.64.8-
go-${{ runner.os }}-1.24-
- name: Download modules
env:
GOMODCACHE: ${{ env.GOMODCACHE }}
GOCACHE: ${{ env.GOCACHE }}
run: go mod download
- name: Vet
env:
GOFLAGS: -mod=readonly
run: go vet ./...
- name: Lint httpapi (ERR-01 / ARCH-01)
run: sh scripts/lint-httpapi.sh
- name: Check migration pairs (DEP-03)
run: sh scripts/check-migrations-pair.sh
- name: Validate remote speaker compose
run: sh scripts/validate-remote-speaker-compose.sh
- name: golangci-lint
env:
GOLANGCI_LINT_VERSION: v1.64.8
run: sh scripts/ci/golangci-lint.sh
- name: Test
env:
GOFLAGS: -mod=readonly
GOMODCACHE: ${{ env.GOMODCACHE }}
GOCACHE: ${{ env.GOCACHE }}
run: go test ./... -race -count=1
- name: Build all commands
env:
GOFLAGS: -mod=readonly
GOMODCACHE: ${{ env.GOMODCACHE }}
GOCACHE: ${{ env.GOCACHE }}
run: |
set -euxo pipefail
out="${RUNNER_TEMP}/evobgp-bin"
mkdir -p "$out"
for d in cmd/*/; do
name="$(basename "$d")"
go build -o "$out/$name" "./$d"
done
bird2:
runs-on: ubuntu-latest
needs: [changes, go]
if: >-
always() &&
needs.changes.result == 'success' &&
needs.go.result != 'failure' &&
(needs.changes.outputs.go == 'true' ||
needs.changes.outputs.bird_conf == 'true' ||
needs.changes.outputs.docker_bird == 'true' ||
needs.changes.outputs.docker_go == 'true')
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
run: |
set -euxo pipefail
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
$SUDO apt-get update -qq
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
bird --version
- name: bird -p on all scenario bird.conf files
env:
WORKSPACE: ${{ github.workspace }}
run: |
set -euxo pipefail
WS="${WORKSPACE:-$PWD}"
cd "$WS"
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
exit 1
fi
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
echo "==> $conf"
bird -c "$WS/$conf" -p
done
commitlint:
if: inputs.is_pull_request
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
node_modules
apps/web/node_modules
packages/ui/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: Lint commit messages
env:
BASE_SHA: ${{ inputs.base_sha }}
HEAD_SHA: ${{ inputs.head_sha }}
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
docker-check:
needs: [changes]
if: >-
inputs.is_pull_request &&
(needs.changes.outputs.docker_go == 'true' ||
needs.changes.outputs.docker_web == 'true' ||
needs.changes.outputs.docker_bird == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
with:
name: evobgp
driver: docker-container
cleanup: false
- name: Log in to Gitea Registry
if: inputs.allow_registry_login
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: bake --print
working-directory: deploy/docker
env:
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
- name: bake (no push)
if: inputs.allow_registry_login
working-directory: deploy/docker
env:
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
CACHE_REF_GO: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:web-buildcache
CACHE_REF_BIRDC: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:birdc-buildcache
run: |
set -euxo pipefail
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
export CACHE_REF_GO="git.shx.one/${owner_lc}/evobgp-buildcache:go-buildcache"
export CACHE_REF_WEB="git.shx.one/${owner_lc}/evobgp-buildcache:web-buildcache"
export CACHE_REF_BIRDC="git.shx.one/${owner_lc}/evobgp-buildcache:birdc-buildcache"
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+1 -1
View File
@@ -50,6 +50,6 @@ docker compose --profile reference up -d
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
- API: `GET /version` и `GET /v1/version` (поле `version`)
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CD run**, что и релиз (job `publish`)
Лицензия и условия использования — по политике владельца репозитория.
@@ -27,10 +27,10 @@ export function MonitoringHealthCard({
return (
<DonutBreakdownCard
title="Доступность системы"
description="GET /v1/health · GET /v1/ready"
description="Проверки живучести и готовности"
slices={slices}
centerLabel="Проверки"
badge={healthOk ? 'API OK' : undefined}
badge={healthOk ? 'API в норме' : undefined}
/>
)
}
@@ -40,15 +40,15 @@ export function NetworkOverviewAnalyticsCard({
/>
<SegmentedProgressCard
title="Спикеры"
description="Доступность live-агентов"
description="Доступность агентов"
primary={{
value: `${net.speakersOnline}/${net.speakersTotal}`,
label: 'Online',
label: 'В сети',
percent: speakersPct,
}}
secondary={{
value: net.speakersTotal - net.speakersOnline,
label: 'Offline',
label: 'Не в сети',
percent: 100 - speakersPct,
}}
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
@@ -100,7 +100,7 @@ function buildKpis({
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm"
>
{loading ? '…' : 'онлайн'}
{loading ? '…' : 'в сети'}
</Badge>
),
},
@@ -40,7 +40,7 @@ function buildMetrics({
{
id: 'bgp',
title: 'BGP готовность',
label: 'Established / включённые',
label: 'Установлено / включено',
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
@@ -62,13 +62,13 @@ function buildMetrics({
{
id: 'speakers',
title: 'Спикеры',
label: 'Online / всего',
label: 'В сети / всего',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
delta:
loading || network.speakersTotal === 0
? '…'
: network.speakersOnline === network.speakersTotal
? 'все online'
? 'все в сети'
: 'частично',
deltaVariant:
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
@@ -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>
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
{
id: 'lookup',
title: 'Проверка IP/домена',
description: 'Проверка IP в списках и BGP-сообществах.',
description: 'Проверка IP в списках и BGP community.',
to: '/lookup',
icon: <Search aria-hidden />,
iconClassName: 'text-primary',
@@ -16,14 +16,15 @@ const ACTIONS: QuickActionItem[] = [
id: 'new-module',
title: 'Создать модуль',
description: 'Новый модуль маршрутизации и источники префиксов.',
to: '/modules/new',
to: '/modules',
search: { create: true },
icon: <Plus aria-hidden />,
iconClassName: 'text-primary',
},
{
id: 'communities',
title: 'BGP-сообщества',
description: 'Справочник BGP-сообществ для политик экспорта.',
title: 'BGP community',
description: 'Справочник BGP community для политик экспорта.',
to: '/directories',
icon: <Tags aria-hidden />,
iconClassName: 'text-info',
@@ -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)
@@ -71,7 +71,7 @@ export function CommunityFormDialog({
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={editTarget ? 'Редактировать сообщество' : 'Новое сообщество BGP'}
title={editTarget ? 'Редактировать community' : 'Новое BGP community'}
description="Тег для префиксов в фильтрах BIRD"
className="sm:max-w-sm"
footer={
@@ -73,10 +73,10 @@ export function DirectoriesCommunitiesGrid({
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет сообществ"
emptyMessage="Нет community"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск сообществ…"
searchPlaceholder="Поиск community…"
/>
)
}
@@ -70,7 +70,7 @@ export function DohProfileFormDialog({
if (timeoutMs.trim() !== '') {
const ms = Number(timeoutMs)
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
toast.error('Timeout должен быть целым числом > 0')
toast.error('Таймаут должен быть целым числом больше 0')
return
}
timeout = ms
@@ -136,7 +136,7 @@ export function DohProfileFormDialog({
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="doh-timeout">Timeout, мс (опционально)</Label>
<Label htmlFor="doh-timeout">Таймаут, мс (необязательно)</Label>
<Input
id="doh-timeout"
type="number"
+5 -5
View File
@@ -71,7 +71,7 @@ const NAV_GROUPS: NavGroup[] = [
to: '/dashboard',
label: 'Панель',
icon: LayoutDashboard,
description: 'KPI, модули и активность',
description: 'Метрики, модули и активность',
},
],
},
@@ -81,15 +81,15 @@ const NAV_GROUPS: NavGroup[] = [
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' },
],
},
{
label: 'Операции',
items: [
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
],
},
{
@@ -94,8 +94,8 @@ export function SystemMonitorPopover() {
() => [
{
id: 'api',
label: 'API health',
value: healthOk ? 'OK' : '—',
label: 'Состояние API',
value: healthOk ? 'норма' : '—',
unit: '',
percent: healthOk ? 100 : 0,
icon: <HeartPulse aria-hidden />,
@@ -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>
@@ -14,8 +14,8 @@ import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
const CDN_KIND_ITEMS = [
{ value: 'plaintext', label: 'plaintext' },
{ value: 'json', label: 'json' },
{ value: 'plaintext', label: 'Текст' },
{ value: 'json', label: 'JSON' },
] as const
interface ModuleCdnSourceDialogProps {
@@ -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>
)
}
@@ -160,7 +160,7 @@ export function ModuleEditDialog({
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="mod-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
Выключенный модуль не участвует в refresh и apply.
Выключенный модуль не участвует в обновлении и применении.
</p>
</div>
<Checkbox
@@ -10,6 +10,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatDateTime } from '@/lib/modules/display'
import { communityLabel } from '@/lib/modules/helpers'
import { cdnSourceKindRu } from '@/lib/ui-labels'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type {
AsEntry,
@@ -145,7 +146,7 @@ export function ModuleEntriesGrid({
accessorKey: 'source_kind',
header: 'Тип',
cell: ({ row }: { row: { original: CdnSource } }) => (
<CategoryBadge>{row.original.source_kind}</CategoryBadge>
<CategoryBadge>{cdnSourceKindRu(row.original.source_kind)}</CategoryBadge>
),
},
{
@@ -35,7 +35,7 @@ import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)'
return host ? `Плоскость · ${host}` : 'Плоскость управления'
}
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
}
@@ -51,7 +51,7 @@ const filterFields: FilterFieldConfig[] = [
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'IP или Neighbor ID…',
placeholder: 'IP или ID соседа…',
},
]
@@ -94,7 +94,7 @@ export function NetworkDiscoveredPeersCard({
id: 'neighbor_id',
accessorFn: (row) => row.neighbor_id || row.neighbor,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Neighbor ID" />
<DataGridColumnHeader column={column} title="ID соседа" />
),
cell: ({ row }) => (
<DataGridPrimaryCell
@@ -103,7 +103,7 @@ export function NetworkDiscoveredPeersCard({
accent="mono"
/>
),
meta: { headerTitle: 'Neighbor ID' },
meta: { headerTitle: 'ID соседа' },
},
{
accessorKey: 'remote_asn',
@@ -212,7 +212,7 @@ export function NetworkDiscoveredPeersCard({
title="Одобрить пира"
description={
approveTarget
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
? `ID соседа ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
: undefined
}
className="sm:max-w-sm"
@@ -55,7 +55,7 @@ export function NetworkKpi({
}
size="sm"
>
{loading ? '…' : 'онлайн'}
{loading ? '…' : 'в сети'}
</Badge>
),
to: '/network',
@@ -39,11 +39,11 @@ const PEER_TABS = [
const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Установлена' },
{ value: 'Idle', label: 'Idle' },
{ value: 'Active', label: 'Active' },
{ value: 'Connect', label: 'Connect' },
{ value: 'OpenSent', label: 'OpenSent' },
{ value: 'OpenConfirm', label: 'OpenConfirm' },
{ value: 'Idle', label: 'Простой' },
{ value: 'Active', label: 'Поиск' },
{ value: 'Connect', label: 'Соединение' },
{ value: 'OpenSent', label: 'Open отправлен' },
{ value: 'OpenConfirm', label: 'Open подтверждён' },
]
function createDefaultPeerFilters(): Filter[] {
@@ -31,14 +31,13 @@ function speakerDeleteLabel(s: SpeakerRow): string {
const SPEAKER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'online', label: 'Online' },
{ id: 'offline', label: 'Offline' },
{ id: 'online', label: 'В сети' },
{ id: 'offline', label: 'Не в сети' },
]
const ROLE_OPTIONS = [
{ value: 'primary', label: 'primary' },
{ value: 'secondary', label: 'secondary' },
{ value: 'speaker', label: 'speaker' },
{ value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер' },
]
function createDefaultSpeakerFilters(): Filter[] {
@@ -52,7 +51,7 @@ const speakerFilterFields: FilterFieldConfig[] = [
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'endpoint…',
placeholder: 'Адрес агента…',
},
{
key: 'role',
@@ -128,7 +127,7 @@ export function NetworkSpeakersCard({
<>
<ResourcePage
title="Спикеры"
description="BIRD-агенты на нодах tenant"
description="BIRD-агенты на нодах арендатора"
tabs={SPEAKER_TABS}
tabFilter={speakerTabFilter}
filterFields={speakerFilterFields}
@@ -5,7 +5,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { speakerOnlineLabel } from '@/lib/ui-labels'
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api'
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
@@ -20,7 +20,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
cell: ({ row }) => <CategoryBadge>{speakerRoleRu(row.original.role)}</CategoryBadge>,
meta: { headerTitle: 'Роль' },
},
{
@@ -22,7 +22,7 @@ interface PeerFormDialogProps {
function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)'
return host ? `Плоскость · ${host}` : 'Плоскость управления'
}
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
}
@@ -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) {
@@ -74,89 +101,217 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
const ep =
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
if (!ep) {
toast.error('Укажите endpoint или agent domain')
toast.error('Укажите конечную точку или домен агента')
return
}
const body: BgpSpeakerCreate = {
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-агент на ноде реплики или control plane"
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: 'Мастер (CP)' },
]}
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">BGP source IPv4</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="ops@example.com"
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)) ??
@@ -77,7 +77,7 @@ export function OperationsRevisionsGrid({
</Button>
}
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
confirmLabel="Откатить"
destructive
onConfirm={() => rollbackMutation.mutate(row.original.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
}
/>
)
@@ -61,7 +61,7 @@ function DataGridColumnFilter<TData, TValue>({
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} selected
{selectedValues.size} выбрано
</Badge>
) : (
options
@@ -94,7 +94,7 @@ function DataGridColumnFilter<TData, TValue>({
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
No results found.
Ничего не найдено.
</div>
) : (
<div className="p-1">
@@ -170,7 +170,7 @@ function DataGridColumnFilter<TData, TValue>({
}}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
Сбросить фильтры
</div>
</div>
</>
@@ -39,11 +39,11 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
sizes: [5, 10, 25, 50, 100],
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
info: "{from} - {to} of {count}",
info: "{from}{to} из {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
rowsPerPageLabel: "Строк на странице",
previousPageLabel: "Предыдущая страница",
nextPageLabel: "Следующая страница",
ellipsisText: "...",
}
@@ -73,7 +73,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
aria-label="Перетащить строку"
disabled
>
<GripHorizontalIcon aria-hidden="true" />
@@ -89,7 +89,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
aria-label="Перетащить строку"
{...context.attributes}
{...context.listeners}
>
@@ -105,7 +105,7 @@ function DataGridTableDndHeader<TData>({
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
aria-label="Перетащить для изменения порядка"
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
@@ -397,9 +397,9 @@ function DataGridTableVirtual<TData>({
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
props.fetchingMoreMessage || props.loadingMessage || "Загрузка…"
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
props.allRowsLoadedMessage || "Все записи загружены"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
@@ -1241,7 +1241,7 @@ function DataGridTableEmpty() {
colSpan={Math.max(visibleColumnCount, 1)}
className="text-muted-foreground py-6 text-center text-sm"
>
{props.emptyMessage || "No data available"}
{props.emptyMessage || "Нет данных"}
</td>
</tr>
)
@@ -1254,7 +1254,7 @@ function DataGridTableLoader() {
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium">
<Spinner className="size-5 opacity-60" />
{props.loadingMessage || "Loading..."}
{props.loadingMessage || "Загрузка…"}
</div>
</div>
)
@@ -1266,7 +1266,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
return (
<button
type="button"
aria-label={isPinned ? "Unpin row" : "Pin row"}
aria-label={isPinned ? "Открепить строку" : "Закрепить строку"}
onClick={() => {
if (isPinned) {
row.pin(false)
@@ -1322,7 +1322,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
aria-label="Выбрать строку"
className="align-[inherit]"
/>
</>
@@ -1341,7 +1341,7 @@ function DataGridTableRowSelectAll() {
indeterminate={isSomeSelected && !isAllSelected}
disabled={isLoading || recordCount === 0}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
aria-label="Выбрать все"
className="align-[inherit]"
/>
)
@@ -1408,7 +1408,7 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{props.loadingMessage || "Loading..."}
{props.loadingMessage || "Загрузка…"}
</div>
</td>
</tr>
+49 -54
View File
@@ -123,75 +123,70 @@ export interface FilterI18nConfig {
// Default English i18n configuration
export const DEFAULT_I18N: FilterI18nConfig = {
// UI Labels
addFilter: "Filter",
searchFields: "Filter...",
noFieldsFound: "No filters found.",
noResultsFound: "No results found.",
select: "Select...",
true: "True",
false: "False",
min: "Min",
max: "Max",
to: "to",
typeAndPressEnter: "Type and press Enter to add tag",
selected: "selected",
selectedCount: "selected",
addFilter: "Фильтр",
searchFields: "Фильтр…",
noFieldsFound: "Поля не найдены.",
noResultsFound: "Ничего не найдено.",
select: "Выбрать…",
true: "Да",
false: "Нет",
min: "Мин.",
max: "Макс.",
to: "",
typeAndPressEnter: "Введите и нажмите Enter",
selected: "выбрано",
selectedCount: "выбрано",
percent: "%",
defaultCurrency: "$",
defaultColor: "#000000",
addFilterTitle: "Add filter",
addFilterTitle: "Добавить фильтр",
// Operators
operators: {
is: "is",
isNot: "is not",
isAnyOf: "is any of",
isNotAnyOf: "is not any of",
includesAll: "includes all",
excludesAll: "excludes all",
before: "before",
after: "after",
between: "between",
notBetween: "not between",
contains: "contains",
notContains: "does not contain",
startsWith: "starts with",
endsWith: "ends with",
isExactly: "is exactly",
equals: "equals",
notEquals: "not equals",
greaterThan: "greater than",
lessThan: "less than",
overlaps: "overlaps",
includes: "includes",
excludes: "excludes",
includesAllOf: "includes all of",
includesAnyOf: "includes any of",
empty: "is empty",
notEmpty: "is not empty",
is: "равно",
isNot: "не равно",
isAnyOf: "любое из",
isNotAnyOf: "кроме",
includesAll: "включает все",
excludesAll: "исключает все",
before: "до",
after: "после",
between: "между",
notBetween: "вне диапазона",
contains: "содержит",
notContains: "не содержит",
startsWith: "начинается с",
endsWith: "заканчивается на",
isExactly: "точно",
equals: "равно",
notEquals: "не равно",
greaterThan: "больше",
lessThan: "меньше",
overlaps: "пересекается",
includes: "включает",
excludes: "исключает",
includesAllOf: "включает все",
includesAnyOf: "включает любое",
empty: "пусто",
notEmpty: "не пусто",
},
// Placeholders
placeholders: {
enterField: (fieldType: string) => `Enter ${fieldType}...`,
selectField: "Select...",
searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`,
enterKey: "Enter key...",
enterValue: "Enter value...",
enterField: (fieldType: string) => `Введите ${fieldType}`,
selectField: "Выбрать…",
searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}`,
enterKey: "Введите ключ…",
enterValue: "Введите значение…",
},
// Helper functions
helpers: {
formatOperator: (operator: string) => operator.replace(/_/g, " "),
},
// Validation
validation: {
invalidEmail: "Invalid email format",
invalidUrl: "Invalid URL format",
invalidTel: "Invalid phone format",
invalid: "Invalid input format",
invalidEmail: "Некорректный email",
invalidUrl: "Некорректный URL",
invalidTel: "Некорректный телефон",
invalid: "Некорректное значение",
},
}
@@ -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
@@ -107,7 +107,7 @@ export function ScheduleAgendaPanel({
return (
<PanelCard
title="Календарь задач"
description="Задачи refresh и apply по дням"
description="Задачи обновления и применения по дням"
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
>
<div className="flex flex-col lg:flex-row">
@@ -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: 'Вид' },
},
{
@@ -43,7 +43,7 @@ export function ScheduleModulesGrid({
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.cron_expr ??
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec}s` : '—')}
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec} с` : '—')}
</span>
),
meta: { headerTitle: 'Расписание' },
@@ -90,16 +90,16 @@ export function AppearanceSettingsTab() {
</SettingsCard>
<SettingsCard
title="Дашборд"
title="Обзор"
description="Блоки на экране «Обзор»"
>
<SettingsFieldGroup
legend="Быстрые действия"
description="Показывать KPI-like плитки быстрых переходов под метриками."
description="Показывать плитки быстрых переходов под метриками."
>
<SettingRow
title="Быстрые действия"
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
description="Блок с частыми переходами (модули, сеть, деплой) на экране «Обзор»."
last
>
<Switch
@@ -31,8 +31,8 @@ const RUNTIME_LOGS_ENABLED_ITEMS = [
] as const
const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'обнулить (truncate)' },
{ value: 'delete', label: 'удалить файл (delete)' },
{ value: 'truncate', label: 'Обнулить файл' },
{ value: 'delete', label: 'Удалить файл' },
] as const
const BIRD_LABELS: Record<BirdSettingKey, string> = {
@@ -40,12 +40,12 @@ const BIRD_LABELS: Record<BirdSettingKey, string> = {
bird_local_ipv4: 'Локальный IPv4',
bird_local_ipv6: 'Локальный IPv6',
bird_local_asn: 'Локальный ASN',
bird_bgp_source_ipv4: 'BGP source IPv4',
bird_bgp_source_ipv6: 'BGP source IPv6',
bird_bgp_source_ipv4: 'Исходный IPv4 BGP',
bird_bgp_source_ipv6: 'Исходный IPv6 BGP',
peer_discovery_enabled: 'Автообнаружение пиров',
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
peer_discovery_require_external: 'Только external ASN',
peer_discovery_ranges_v4: 'CIDR автообнаружения IPv4',
peer_discovery_ranges_v6: 'CIDR автообнаружения IPv6',
peer_discovery_require_external: 'Только внешние ASN',
}
const BIRD_BOOL_ITEMS = [
@@ -55,10 +55,10 @@ const BIRD_BOOL_ITEMS = [
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
peer_discovery_enabled:
'Dynamic neighbor range в BIRD (карантин import/export none). Требует CIDR.',
'Динамический диапазон соседей в BIRD (карантин import/export none). Нужны CIDR.',
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
peer_discovery_require_external: 'neighbor range … external (любой чужой ASN)',
peer_discovery_require_external: 'Диапазон соседей с внешним (любым чужим) ASN',
}
export function BirdSettingsTab() {
@@ -103,8 +103,8 @@ export function BirdSettingsTab() {
return (
<div className="flex flex-col gap-6">
<SettingsCard
title="BIRD control plane"
description="Глобальные параметры BIRD для pipeline refresh/apply. Сохранение — роль operator."
title="Плоскость управления BIRD"
description="Глобальные параметры BIRD для обновления модулей и применения конфигурации. Сохранение доступно оператору."
footer={
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
<Save />
@@ -184,11 +184,11 @@ export function BirdSettingsTab() {
>
{() => (
<SettingsFieldGroup
legend="Retention"
legend="Хранение"
description="Срок хранения ревизий в минутах."
>
<SettingsSettingField
title="Retention"
title="Срок хранения"
description="Сколько минут хранить ревизии конфигурации."
labelFor="revision_retention_minutes"
stacked
@@ -63,7 +63,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
<SettingsCard
title="Подключение к API"
description="Токен хранится только в этом браузере (localStorage)"
description="Токен хранится только в этом браузере"
footer={
<div className="flex w-full min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="outline" onClick={useDevToken}>
@@ -84,7 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
title="Токен для запросов"
description={
<>
Ключ для заголовка Authorization. Управление ключами tenant в разделе{' '}
Ключ для заголовка Authorization. Управление ключами арендатора в разделе{' '}
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
Права доступа
</Link>
@@ -93,7 +93,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
}
titleAddon={
<Badge variant="outline" size="sm">
localStorage
браузер
</Badge>
}
labelFor="settings-token"
@@ -28,25 +28,25 @@ const ADMIN_SECTIONS = [
title: 'Параметры арендатора',
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
icon: <SlidersHorizontalIcon aria-hidden="true" />,
badge: { label: 'operator', variant: 'warning-light' as const },
badge: { label: 'оператор', variant: 'warning-light' as const },
},
{
id: 'access',
to: '/access' as const,
search: undefined,
title: 'Права доступа',
description: 'API-ключи tenant, роли и управление доступом.',
description: 'API-ключи арендатора, роли и управление доступом.',
icon: <KeyRoundIcon aria-hidden="true" />,
badge: { label: 'operator', variant: 'warning-light' as const },
badge: { label: 'оператор', variant: 'warning-light' as const },
},
{
id: 'monitoring',
to: '/monitoring' as const,
search: undefined,
title: 'Мониторинг',
description: 'Метрики, состояние jobs и observability control plane.',
description: 'Метрики, состояние задач и наблюдаемость плоскости управления.',
icon: <ActivityIcon aria-hidden="true" />,
badge: { label: 'viewer+', variant: 'info-light' as const },
badge: { label: 'просмотр+', variant: 'info-light' as const },
},
] as const
@@ -54,8 +54,8 @@ export function SectionsSettingsTab() {
return (
<div className="flex flex-col gap-6">
<SettingsCard
title="Разделы control plane"
description="Параметры tenant и операции — отдельно от настроек браузера"
title="Разделы плоскости управления"
description="Параметры арендатора и операции — отдельно от настроек браузера"
>
<ItemGroup className="gap-0">
{ADMIN_SECTIONS.map((section, index) => (
@@ -36,7 +36,7 @@ export function SessionSettingsTab() {
return (
<div className="flex flex-col gap-6">
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
<SettingsCard title="Текущая сессия" description="Проверка токена через API сессии">
<QueryState
data={sessionQ.data}
isLoading={sessionQ.isLoading && hasStoredToken}
@@ -65,7 +65,7 @@ export function SessionSettingsTab() {
</Badge>
</ItemTitle>
<ItemDescription className="leading-5">
Tenant:{' '}
Арендатор:{' '}
<code className="text-foreground font-mono text-xs break-all">
{session.tenant_id}
</code>
@@ -84,7 +84,7 @@ export function SessionSettingsTab() {
>
<SettingRow
title="Роль"
description="Определяет доступ к операциям control plane и CRUD."
description="Определяет доступ к операциям плоскости управления и изменению данных."
titleAddon={
sessionQ.data ? (
<Badge variant="info-light" size="sm">
@@ -101,7 +101,7 @@ export function SessionSettingsTab() {
>
{sessionQ.data ? (
<p className="text-muted-foreground text-sm break-words">
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
Ключ с ролью <strong>{ROLE_LABELS[sessionQ.data.role] ?? sessionQ.data.role}</strong> у арендатора{' '}
<code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
</p>
) : (
+1 -1
View File
@@ -4,7 +4,7 @@ import { apiKeyRoleRu } from '@/lib/ui-labels'
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
{ value: 'editor', label: `${apiKeyRoleRu('editor')}CRUD без применения` },
{ value: 'editor', label: `${apiKeyRoleRu('editor')}изменение данных без применения` },
{ value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
{ value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
]
@@ -38,7 +38,7 @@ describe('isReadyCheckOk', () => {
describe('readyCheckStatusLabel', () => {
it('labels memory and failures', () => {
expect(readyCheckStatusLabel('memory', true)).toBe('Memory')
expect(readyCheckStatusLabel('memory', true)).toBe('В памяти')
expect(readyCheckStatusLabel('ok', true)).toBe('В норме')
expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно')
})
@@ -54,7 +54,7 @@ export function readyCheckStatusLabel(value: ReadyCheckValue, ok: boolean): stri
}
if (typeof value === 'string') {
const n = value.trim().toLowerCase()
if (n === 'memory') return 'Memory'
if (n === 'memory') return 'В памяти'
if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме'
if (n) return value
}
+51 -4
View File
@@ -30,9 +30,28 @@ export function moduleTypeRu(type: string): string {
const JOB_KIND_RU: Record<string, string> = {
module_refresh: 'Обновление модуля',
tenant_refresh: 'Обновление тенанта',
peer_reconcile: 'Согласование пиров',
deploy_apply: 'Применение на спикеры',
apply: 'Применение конфигурации',
revision_rollback: 'Откат ревизии',
rollback: 'Откат ревизии',
bird_reload: 'Перезагрузка BIRD',
postgres_metrics_refresh: 'Метрики PostgreSQL',
postgres_slow_query_aggregate: 'Медленные запросы PostgreSQL',
postgres_table_bloat_estimate: 'Bloat таблиц PostgreSQL',
postgres_index_usage_analyze: 'Использование индексов PostgreSQL',
postgres_autovacuum_lag_detect: 'Отставание autovacuum',
postgres_vacuum: 'VACUUM PostgreSQL',
postgres_vacuum_analyze: 'VACUUM ANALYZE PostgreSQL',
postgres_analyze: 'ANALYZE PostgreSQL',
postgres_reindex: 'REINDEX PostgreSQL',
postgres_cleanup: 'Очистка PostgreSQL',
maintenance_policy_run: 'Политика обслуживания',
}
export function isRefreshJobKind(kind: string): boolean {
return kind === 'module_refresh' || kind === 'tenant_refresh'
}
export function jobKindRu(kind: string): string {
@@ -61,18 +80,46 @@ const JOB_STATUS_RU: Record<string, string> = {
paused: 'Приостановлен',
disabled: 'Выключен',
archived: 'В архиве',
block: 'block',
accept: 'accept',
block: 'Блокировать',
accept: 'Принимать',
}
export function jobStatusRu(status: string): string {
return JOB_STATUS_RU[status.toLowerCase()] ?? status
}
const BGP_SESSION_STATE_RU: Record<string, string> = {
Idle: 'Простой',
Connect: 'Соединение',
Active: 'Поиск',
OpenSent: 'Open отправлен',
OpenConfirm: 'Open подтверждён',
Established: 'Установлена',
}
export function bgpSessionStateRu(state: string | null | undefined): string {
if (!state) return '—'
if (state === 'Established') return 'Установлена'
return state
return BGP_SESSION_STATE_RU[state] ?? state
}
export function speakerRoleRu(role: string | null | undefined): string {
switch (role) {
case 'master':
case 'primary':
return 'Основной'
case 'replica':
return 'Реплика'
case 'secondary':
return 'Резервный'
case 'speaker':
return 'Спикер'
default:
return role?.trim() || '—'
}
}
export function cdnSourceKindRu(kind: string): string {
return kind === 'json' ? 'JSON' : 'Текст'
}
export function speakerOnlineLabel(agentOk: boolean | undefined): string {
+4 -4
View File
@@ -41,11 +41,11 @@ export function useCreateCommunityMutation() {
mutationFn: (body: BgpCommunityCreate) =>
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('Сообщество создано')
toast.success('Community создано')
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось создать сообщество'),
toast.error(e instanceof Error ? e.message : 'Не удалось создать community'),
})
}
@@ -55,11 +55,11 @@ export function useUpdateCommunityMutation() {
mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) =>
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => {
toast.success('Сообщество обновлено')
toast.success('Community обновлено')
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось обновить сообщество'),
toast.error(e instanceof Error ? e.message : 'Не удалось обновить community'),
})
}
+13
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { apiJSON, apiMutate } from '@/lib/api-client'
import { overviewKeys } from '@/queries/overview'
import type {
ModuleCreate,
ModulePatch,
ModuleRow,
ModulesResponse,
@@ -64,6 +65,18 @@ export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
})
}
export function useCreateModuleMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ModuleCreate) => apiMutate<ModuleRow>('/v1/modules', 'POST', body),
onSuccess: (data) => {
toast.success('Модуль создан')
invalidateModules(qc, data.id)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать модуль'),
})
}
export function useUpdateModuleMutation() {
const qc = useQueryClient()
return useMutation({
+9 -9
View File
@@ -52,7 +52,7 @@ function AccessComponent() {
icon: <KeyRound className="size-4" />,
footer: (
<Badge variant="primary-light" size="sm">
в tenant
в арендаторе
</Badge>
),
},
@@ -89,16 +89,16 @@ function AccessComponent() {
}
const sessionKindLabel =
session?.kind === 'jwt' ? 'Portal JWT' : session?.kind === 'apikey' ? 'API-ключ' : null
session?.kind === 'jwt' ? 'JWT портала' : session?.kind === 'apikey' ? 'API-ключ' : null
const sessionAccessLabel = (() => {
if (!session) return null
if (session.kind === 'jwt' || session.is_admin || (session.permissions?.length ?? 0) > 0) {
if (session.is_admin) return 'admin (portal)'
if (session.is_admin) return 'Администратор (портал)'
if (sessionCanManageApiKeys(session)) return 'bgp:access:admin'
return session.permissions?.length
? session.permissions.slice(0, 3).join(', ')
: 'без access:admin'
: 'без права access:admin'
}
return session.role || '—'
})()
@@ -107,7 +107,7 @@ function AccessComponent() {
<div className="flex min-w-0 flex-col gap-6">
<PageHeader
title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена."
description="API-ключи плоскости управления и текущая сессия."
actions={
canManageKeys ? (
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
@@ -121,7 +121,7 @@ function AccessComponent() {
{session ? (
<SettingsCard
title="Текущая сессия"
description="Tenant и права текущего Bearer (API-ключ или portal JWT)."
description="Арендатор и права текущей сессии (API-ключ или JWT портала)."
>
<ItemGroup className="gap-0">
<Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
@@ -141,7 +141,7 @@ function AccessComponent() {
</div>
<ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
<span className="break-all">
Tenant:{' '}
Арендатор:{' '}
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
</span>
<span className="break-words">Доступ: {sessionAccessLabel}</span>
@@ -188,8 +188,8 @@ function AccessComponent() {
) : session ? (
<SettingsCard title="API-ключи">
<p className="text-muted-foreground px-5 py-4 text-sm break-words">
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
Управление API-ключами доступно роли <strong>оператор</strong> (API-ключ) или JWT портала
с правом администратора / <code className="text-xs">bgp:access:admin</code>.
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
</p>
</SettingsCard>
+6 -6
View File
@@ -45,7 +45,7 @@ function DirectoriesComponent() {
const items: KpiStatItem[] = [
{
label: 'Сообщества BGP',
label: 'BGP community',
value: communities.length,
icon: <Tags className="size-4" />,
footer: (
@@ -70,7 +70,7 @@ function DirectoriesComponent() {
icon: <BookText className="size-4" />,
footer: (
<Badge variant="outline" size="sm">
все модули tenant
все модули арендатора
</Badge>
),
},
@@ -114,7 +114,7 @@ function DirectoriesComponent() {
<div className="flex flex-col gap-6">
<PageHeader
title="Справочники"
description="Сообщества BGP и DoH-профили для резолвинга доменов"
description="BGP community и DoH-профили для резолвинга доменов"
actions={
<Button
variant="outline"
@@ -136,13 +136,13 @@ function DirectoriesComponent() {
<BadgeTabs
defaultValue="communities"
items={[
{ value: 'communities', label: 'Сообщества BGP', count: communities.length },
{ value: 'communities', label: 'BGP community', count: communities.length },
{ value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
]}
>
<TabsContent value="communities" className="mt-0">
<FrameDataGrid
title="Сообщества BGP"
title="BGP community"
description="Теги для префиксов в фильтрах BIRD"
actions={addCommunityButton}
>
@@ -152,7 +152,7 @@ function DirectoriesComponent() {
isError={communitiesQ.isError}
error={communitiesQ.error}
empty={communities.length === 0}
emptyTitle="Нет сообществ"
emptyTitle="Нет community"
emptyAction={addCommunityButton}
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => communitiesQ.refetch()}
+50 -6
View File
@@ -1,4 +1,4 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Plus, RefreshCw } from 'lucide-react'
@@ -6,18 +6,45 @@ import { Button } from '@evobgp/ui/components/button'
import { FrameDataGrid } from '@/components/reui-kit'
import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
import { ModuleCreateDialog } from '@/components/modules/module-create-dialog'
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteModules } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth'
import {
directoriesCommunitiesQueryOptions,
directoriesDohQueryOptions,
} from '@/queries/directories'
import { modulesListQueryOptions } from '@/queries/modules'
function parseCreateFlag(value: unknown): boolean {
return value === true || value === '1' || value === 'true'
}
export const Route = createFileRoute('/_auth/modules/')({
component: ModulesListComponent,
validateSearch: (search: Record<string, unknown>): { create?: boolean } => {
if (parseCreateFlag(search.create)) return { create: true }
return {}
},
})
function ModulesListComponent() {
const { create } = Route.useSearch()
const navigate = Route.useNavigate()
const query = useQuery(modulesListQueryOptions())
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteModules(sessionQ.data)
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
const createOpen = canWrite && create === true
function setCreateOpen(open: boolean) {
void navigate({ search: open ? { create: true } : {}, replace: true })
}
return (
<div className="flex flex-col gap-6">
@@ -42,10 +69,12 @@ function ModulesListComponent() {
<FrameDataGrid
title="Все модули"
actions={
<Button size="sm" render={<Link to="/modules/new" />}>
<Plus />
Создать
</Button>
canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus />
Создать
</Button>
) : undefined
}
>
<QueryState
@@ -54,7 +83,12 @@ function ModulesListComponent() {
isError={query.isError}
error={query.error}
empty={query.data?.items?.length === 0}
emptyContent={<ProjectsEmptyState />}
emptyContent={
<ProjectsEmptyState
canCreate={canWrite}
onCreate={() => setCreateOpen(true)}
/>
}
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()}
>
@@ -66,6 +100,16 @@ function ModulesListComponent() {
)}
</QueryState>
</FrameDataGrid>
<ModuleCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
communities={communitiesQ.data?.items ?? []}
dohProfiles={dohQ.data?.items ?? []}
onCreated={(mod) => {
void navigate({ to: '/modules/$moduleId', params: { moduleId: mod.id } })
}}
/>
</div>
)
}
+4 -39
View File
@@ -1,42 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Button } from '@evobgp/ui/components/button'
import { PageHeader } from '@/components/page-header'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/modules/new')({
component: NewModuleComponent,
beforeLoad: () => {
throw redirect({ to: '/modules', search: { create: true } })
},
})
function NewModuleComponent() {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<PageHeader
title="Новый модуль"
description="Создание модуля — через API или будущая форма"
/>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Создание через API</FrameTitle>
<FrameDescription>
Форма в UI появится позже. Сейчас модуль можно создать запросом ниже.
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3 text-sm text-muted-foreground">
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{`POST /v1/modules
{ "type": "DOMAINS", "name": "Мой список" }`}
</pre>
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
Назад к списку
</Button>
</FramePanel>
</Frame>
</div>
)
}
+7 -8
View File
@@ -127,7 +127,7 @@ function MonitoringComponent() {
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
<FrameDataGrid
title="Доступность и готовность"
description="GET /v1/health · GET /v1/ready"
description="Проверки живучести и готовности (/v1/health, /v1/ready)"
>
<QueryState
data={readyQ.data}
@@ -148,7 +148,7 @@ function MonitoringComponent() {
BGP на API-хосте
</span>
}
description="GET /v1/bird/status"
description="Статус BIRD на хосте API"
className="h-full"
contentClassName="px-5 py-4"
>
@@ -169,7 +169,7 @@ function MonitoringComponent() {
<div className="flex flex-col gap-2 md:gap-3">
<SegmentedProgressCard
title="Задачи"
description="Последние 100 задач · GET /v1/jobs"
description="Последние 100 задач"
primary={{
value: jobs.filter((j) => j.status === 'running' || j.status === 'queued').length,
label: 'Активных',
@@ -230,11 +230,10 @@ function MonitoringComponent() {
</li>
<li>
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
и <code className="text-xs">jobs</code> в проверках.
<code className="text-xs">PostgreSQL</code>, затем хранилище и очередь задач в проверках.
</li>
<li>
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
<span className="font-medium text-foreground">Низкая доля установленных BGP-сессий.</span> Проверьте{' '}
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</li>
<li>
@@ -250,7 +249,7 @@ function MonitoringComponent() {
<IllustratedEmptyState
icon={Database}
title="PostgreSQL"
description="Статус соединения и пул отображаются в readiness-проверке на вкладке «Система» (check postgres)."
description="Состояние соединения и пула видно в проверке готовности на вкладке «Система»."
/>
</TabsContent>
@@ -258,7 +257,7 @@ function MonitoringComponent() {
<IllustratedEmptyState
icon={FileText}
title="Файловые логи"
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в Настройках BIRD."
description="Логи API и конвейера задаются переменной EVOBGP_LOG_* и управляются в Настройках BIRD."
/>
</TabsContent>
</BadgeTabs>
+32 -5
View File
@@ -763,7 +763,7 @@ export interface paths {
put?: never;
/**
* Зарегистрировать спикер
* @description Реплика, canary и т.д.
* @description Реплика или master. Для replica 201 содержит agent_secret, node_token и install.docker_commands (bird2 + agent + Traefik LE DNS-01) один раз.
*/
post: operations["createSpeaker"];
delete?: never;
@@ -2733,11 +2733,38 @@ export interface components {
role: string;
/** @description URL agent или https://AGENT_DOMAIN */
endpoint: string;
/** @description JSON-объект. Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4), agent_domain, agent_secret (генерируется при создании если пуст). */
meta_json?: string;
/** @description JSON-объект (строка или object). Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4), agent_domain, agent_secret (генерируется при создании если пуст). */
meta_json?: string | Record<string, never>;
/** @description Email ACME для Traefik на ноде. Только для генерации install.docker_commands, не сохраняется. */
letsencrypt_email?: string;
/** @description Cloudflare DNS API token (Zone:DNS:Edit) для LE DNS-01. Только для install-сниппета, не сохраняется. */
cf_dns_api_token?: string;
/** @description CIDR/IP панели для Traefik ipallowlist. Только для install-сниппета, не сохраняется. */
panel_ip_whitelist?: string;
/**
* Format: uri
* @description Публичный HTTPS URL панели (EVOBGP_CONTROL_PLANE_URL на реплике). Если пуст из Origin / X-Forwarded-Host.
*/
control_plane_url?: string;
} & {
[key: string]: unknown;
};
/** @description Одноразовый пакет установки реплики (только POST /v1/speakers 201). */
SpeakerInstall: {
/** @description Bash: sysctl, heredoc docker-compose.yaml (bird2 + agent + Traefik DNS-01) и docker compose up -d. */
docker_commands?: string;
/** @description Тело docker-compose.yaml без heredoc (превью). */
compose_yaml?: string;
};
BgpSpeakerCreated: components["schemas"]["BgpSpeaker"] & {
/** @description Bearer для Panel→Node (EVOBGP_AGENT_SECRET). Только в 201. */
agent_secret?: string;
/** @description API-ключ role=node (EVOBGP_NODE_TOKEN). Только в 201. */
node_token?: string;
/** @description Ed25519 pubkey для verify-bundle на ноде. */
bundle_pubkey_base64?: string;
install?: components["schemas"]["SpeakerInstall"];
};
BgpSpeakerPatch: {
role?: string;
endpoint?: string;
@@ -4576,13 +4603,13 @@ export interface operations {
};
};
responses: {
/** @description Ресурс создан. */
/** @description Ресурс создан. Для replica — одноразовый install-сниппет. */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BgpSpeaker"];
"application/json": components["schemas"]["BgpSpeakerCreated"];
};
};
default: components["responses"]["DefaultProblem"];
+12 -1
View File
@@ -293,13 +293,24 @@ export type SpeakerRow = {
last_dispatch_error?: string | null
meta_json?: Record<string, unknown>
agent_secret?: string
node_token?: string
bundle_pubkey_base64?: string
install?: SpeakerInstall
live?: SpeakerLiveStatus
}
export type SpeakerInstall = {
docker_commands?: string
compose_yaml?: string
}
export type SpeakersResponse = Page<SpeakerRow>
export type BgpSpeakerCreate = {
endpoint: string
role?: string
meta_json?: string
meta_json?: string | Record<string, string>
letsencrypt_email?: string
cf_dns_api_token?: string
panel_ip_whitelist?: string
control_plane_url?: string
}
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>
File diff suppressed because one or more lines are too long
+36 -23
View File
@@ -2,29 +2,38 @@
## CI (Gitea Actions)
Сборка образов — **`docker buildx bake`** (`deploy/docker/docker-bake.hcl`), не отдельные `docker build`.
Сборка образов — **`docker buildx bake`** ([docker-bake.hcl](docker-bake.hcl)), не отдельные `docker build`.
**Publish:** push в `main` после quality gates — job **release** в [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml): semantic-release + bake с `VERSION` из релиза.
**Publish:** push в `main` после quality gates — workflow [CD](../../.gitea/workflows/cd.yaml) job **publish**: semantic-release + зеркало base-образов + bake с `VERSION` из релиза.
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
Bake читает переменные из **окружения** (`REGISTRY`, `IMAGE_TAG`, `CACHE_REF_*`, `BASE_*`). Скрипт [write-bake-override.sh](write-bake-override.sh) — опциональный helper для локальной отладки.
| Было | Стало |
|------|--------|
| 8× `go mod download` + 8× `go build` (разные BIN) | 1× download + 1× компиляция всех `cmd/*` |
| 2× сборка BIRD из исходников (api, all) | 1× stage `birdc`, копируется в runtime |
| 2× `npm ci` (web, web-all) | 1× `web-deps` + 1× `web-build` + два nginx-образа (общий артефакт) |
| 8 runner'ов с checkout/login | 1 job `docker-go`, 1 job `docker-web` |
BuildKit кэширует `/go/pkg/mod`, `~/.cache/go-build` и pnpm store через `RUN --mount=type=cache`. На CI mounts живут, пока named builder `evobgp` не удаляют (`cleanup: false`).
Кэш registry (переменные bake):
### Слои и runtime
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache`**запись** только из target `go-build-all`
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache`**запись** только из target `web-build`
| Образ | Runtime base | Заметка |
|-------|----------------|---------|
| scheduler, ingest, render | `gcr.io/distroless/static-debian12:nonroot` | static Go (`CGO_ENABLED=0`), без shell |
| api, all, deploy, node | `debian:bookworm-slim` + `bird` + `birdc` | `bird -p` (parse-check) и `birdc`; демон не запускается |
| agent | тот же Ubuntu+bird2, что bird2 | общие слои с `evobgp-bird2` |
| bird2 | Ubuntu Noble + пакет bird2 | |
| web, web-all | `nginx:1.27-alpine` | `worker_processes 1` |
Остальные bake-target’ы только `cache-from` (чтение). Параллельный `cache-to` в один ref ломает manifest в registry (`content descriptor … not found`).
Сборка Go: `golang:1.24-alpine`. Context режется корневым [.dockerignore](../../.dockerignore).
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
### Кэш registry
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evobgp-buildcache:go-buildcache` и `:web-buildcache` в registry и пересоберите.
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache`**запись** только из `go-build-all`
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache`**запись** только из `web-build`
- `git.shx.one/<owner>/evobgp-buildcache:birdc-buildcache`**запись** только из `go-birdc`
- `git.shx.one/<owner>/evobgp-buildcache:base-*` — зеркало FROM с **Docker Hub** (`docker.io/library/…`; distroless — `gcr.io`). Только `linux/amd64`; skip если тег уже в Gitea. Неуспешный copy не валит CD — bake берёт Docker Hub FROM для этой базы.
`pull = false` в bake: не перекачивать FROM, если слой уже в builder. Не запускайте `docker system prune -a` на runner.
Параллельный `cache-to` в один ref ломает manifest (`content descriptor … not found`).
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evobgp-buildcache:*` в registry и пересоберите.
## Локальная сборка
@@ -37,20 +46,24 @@ export IMAGE_TAG=latest
export SHORT_SHA=$(git rev-parse --short HEAD)
export VERSION=dev
export BUILD_TIME=
sh write-bake-override.sh
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl go-images
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl web-images
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl go-images
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl web-images
```
В `docker-bake.hcl`: `context = "../.."` (корень репо), `dockerfile = "deploy/docker/…"` (путь от корня репо). **CI:** `write-bake-override.sh` + `--allow=fs.read=$GITHUB_WORKSPACE` в `.gitea/workflows/ci.yaml`.
Проверка манифеста без сборки:
Один образ (legacy):
```bash
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl --print default
```
В `docker-bake.hcl`: `context = "../.."` резолвится **от cwd** (каталог `deploy/docker/`). `dockerfile` — путь от этого context (корень репо). Из корня репо не вызывать bake с `-f deploy/docker/docker-bake.hcl`: получится `lstat ../../deploy`. Можно задать `BUILDX_BAKE_FILE_RELATIVE_PATHS=1`.
Один образ (legacy, target `build-all`):
```bash
docker build -f deploy/docker/gobinary/Dockerfile \
--target build-all \
--build-arg BIN=evobgp-api \
-t evobgp-api:local .
-t evobgp-build-all:local .
```
Runtime-образы в bake ожидают stage `build-all` (через bake contexts), не отдельный `--build-arg BIN` на старый target `build`.
Runtime-образы в bake ожидают stage `build-all` (через bake contexts).
+13 -6
View File
@@ -1,10 +1,17 @@
# syntax=docker/dockerfile:1.7
# BIRD из репозитория Ubuntu (Noble 24.04 LTS) — актуальнее пакета Debian bookworm.
# Сборка из корня репозитория: docker build -f deploy/docker/bird2/Dockerfile .
# Зеркало ECR Public вместо прямого pull с Docker Hub.
FROM public.ecr.aws/docker/library/ubuntu:noble
RUN apt-get update \
&& apt-get install -y --no-install-recommends bird2 \
&& rm -rf /var/lib/apt/lists/*
# Stage bird2-base общий с evobgp-agent (одинаковые слои на speaker-VPS).
ARG BASE_UBUNTU=docker.io/library/ubuntu:noble
FROM ${BASE_UBUNTU} AS bird2-base
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean \
&& apt-get update \
&& apt-get install -y --no-install-recommends bird2 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
FROM bird2-base AS bird2
COPY deploy/bird/bird.conf /etc/bird/bird.conf
RUN mkdir -p /etc/bird/bird.d
EXPOSE 179
+110 -27
View File
@@ -1,7 +1,7 @@
# Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl).
# context = "../.." корень репозитория (относительно этого файла).
# dockerfile путь от корня репозитория (относительно context).
# Переменные: REGISTRY, IMAGE_TAG, CACHE_REF_GO, CACHE_REF_WEB
# context = "../.." корень репо. Bake резолвит context от cwd (не от HCL),
# поэтому вызов из deploy/docker/ либо BUILDX_BAKE_FILE_RELATIVE_PATHS=1.
# Переменные Bake читаются из окружения (см. docs.docker.com/build/bake/variables/).
variable "REGISTRY" {
default = "git.shx.one/evobgp"
@@ -35,14 +35,40 @@ variable "CACHE_REF_WEB" {
default = ""
}
variable "CACHE_REF_BIRDC" {
default = ""
}
variable "BASE_GOLANG" {
default = "docker.io/library/golang:1.24-alpine"
}
variable "BASE_DEBIAN" {
default = "docker.io/library/debian:bookworm-slim"
}
variable "BASE_DISTROLESS" {
default = "gcr.io/distroless/static-debian12:nonroot"
}
variable "BASE_NODE" {
default = "docker.io/library/node:22-alpine"
}
variable "BASE_NGINX" {
default = "docker.io/library/nginx:1.27-alpine"
}
variable "BASE_UBUNTU" {
default = "docker.io/library/ubuntu:noble"
}
function "go-cache-from" {
params = []
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : []
}
# Экспорт кэша только go-build-all / web-build (один writer на ref).
# Несколько target с cache-to в один ref и mode=max дают гонку в registry
# (content descriptor not found при параллельном bake).
# Экспорт кэша один writer на ref (параллельный cache-to в один ref ломает manifest).
function "go-cache-to-export" {
params = []
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO},mode=max"] : []
@@ -58,6 +84,16 @@ function "web-cache-to-export" {
result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB},mode=max"] : []
}
function "birdc-cache-from" {
params = []
result = notequal("", CACHE_REF_BIRDC) ? ["type=registry,ref=${CACHE_REF_BIRDC}"] : []
}
function "birdc-cache-to-export" {
params = []
result = notequal("", CACHE_REF_BIRDC) ? ["type=registry,ref=${CACHE_REF_BIRDC},mode=max"] : []
}
group "default" {
targets = ["go-images", "web-images", "evobgp-bird2"]
}
@@ -75,26 +111,38 @@ group "go-images" {
]
}
# web-deps / web-build только зависимости (contexts), без tags; при --push в группе не указывать.
group "web-images" {
targets = ["evobgp-web", "evobgp-web-all"]
}
target "_common" {
platforms = ["linux/amd64"]
pull = false
}
target "_go-bases" {
args = {
BASE_GOLANG = BASE_GOLANG
BASE_DEBIAN = BASE_DEBIAN
BASE_DISTROLESS = BASE_DISTROLESS
}
}
# --- Go: go mod download все cmd/* birdc (один раз) runtime-образы ---
target "go-deps" {
inherits = ["_common", "_go-bases"]
context = "../.."
dockerfile = "deploy/docker/gobinary/Dockerfile"
target = "deps"
platforms = ["linux/amd64"]
cache-from = go-cache-from()
}
target "go-build-all" {
inherits = ["_common", "_go-bases"]
context = "../.."
dockerfile = "deploy/docker/gobinary/Dockerfile"
target = "build-all"
platforms = ["linux/amd64"]
args = {
VERSION = VERSION
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
@@ -108,18 +156,19 @@ target "go-build-all" {
}
target "go-birdc" {
inherits = ["_common", "_go-bases"]
context = "../.."
dockerfile = "deploy/docker/gobinary/Dockerfile"
target = "birdc"
platforms = ["linux/amd64"]
cache-from = go-cache-from()
cache-from = birdc-cache-from()
cache-to = birdc-cache-to-export()
}
target "_go-runtime" {
inherits = ["_common", "_go-bases"]
context = "../.."
dockerfile = "deploy/docker/gobinary/Dockerfile"
target = "runtime"
platforms = ["linux/amd64"]
contexts = {
build-all = "target:go-build-all"
}
@@ -127,15 +176,15 @@ target "_go-runtime" {
}
target "_go-runtime-birdc" {
inherits = ["_common", "_go-bases"]
context = "../.."
dockerfile = "deploy/docker/gobinary/Dockerfile"
target = "runtime-birdc"
platforms = ["linux/amd64"]
contexts = {
build-all = "target:go-build-all"
birdc = "target:go-birdc"
}
cache-from = go-cache-from()
cache-from = concat(go-cache-from(), birdc-cache-from())
}
function "image-tags" {
@@ -187,43 +236,62 @@ target "evobgp-render" {
}
target "evobgp-deploy" {
inherits = ["_go-runtime"]
inherits = ["_go-runtime-birdc"]
args = { BIN = "evobgp-deploy" }
tags = image-tags("evobgp-deploy")
}
target "evobgp-node" {
inherits = ["_go-runtime"]
inherits = ["_go-runtime-birdc"]
args = { BIN = "evobgp-node" }
tags = image-tags("evobgp-node")
}
target "evobgp-bird2-base" {
inherits = ["_common"]
context = "../.."
dockerfile = "deploy/docker/bird2/Dockerfile"
target = "bird2-base"
args = {
BASE_UBUNTU = BASE_UBUNTU
}
}
target "evobgp-agent" {
inherits = ["_common"]
context = "../.."
dockerfile = "deploy/docker/evobgp-agent/Dockerfile"
platforms = ["linux/amd64"]
contexts = {
build-all = "target:go-build-all"
build-all = "target:go-build-all"
bird2-base = "target:evobgp-bird2-base"
}
cache-from = go-cache-from()
tags = image-tags("evobgp-agent")
}
# --- Web: npm ci (кэш) build nginx ---
# --- Web ---
target "web-deps" {
inherits = ["_common"]
context = "../.."
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
target = "deps"
platforms = ["linux/amd64"]
args = {
BASE_NODE = BASE_NODE
BASE_NGINX = BASE_NGINX
}
cache-from = web-cache-from()
}
target "web-build" {
inherits = ["_common"]
context = "../.."
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
target = "build"
platforms = ["linux/amd64"]
args = {
BASE_NODE = BASE_NODE
BASE_NGINX = BASE_NGINX
}
contexts = {
deps = "target:web-deps"
}
@@ -232,34 +300,49 @@ target "web-build" {
}
target "evobgp-web" {
inherits = ["_common"]
context = "../.."
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
target = "web"
platforms = ["linux/amd64"]
contexts = {
web-artifacts = "target:web-build"
}
args = { EVOBGP_UPSTREAM = "evobgp-api" }
args = {
EVOBGP_UPSTREAM = "evobgp-api"
BASE_NODE = BASE_NODE
BASE_NGINX = BASE_NGINX
}
cache-from = web-cache-from()
tags = image-tags("evobgp-web")
}
target "evobgp-web-all" {
inherits = ["_common"]
context = "../.."
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
target = "web"
platforms = ["linux/amd64"]
contexts = {
web-artifacts = "target:web-build"
}
args = { EVOBGP_UPSTREAM = "evobgp-all" }
args = {
EVOBGP_UPSTREAM = "evobgp-all"
BASE_NODE = BASE_NODE
BASE_NGINX = BASE_NGINX
}
cache-from = web-cache-from()
tags = image-tags("evobgp-web-all")
}
target "evobgp-bird2" {
inherits = ["_common"]
context = "../.."
dockerfile = "deploy/docker/bird2/Dockerfile"
platforms = ["linux/amd64"]
tags = image-tags("evobgp-bird2")
target = "bird2"
args = {
BASE_UBUNTU = BASE_UBUNTU
}
contexts = {
bird2-base = "target:evobgp-bird2-base"
}
tags = image-tags("evobgp-bird2")
}
+2 -5
View File
@@ -1,9 +1,6 @@
# syntax=docker/dockerfile:1.7
# Агент: бинарь из общего build-all (docker-bake.hcl → contexts.build-all), bird2 из apt.
FROM public.ecr.aws/docker/library/ubuntu:noble
RUN apt-get update \
&& apt-get install -y --no-install-recommends bird2 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Агент: бинарь из bake context build-all, runtime = тот же bird2-base, что evobgp-bird2.
FROM bird2-base
ARG BIN=evobgp-agent
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp-agent
WORKDIR /etc/bird
+8 -5
View File
@@ -1,9 +1,10 @@
# syntax=docker/dockerfile:1.7
# React + Vite статическая панель EvoBGP + nginx.
# Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build).
# Сборка ведётся из корня репозитория (context = "../.." в docker-bake.hcl).
ARG BASE_NODE=docker.io/library/node:22-alpine
ARG BASE_NGINX=docker.io/library/nginx:1.27-alpine
FROM public.ecr.aws/docker/library/node:22-alpine AS deps
FROM ${BASE_NODE} AS deps
WORKDIR /repo
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
@@ -16,11 +17,13 @@ FROM deps AS build
COPY tsconfig.base.json ./
COPY apps/web/ ./apps/web/
COPY packages/ui/ ./packages/ui/
RUN pnpm --filter @evobgp/web run build
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm --filter @evobgp/web run build
FROM public.ecr.aws/docker/library/nginx:1.27-alpine AS web
FROM ${BASE_NGINX} AS web
ARG EVOBGP_UPSTREAM=evobgp-api
COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf
RUN sed -e "s/evobgp-api/${EVOBGP_UPSTREAM}/g" /tmp/nginx-default.conf > /etc/nginx/conf.d/default.conf \
&& rm -f /tmp/nginx-default.conf
&& rm -f /tmp/nginx-default.conf \
&& sed -i 's/^[[:space:]]*worker_processes[[:space:]]*auto;/worker_processes 1;/' /etc/nginx/nginx.conf
COPY --from=web-artifacts /repo/apps/web/dist /usr/share/nginx/html
+1
View File
@@ -4,6 +4,7 @@ server {
root /usr/share/nginx/html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 256;
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
+31 -21
View File
@@ -1,19 +1,23 @@
# syntax=docker/dockerfile:1.7
# Универсальная сборка бинарей cmd/* (ARG BIN) или всех сразу (target build-all).
# INSTALL_BIRDC=1 — birdc из stage birdc (собирается один раз, переиспользуется api/all).
# Воркеры (runtime): distroless static. api/all/deploy/node (runtime-birdc): debian-slim + bird + birdc.
# CI: docker buildx bake -f deploy/docker/docker-bake.hcl
FROM public.ecr.aws/docker/library/golang:1.24-bookworm AS deps
ARG BASE_GOLANG=docker.io/library/golang:1.24-alpine
ARG BASE_DEBIAN=docker.io/library/debian:bookworm-slim
ARG BASE_DISTROLESS=gcr.io/distroless/static-debian12:nonroot
FROM ${BASE_GOLANG} AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
go mod download
FROM deps AS build-all
COPY . .
ARG VERSION=dev
ARG GIT_SHA=unknown
ARG BUILD_TIME=
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
RUN --mount=type=bind,target=. \
--mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
set -eux; \
mkdir -p /out; \
@@ -29,12 +33,12 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
# Один бинарь (локальная сборка); в CI — build-all + runtime.
FROM deps AS build
COPY . .
ARG BIN=evobgp-api
ARG VERSION=dev
ARG GIT_SHA=unknown
ARG BUILD_TIME=
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
RUN --mount=type=bind,target=. \
--mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w \
@@ -43,29 +47,35 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
-o /out/evobgp "./cmd/${BIN}"
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS birdc
FROM ${BASE_DEBIAN} AS birdc
ARG BIRD_VERSION=2.14
COPY deploy/docker/bird/bird-from-source.sh /tmp/bird-from-source.sh
RUN chmod +x /tmp/bird-from-source.sh \
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean \
&& chmod +x /tmp/bird-from-source.sh \
&& BIRD_VERSION="${BIRD_VERSION}" /tmp/bird-from-source.sh \
&& rm -f /tmp/bird-from-source.sh
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS runtime-base
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
FROM runtime-base AS runtime
# scheduler / ingest / render — static Go, без shell.
FROM ${BASE_DISTROLESS} AS runtime
ARG BIN=evobgp-api
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
COPY scripts/firewall /opt/evobgp/scripts/firewall
ENV EVOBGP_FIREWALL_SCRIPTS=/opt/evobgp/scripts/firewall
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/evobgp"]
FROM runtime AS runtime-birdc
# birdc: динамическая линковка readline + ncurses (debian bookworm).
RUN apt-get update \
&& apt-get install -y --no-install-recommends libreadline8 libncurses6 \
# api / all / deploy / node — bird -p (parse-check) + birdc configure.
# Демон BIRD в этом контейнере не запускается; процесс bird — в образе evobgp-bird2.
FROM ${BASE_DEBIAN} AS runtime-birdc
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates libreadline8 libncurses6 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=birdc /usr/local/sbin/bird /usr/local/sbin/birdc /usr/local/sbin/
ARG BIN=evobgp-api
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
COPY --from=birdc /usr/local/sbin/bird /usr/local/sbin/bird
COPY --from=birdc /usr/local/sbin/birdc /usr/local/sbin/birdc
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/evobgp"]
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env sh
# Copy-by-digest зеркало базовых образов в Gitea Container Registry (без rebuild).
# Требует: docker login в REGISTRY host, docker buildx.
# REGISTRY=git.shx.one/<owner> (без суффикса /evobgp-buildcache)
#
# Источник library-образов — Docker Hub (docker.io), не public.ecr.aws (429 Too Many Requests).
# Если тег уже есть в Gitea — skip (без повторного pull).
# Копируем только linux/amd64 — bake platforms совпадает, multi-arch индекс не нужен.
# Шаг CD не должен падать: неуспешный copy оставляет bake на Docker Hub FROM для этой базы.
set -eu
REGISTRY="${REGISTRY:?REGISTRY required (git.shx.one/<owner>)}"
CACHE_REPO="${REGISTRY}/evobgp-buildcache"
ENV_FILE="${MIRROR_ENV_FILE:-}"
PLATFORM="${MIRROR_PLATFORM:-linux/amd64}"
RETRIES="${MIRROR_RETRIES:-4}"
tmp="$(mktemp)"
cleanup() { rm -f "$tmp"; }
trap cleanup EXIT
dest_exists() {
docker buildx imagetools inspect "$1" >/dev/null 2>&1
}
copy_retry() {
src="$1"
dest="$2"
n=0
while [ "$n" -lt "$RETRIES" ]; do
n=$((n + 1))
if docker buildx imagetools create --platform "$PLATFORM" --tag "$dest" "$src"; then
return 0
fi
delay=$((n * 25))
echo "mirror retry ${n}/${RETRIES}, sleep ${delay}s: ${dest}"
sleep "$delay"
done
return 1
}
mirror() {
src="$1"
tag="$2"
var="$3"
dest="${CACHE_REPO}:${tag}"
if dest_exists "$dest"; then
echo "skip (already in registry): ${dest}"
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
return 0
fi
echo "mirror ${src} -> ${dest} (${PLATFORM})"
if copy_retry "$src" "$dest"; then
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
return 0
fi
echo "warn: ${dest} not mirrored — bake uses Docker Hub FROM for ${var}"
return 0
}
mirror "docker.io/library/golang:1.24-alpine" "base-golang-1.24-alpine" BASE_GOLANG
sleep 8
mirror "docker.io/library/debian:bookworm-slim" "base-debian-bookworm-slim" BASE_DEBIAN
sleep 8
mirror "docker.io/library/node:22-alpine" "base-node-22-alpine" BASE_NODE
sleep 8
mirror "docker.io/library/nginx:1.27-alpine" "base-nginx-1.27-alpine" BASE_NGINX
sleep 8
mirror "docker.io/library/ubuntu:noble" "base-ubuntu-noble" BASE_UBUNTU
sleep 8
mirror "gcr.io/distroless/static-debian12:nonroot" "base-distroless-static-debian12-nonroot" BASE_DISTROLESS
echo "----- mirrored BASE_* -----"
cat "$tmp"
if [ -n "$ENV_FILE" ]; then
env_dir="$(dirname "$ENV_FILE")"
if [ -d "$env_dir" ]; then
cp "$tmp" "$ENV_FILE"
echo "wrote ${ENV_FILE}"
else
echo "warn: MIRROR_ENV_FILE dir missing (${env_dir}) — skip write"
fi
fi
+25 -25
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env sh
# Writes docker-bake.override.hcl for CI (buildx without --var support).
# Optional helper for local bake when env-based Bake variables are inconvenient.
# CI задаёт те же имена переменных в окружении и вызывает bake без этого файла.
set -eu
OUT="${1:-docker-bake.override.hcl}"
REGISTRY="${REGISTRY:?REGISTRY required}"
@@ -10,30 +11,29 @@ VERSION="${VERSION:-dev}"
BUILD_TIME="${BUILD_TIME:-}"
CACHE_REF_GO="${CACHE_REF_GO:-}"
CACHE_REF_WEB="${CACHE_REF_WEB:-}"
CACHE_REF_BIRDC="${CACHE_REF_BIRDC:-}"
BASE_GOLANG="${BASE_GOLANG:-}"
BASE_DEBIAN="${BASE_DEBIAN:-}"
BASE_DISTROLESS="${BASE_DISTROLESS:-}"
BASE_NODE="${BASE_NODE:-}"
BASE_NGINX="${BASE_NGINX:-}"
BASE_UBUNTU="${BASE_UBUNTU:-}"
cat >"$OUT" <<EOF
# Generated by deploy/docker/write-bake-override.sh — do not commit.
variable "REGISTRY" {
default = "${REGISTRY}"
}
variable "IMAGE_TAG" {
default = "${IMAGE_TAG}"
}
variable "SHORT_SHA" {
default = "${SHORT_SHA}"
}
variable "SHA_FULL" {
default = "${SHA_FULL}"
}
variable "VERSION" {
default = "${VERSION}"
}
variable "BUILD_TIME" {
default = "${BUILD_TIME}"
}
variable "CACHE_REF_GO" {
default = "${CACHE_REF_GO}"
}
variable "CACHE_REF_WEB" {
default = "${CACHE_REF_WEB}"
}
variable "REGISTRY" { default = "${REGISTRY}" }
variable "IMAGE_TAG" { default = "${IMAGE_TAG}" }
variable "SHORT_SHA" { default = "${SHORT_SHA}" }
variable "SHA_FULL" { default = "${SHA_FULL}" }
variable "VERSION" { default = "${VERSION}" }
variable "BUILD_TIME" { default = "${BUILD_TIME}" }
variable "CACHE_REF_GO" { default = "${CACHE_REF_GO}" }
variable "CACHE_REF_WEB" { default = "${CACHE_REF_WEB}" }
variable "CACHE_REF_BIRDC" { default = "${CACHE_REF_BIRDC}" }
EOF
# BASE_* — только если заданы (иначе bake берёт default из docker-bake.hcl).
if [ -n "$BASE_GOLANG" ]; then printf 'variable "BASE_GOLANG" { default = "%s" }\n' "$BASE_GOLANG" >>"$OUT"; fi
if [ -n "$BASE_DEBIAN" ]; then printf 'variable "BASE_DEBIAN" { default = "%s" }\n' "$BASE_DEBIAN" >>"$OUT"; fi
if [ -n "$BASE_DISTROLESS" ]; then printf 'variable "BASE_DISTROLESS" { default = "%s" }\n' "$BASE_DISTROLESS" >>"$OUT"; fi
if [ -n "$BASE_NODE" ]; then printf 'variable "BASE_NODE" { default = "%s" }\n' "$BASE_NODE" >>"$OUT"; fi
if [ -n "$BASE_NGINX" ]; then printf 'variable "BASE_NGINX" { default = "%s" }\n' "$BASE_NGINX" >>"$OUT"; fi
if [ -n "$BASE_UBUNTU" ]; then printf 'variable "BASE_UBUNTU" { default = "%s" }\n' "$BASE_UBUNTU" >>"$OUT"; fi
+52 -6
View File
@@ -1801,11 +1801,55 @@ components:
type: string
description: URL agent или https://AGENT_DOMAIN
meta_json:
oneOf:
- type: string
- type: object
description: >
JSON-объект (строка или object). Ключи node_ipv4, bird_bgp_source_ipv4
(default = node_ipv4), agent_domain, agent_secret (генерируется при создании если пуст).
letsencrypt_email:
type: string
description: Email ACME для Traefik на ноде. Только для генерации install.docker_commands, не сохраняется.
cf_dns_api_token:
type: string
description: Cloudflare DNS API token (Zone:DNS:Edit) для LE DNS-01. Только для install-сниппета, не сохраняется.
panel_ip_whitelist:
type: string
description: CIDR/IP панели для Traefik ipallowlist. Только для install-сниппета, не сохраняется.
control_plane_url:
type: string
format: uri
description: Публичный HTTPS URL панели (EVOBGP_CONTROL_PLANE_URL на реплике). Если пуст — из Origin / X-Forwarded-Host.
additionalProperties: true
SpeakerInstall:
type: object
description: Одноразовый пакет установки реплики (только POST /v1/speakers 201).
properties:
docker_commands:
type: string
description: >
JSON-объект. Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4),
agent_domain, agent_secret (генерируется при создании если пуст).
additionalProperties: true
Bash: sysctl, heredoc docker-compose.yaml (bird2 + agent + Traefik DNS-01) и docker compose up -d.
compose_yaml:
type: string
description: Тело docker-compose.yaml без heredoc (превью).
BgpSpeakerCreated:
allOf:
- $ref: "#/components/schemas/BgpSpeaker"
- type: object
properties:
agent_secret:
type: string
description: Bearer для Panel→Node (EVOBGP_AGENT_SECRET). Только в 201.
node_token:
type: string
description: API-ключ role=node (EVOBGP_NODE_TOKEN). Только в 201.
bundle_pubkey_base64:
type: string
description: Ed25519 pubkey для verify-bundle на ноде.
install:
$ref: "#/components/schemas/SpeakerInstall"
BgpSpeakerPatch:
type: object
@@ -3360,7 +3404,9 @@ paths:
post:
tags: [Speakers]
summary: Зарегистрировать спикер
description: Реплика, canary и т.д.
description: >
Реплика или master. Для replica 201 содержит agent_secret, node_token и
install.docker_commands (bird2 + agent + Traefik LE DNS-01) — один раз.
operationId: createSpeaker
parameters:
- $ref: "#/components/parameters/TenantId"
@@ -3373,11 +3419,11 @@ paths:
$ref: "#/components/schemas/BgpSpeakerCreate"
responses:
"201":
description: Ресурс создан.
description: Ресурс создан. Для replica — одноразовый install-сниппет.
content:
application/json:
schema:
$ref: "#/components/schemas/BgpSpeaker"
$ref: "#/components/schemas/BgpSpeakerCreated"
default:
$ref: "#/components/responses/DefaultProblem"
+2 -2
View File
@@ -11,7 +11,7 @@
## Готовые образы без сборки (Container Registry Gitea)
После успешного CI (push в `main` или `master`) образы публикуются в **Container Registry** вашего Gitea. В workflow зафиксирован хост реестра **`git.shx.one`**; имя владельца в пути образа — **в нижнем регистре**, как у `github.repository_owner` в CI (например, пользователь `Denozord` → префикс `denozord`).
После успешного **CD** (push в `main` или `master`, job **publish**) образы публикуются в **Container Registry** вашего Gitea. В workflow зафиксирован хост реестра **`git.shx.one`**; имя владельца в пути образа — **в нижнем регистре**, как у `github.repository_owner` в CI (например, пользователь `Denozord` → префикс `denozord`).
### Шаблон имени и теги
@@ -39,7 +39,7 @@ docker login git.shx.one
| Образ | Назначение | Страница пакета (пример) | Pull |
|--------|------------|--------------------------|------|
| `evobgp-api` | HTTP API (с `birdc` в образе) | [packages/…/evobgp-api](https://git.shx.one/denozord/-/packages/container/evobgp-api/latest) | `docker pull git.shx.one/denozord/evobgp-api:latest` |
| `evobgp-api` | HTTP API (с `bird`/`birdc` в образе для parse-check) | [packages/…/evobgp-api](https://git.shx.one/denozord/-/packages/container/evobgp-api/latest) | `docker pull git.shx.one/denozord/evobgp-api:latest` |
| `evobgp-all` | Монолит microVPS: API + in-process воркеры scheduler/ingest/render/deploy | [packages/…/evobgp-all](https://git.shx.one/denozord/-/packages/container/evobgp-all/latest) | `docker pull git.shx.one/denozord/evobgp-all:latest` |
| `evobgp-scheduler` | Планировщик (reference) | [packages/…/evobgp-scheduler](https://git.shx.one/denozord/-/packages/container/evobgp-scheduler/latest) | `docker pull git.shx.one/denozord/evobgp-scheduler:latest` |
| `evobgp-ingest` | Ingest CDN / ETag | [packages/…/evobgp-ingest](https://git.shx.one/denozord/-/packages/container/evobgp-ingest/latest) | `docker pull git.shx.one/denozord/evobgp-ingest:latest` |
+13 -12
View File
@@ -23,21 +23,22 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
## CI-пайплайн (один push в main)
## CI-пайплайн (push в main)
```text
push/merge в main
CI: openapi, web, go, bird2 (параллельно)
→ job release (в том же workflow, после quality gates):
workflow CD: quality (openapi, web, go, bird2)
→ job publish:
→ semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
→ Gitea Release + CHANGELOG.md как attachment
docker buildx bake с VERSION=X.Y.Z
зеркало base-образов в evobgp-buildcache:base-*
→ docker buildx bake с VERSION=X.Y.Z (pull=false, named builder evobgp)
→ образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA
```
Pull request: только quality gates + commitlint; релиз и образы **не** публикуются.
Pull request: workflow **CI** quality gates + commitlint; релиз и образы **не** публикуются.
Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release**).
Workflows: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml), [.gitea/workflows/cd.yaml](../.gitea/workflows/cd.yaml), reusable [.gitea/workflows/quality.yaml](../.gitea/workflows/quality.yaml).
Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий).
@@ -51,7 +52,7 @@ Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release
| releases | Gitea Release + notes |
| packages (Container Registry) | push образов |
Fallback: **`gitea.token`** (нужны права на releases и packages).
Fallback для **git tag**: `github.token`, если PAT недоступен. Push образов в Container Registry — **только `ACTIONS_PAT`** (у job token Gitea нет права packages).
## Источник правды для версии в runtime
@@ -75,15 +76,15 @@ Web UI показывает версию из API (footer sidebar, страни
Правило: **один scope** из таблицы в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) (`web`, `httpapi`, `api`, …).
На push в `main` job **release** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
На push в `main` job **publish** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `refactor(web): …`).
## Перезапуск упавшего job release
## Перезапуск упавшего job publish
semantic-release пишет `.release-version` только в `successCmd` при **новом** релизе. Если тег `vX.Y.Z` уже создан, а `docker buildx bake` упал, повторный run того же SHA делает semantic-release no-op (файла нет). Job **release** тогда берёт версию из git-тега на `HEAD` и публикует образы.
semantic-release пишет `.release-version` только в `successCmd` при **новом** релизе. Если тег `vX.Y.Z` уже создан, а `docker buildx bake` упал, повторный run того же SHA делает semantic-release no-op (файла нет). Job **publish** тогда берёт версию из git-тега на `HEAD` и публикует образы.
Перезапускать нужно **весь job release**, не отдельный шаг bake: checkout + semantic-release + detect + bake идут подряд.
Перезапускать нужно **весь job publish**, не отдельный шаг bake: checkout + semantic-release + detect + bake идут подряд.
## CHANGELOG
@@ -91,7 +92,7 @@ Release notes — в Gitea Release; файл `CHANGELOG.md` генерирует
## Проверка после релиза
1. Один run workflow **CI** на push в main: job **release** зелёный.
1. Один run workflow **CD** на push в main: job **publish** зелёный.
2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes.
3. Container Registry: `evobgp-api:vX.Y.Z`, `evobgp-api:X.Y.Z`, `evobgp-api:latest`.
4. `curl http://localhost:8080/version``"version":"X.Y.Z"`.
+51 -37
View File
@@ -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 на реплике |
+36 -2
View File
@@ -1129,11 +1129,25 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.Speaker
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
var req speakerCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
metaStr, err := metaJSONRawToString(req.MetaJSON)
if err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "meta_json must be a JSON object or string")
return
}
role := strings.TrimSpace(req.Role)
if role == "" {
role = "replica"
}
body := store.Speaker{
Role: role,
Endpoint: strings.TrimSpace(req.Endpoint),
MetaJSON: metaStr,
}
if err := normalizeSpeakerCreate(&body); err != nil {
writeStoreErr(w, err)
return
@@ -1148,6 +1162,26 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
resp["agent_secret"] = meta.AgentSecret
}
if strings.ToLower(strings.TrimSpace(x.Role)) != "master" {
createdKey, kerr := s.store.CreateAPIKey(a.TenantID, &store.APIKeyCreate{
Name: "speaker:" + x.ID,
Role: "node",
})
if kerr != nil {
_ = s.store.DeleteSpeaker(a.TenantID, x.ID)
writeStoreErr(w, kerr)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
s.recordCRUDAudit(r, a, "bgp.api_key.create", "Created API key "+createdKey.Name, createdKey.ID, map[string]any{"api_key_id": createdKey.ID, "role": createdKey.Role, "speaker_id": x.ID})
resp["node_token"] = createdKey.Token
s.attachReplicaInstall(resp, x, createdKey.Token, req, r)
}
writeJSON(w, http.StatusCreated, resp)
}
+88
View File
@@ -1,17 +1,32 @@
package httpapi
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"evobgp/internal/nodedispatch"
"evobgp/internal/speakerinstall"
"evobgp/internal/store"
)
var errSpeakerMetaJSONType = errors.New("meta_json must be a JSON object or string")
type speakerCreateRequest struct {
Role string `json:"role"`
Endpoint string `json:"endpoint"`
MetaJSON json.RawMessage `json:"meta_json"`
LetsEncryptEmail string `json:"letsencrypt_email"`
CFDNSAPIToken string `json:"cf_dns_api_token"`
PanelIPWhitelist string `json:"panel_ip_whitelist"`
ControlPlaneURL string `json:"control_plane_url"`
}
func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
if sp == nil {
return map[string]any{}
@@ -123,6 +138,79 @@ func normalizeSpeakerCreate(in *store.Speaker) error {
return nil
}
func metaJSONRawToString(raw json.RawMessage) (string, error) {
t := bytes.TrimSpace(raw)
if len(t) == 0 {
return "{}", nil
}
switch t[0] {
case '"':
var s string
if err := json.Unmarshal(t, &s); err != nil {
return "", err
}
s = strings.TrimSpace(s)
if s == "" {
return "{}", nil
}
return s, nil
case '{':
return string(t), nil
default:
return "", errSpeakerMetaJSONType
}
}
func publicControlPlaneURL(r *http.Request, override string) string {
if s := strings.TrimSpace(override); s != "" {
return strings.TrimRight(s, "/")
}
if origin := strings.TrimSpace(r.Header.Get("Origin")); strings.HasPrefix(origin, "http://") || strings.HasPrefix(origin, "https://") {
return strings.TrimRight(origin, "/")
}
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
if proto == "" {
proto = "https"
}
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
if i := strings.Index(host, ","); i >= 0 {
host = strings.TrimSpace(host[:i])
}
if host == "" {
host = strings.TrimSpace(r.Host)
}
if host == "" {
return ""
}
return proto + "://" + host
}
func (s *Server) attachReplicaInstall(resp map[string]any, sp *store.Speaker, nodeToken string, req speakerCreateRequest, r *http.Request) {
if s == nil || sp == nil || resp == nil {
return
}
meta := store.ParseSpeakerMeta(sp.MetaJSON)
built, err := speakerinstall.Build(speakerinstall.Params{
SpeakerID: sp.ID,
AgentSecret: meta.AgentSecret,
NodeToken: nodeToken,
BundlePubkey: s.BundlePublicKeyBase64(),
ControlPlaneURL: publicControlPlaneURL(r, req.ControlPlaneURL),
AgentDomain: meta.AgentDomain,
LetsEncryptEmail: req.LetsEncryptEmail,
CFDNSAPIToken: req.CFDNSAPIToken,
PanelIPWhitelist: req.PanelIPWhitelist,
})
if err != nil {
return
}
resp["bundle_pubkey_base64"] = s.BundlePublicKeyBase64()
resp["install"] = map[string]any{
"docker_commands": built.DockerCommands,
"compose_yaml": built.ComposeYAML,
}
}
func (s *Server) recordSpeakerDispatch(tenantID string, sp *store.Speaker, res nodedispatch.Result) {
if s == nil || s.store == nil || sp == nil {
return
+111
View File
@@ -33,12 +33,23 @@ func TestPostSpeaker_defaultsFromEndpointIP(t *testing.T) {
if out["agent_secret"] == nil || out["agent_secret"] == "" {
t.Fatal("expected agent_secret on create")
}
if out["node_token"] == nil || out["node_token"] == "" {
t.Fatal("expected node_token on replica create")
}
if out["node_ipv4"] != "203.0.113.55" {
t.Fatalf("node_ipv4: %#v", out["node_ipv4"])
}
if out["bird_bgp_source_ipv4"] != "203.0.113.55" {
t.Fatalf("bird_bgp_source_ipv4: %#v", out["bird_bgp_source_ipv4"])
}
install, _ := out["install"].(map[string]any)
if install == nil {
t.Fatal("expected install on replica create")
}
cmd, _ := install["docker_commands"].(string)
if !strings.Contains(cmd, "traefik") || !strings.Contains(cmd, "dnschallenge") {
t.Fatalf("docker_commands missing traefik dns challenge: %s", cmd[:min(200, len(cmd))])
}
}
func TestDeleteSpeaker(t *testing.T) {
@@ -84,3 +95,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":"ops@example.com",
"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"])
}
}
+10 -1
View File
@@ -10,6 +10,7 @@ import (
func TestParallelModuleRefresh_CoalescesDeployApply(t *testing.T) {
t.Setenv("EVOBGP_ASN_RESOLVE", "0")
t.Setenv("EVOBGP_BIRD_ACTIVE_DIR", "") // skip bird binary path in deploy_apply
t.Setenv("EVOBGP_JOB_MAX_CONCURRENT", "8")
m := store.NewMemory()
m.SeedDemo()
@@ -35,8 +36,15 @@ func TestParallelModuleRefresh_CoalescesDeployApply(t *testing.T) {
t.Fatal(err)
}
// Hold workers until both jobs are enqueued so inflightRefresh=2 before either
// finishModuleRefreshSuccess. Otherwise a fast ingest can finalize+deploy before
// the second Enqueue — sequential refreshes correctly produce two deploy_apply jobs.
start := make(chan struct{})
wk := &Worker{Store: m}
reg := NewRegistry(wk.Process)
reg := NewRegistry(func(j *Job) {
<-start
wk.Process(j)
})
wk.Registry = reg
mid1 := modIP
@@ -47,6 +55,7 @@ func TestParallelModuleRefresh_CoalescesDeployApply(t *testing.T) {
if _, _, err := reg.Enqueue(tenant, KindModuleRefresh, nil, &mid2, map[string]any{"module_id": mod2.ID}); err != nil {
t.Fatal(err)
}
close(start)
waitSucceededJobsByKindCount(t, reg, tenant, KindModuleRefresh, 2)
+254
View File
@@ -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 ""
}
+66
View File
@@ -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: "ops@example.com",
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)
}
}
}
+25 -1
View File
@@ -414,6 +414,9 @@ func (m *Memory) ListModules(tenantID string) []*Module {
}
return out[i].Name < out[j].Name
})
for i := range out {
out[i] = cloneModule(out[i])
}
return out
}
@@ -446,7 +449,7 @@ func (m *Memory) GetModule(tenantID, moduleID string) (*Module, error) {
if mod.TenantID != tenantID {
return nil, ErrTenantScope
}
return mod, nil
return cloneModule(mod), nil
}
func (m *Memory) GetRevision(tenantID, revisionID string) (*Revision, error) {
@@ -684,3 +687,24 @@ func (m *Memory) ListRevisions(tenantID, moduleID string, cursor string, limit i
}
return page, nextCursor, hasMore
}
func cloneStringPtr(s *string) *string {
if s == nil {
return nil
}
v := *s
return &v
}
func cloneModule(m *Module) *Module {
if m == nil {
return nil
}
cp := *m
cp.DefaultCommunityID = cloneStringPtr(m.DefaultCommunityID)
cp.DohProfileID = cloneStringPtr(m.DohProfileID)
cp.DohProfileIDs = append([]string(nil), m.DohProfileIDs...)
cp.LastRefreshedAt = cloneTime(m.LastRefreshedAt)
cp.DeletedAt = cloneTime(m.DeletedAt)
return &cp
}
+5 -3
View File
@@ -38,7 +38,7 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
}
NormalizeModuleDoh(mod)
m.modules[id] = mod
return mod, nil
return cloneModule(mod), nil
}
func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*Module, error) {
@@ -74,12 +74,14 @@ func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*M
mod.DefaultCommunityID = &v
}
}
ApplyModuleDohPatch(mod, patch)
if patch.DohProfileIDs != nil || patch.DohProfileID != nil || patch.DohResolverPolicy != nil {
ApplyModuleDohPatch(mod, patch)
}
if patch.LastRefreshedAt != nil {
t := patch.LastRefreshedAt.UTC()
mod.LastRefreshedAt = &t
}
return mod, nil
return cloneModule(mod), nil
}
func (m *Memory) SoftDeleteModule(tenantID, moduleID string) error {
+2
View File
@@ -13,10 +13,12 @@
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@markwylde/semantic-release-gitea": "^2.2.0",
"@redocly/cli": "1.34.5",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/exec": "^7.0.0",
"@semantic-release/release-notes-generator": "^14.0.3",
"conventional-commits-parser": "^6.4.0",
"semantic-release": "^25.0.2"
}
}
+1312
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env sh
# Абсолютные пути для actions/cache: на act_runner «~» часто не раскрывается → cache miss.
set -eu
: "${GITHUB_ENV:?GITHUB_ENV required (Gitea/GitHub Actions)}"
HOME_DIR="${HOME:-}"
if [ -z "$HOME_DIR" ]; then
HOME_DIR="$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6 || true)"
fi
HOME_DIR="${HOME_DIR:-/root}"
if command -v go >/dev/null 2>&1; then
GOMODCACHE="$(go env GOMODCACHE)"
GOCACHE="$(go env GOCACHE)"
GOPATH="$(go env GOPATH)"
else
GOMODCACHE="${HOME_DIR}/go/pkg/mod"
GOCACHE="${HOME_DIR}/.cache/go-build"
GOPATH="${HOME_DIR}/go"
fi
PNPM_STORE_DIR="${HOME_DIR}/.pnpm-store"
COREPACK_HOME="${HOME_DIR}/.cache/node/corepack"
GOBIN="${GOPATH}/bin"
GOLANGCI_LINT_CACHE="${HOME_DIR}/.cache/golangci-lint"
mkdir -p "$PNPM_STORE_DIR" "$COREPACK_HOME" "$GOMODCACHE" "$GOCACHE" "$GOBIN" "$GOLANGCI_LINT_CACHE"
{
echo "HOME_DIR=${HOME_DIR}"
echo "PNPM_STORE_DIR=${PNPM_STORE_DIR}"
echo "COREPACK_HOME=${COREPACK_HOME}"
echo "GOMODCACHE=${GOMODCACHE}"
echo "GOCACHE=${GOCACHE}"
echo "GOPATH=${GOPATH}"
echo "GOBIN=${GOBIN}"
echo "GOLANGCI_LINT_CACHE=${GOLANGCI_LINT_CACHE}"
} >> "$GITHUB_ENV"
echo "cache-env HOME_DIR=${HOME_DIR}"
echo "cache-env PNPM_STORE_DIR=${PNPM_STORE_DIR}"
echo "cache-env GOMODCACHE=${GOMODCACHE}"
echo "cache-env GOCACHE=${GOCACHE}"
echo "cache-env GOBIN=${GOBIN}"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env sh
# Бинарь golangci-lint в GOBIN (кэш actions/cache), без goinstall и без hashFiles.
set -eu
VER="${GOLANGCI_LINT_VERSION:-v1.64.8}"
: "${GOBIN:?GOBIN required — run scripts/ci/export-cache-env.sh after setup-go}"
mkdir -p "$GOBIN"
export PATH="${GOBIN}:${PATH}"
if [ -n "${GOLANGCI_LINT_CACHE:-}" ]; then
mkdir -p "$GOLANGCI_LINT_CACHE"
export GOLANGCI_LINT_CACHE
fi
if [ ! -x "${GOBIN}/golangci-lint" ]; then
echo "install golangci-lint ${VER} -> ${GOBIN}"
curl -sSfL "https://raw.githubusercontent.com/golangci/golangci-lint/${VER}/install.sh" \
| sh -s -- -b "$GOBIN" "$VER"
fi
golangci-lint version
golangci-lint run
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
# pnpm install из кэша Gitea (store + node_modules). Без повторного download при hit.
set -eu
: "${PNPM_STORE_DIR:?PNPM_STORE_DIR required — run scripts/ci/export-cache-env.sh first}"
export COREPACK_HOME="${COREPACK_HOME:-${HOME:-/root}/.cache/node/corepack}"
mkdir -p "$PNPM_STORE_DIR" "$COREPACK_HOME"
corepack enable
pnpm config set store-dir "$PNPM_STORE_DIR"
echo "pnpm store: $(pnpm store path)"
echo "PNPM_CACHE_HIT=${PNPM_CACHE_HIT:-}"
if [ "${PNPM_CACHE_HIT:-}" = "true" ]; then
if pnpm install --frozen-lockfile --offline; then
echo "pnpm install --offline (cache hit)"
exit 0
fi
echo "offline install failed — prefer-offline"
fi
pnpm install --frozen-lockfile --prefer-offline

Some files were not shown because too many files have changed in this diff Show More