Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
927e27640a | ||
|
|
5255cd2d30 | ||
|
|
3723ba7ed1 | ||
|
|
c5148ac4a0 | ||
|
|
e6e319a275 | ||
|
|
869b13cb57 | ||
|
|
e190785d4f | ||
|
|
3fd05ff833 | ||
|
|
e7f24f0be4 | ||
|
|
1bfe460e4b | ||
|
|
e55d2c5aba | ||
|
|
f63e9b5fd0 | ||
|
|
0148d4ca37 | ||
|
|
e0ecabb22f | ||
|
|
da301b1a94 | ||
|
|
653bc6cc91 | ||
|
|
e0d695f2a4 | ||
|
|
dfc33dcb99 |
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 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.
|
**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
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 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.
|
**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
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ alwaysApply: true
|
|||||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||||
|------------|-------------|------------------|-------|
|
|------------|-------------|------------------|-------|
|
||||||
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
| 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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ alwaysApply: true
|
|||||||
## Dependency Management
|
## Dependency Management
|
||||||
|
|
||||||
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
|
**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-копирование без явного решения в репозитории.
|
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
|
||||||
*Проверка:* review.
|
*Проверка:* review.
|
||||||
@@ -123,10 +123,10 @@ alwaysApply: true
|
|||||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||||
*Проверка:* CI job `bird2`.
|
*Проверка:* 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.
|
*Проверка:* 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`.
|
*Проверка:* CI job `openapi`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -229,7 +229,7 @@ alwaysApply: true
|
|||||||
```powershell
|
```powershell
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./... -race -count=1
|
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
|
# 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)
|
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ alwaysApply: false
|
|||||||
|
|
||||||
---
|
---
|
||||||
name: reui
|
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
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **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 for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 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
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -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/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 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/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 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.
|
**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
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,38 +1,43 @@
|
|||||||
# Gitea Actions
|
# Gitea Actions
|
||||||
|
|
||||||
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
| Workflow | Когда | Что |
|
||||||
|
|----------|--------|-----|
|
||||||
| Job | PR | push в main |
|
| [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 |
|
||||||
| changes, openapi, web, go, bird2 | quality gates | quality gates |
|
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, go, bird2, commitlint, docker-check |
|
||||||
| commitlint | да | — |
|
|
||||||
| **release** | — | semantic-release + docker push (один run) |
|
|
||||||
|
|
||||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||||
|
|
||||||
## CI (quality gates)
|
## 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).
|
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, образы не публикуются.
|
Если 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-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`.
|
**Удалённый спикер** (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).
|
См. [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 не будет.
|
||||||
|
|||||||
@@ -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
@@ -1,387 +1,23 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main, master]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, master]
|
branches: [main, master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ---------------------------------------------------------------------------
|
quality:
|
||||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
uses: ./.gitea/workflows/quality.yaml
|
||||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
with:
|
||||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
is_pull_request: true
|
||||||
# ---------------------------------------------------------------------------
|
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||||
changes:
|
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||||
runs-on: ubuntu-latest
|
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
outputs:
|
secrets:
|
||||||
openapi: ${{ steps.detect.outputs.openapi }}
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
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
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 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.
|
**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
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,6 @@ docker compose --profile reference up -d
|
|||||||
|
|
||||||
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
||||||
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
- 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`)
|
||||||
|
|
||||||
Лицензия и условия использования — по политике владельца репозитория.
|
Лицензия и условия использования — по политике владельца репозитория.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
} from '@evobgp/ui/components/chart'
|
} from '@evobgp/ui/components/chart'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
@@ -43,6 +45,7 @@ export function ChartDonutMetric({
|
|||||||
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
|
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
|
||||||
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
|
||||||
<Pie
|
<Pie
|
||||||
data={data}
|
data={data}
|
||||||
dataKey="count"
|
dataKey="count"
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ export function MonitoringHealthCard({
|
|||||||
return (
|
return (
|
||||||
<DonutBreakdownCard
|
<DonutBreakdownCard
|
||||||
title="Доступность системы"
|
title="Доступность системы"
|
||||||
description="GET /v1/health · GET /v1/ready"
|
description="Проверки живучести и готовности"
|
||||||
slices={slices}
|
slices={slices}
|
||||||
centerLabel="Проверки"
|
centerLabel="Проверки"
|
||||||
badge={healthOk ? 'API OK' : undefined}
|
badge={healthOk ? 'API в норме' : undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,15 +40,15 @@ export function NetworkOverviewAnalyticsCard({
|
|||||||
/>
|
/>
|
||||||
<SegmentedProgressCard
|
<SegmentedProgressCard
|
||||||
title="Спикеры"
|
title="Спикеры"
|
||||||
description="Доступность live-агентов"
|
description="Доступность агентов"
|
||||||
primary={{
|
primary={{
|
||||||
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
||||||
label: 'Online',
|
label: 'В сети',
|
||||||
percent: speakersPct,
|
percent: speakersPct,
|
||||||
}}
|
}}
|
||||||
secondary={{
|
secondary={{
|
||||||
value: net.speakersTotal - net.speakersOnline,
|
value: net.speakersTotal - net.speakersOnline,
|
||||||
label: 'Offline',
|
label: 'Не в сети',
|
||||||
percent: 100 - speakersPct,
|
percent: 100 - speakersPct,
|
||||||
}}
|
}}
|
||||||
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function BadgeTabs({
|
|||||||
<TabsList
|
<TabsList
|
||||||
variant="line"
|
variant="line"
|
||||||
className={cn(
|
className={cn(
|
||||||
'mb-3.5 w-full min-w-0 justify-start gap-4 overflow-x-auto',
|
'mb-3.5 w-full min-w-0 justify-start gap-4',
|
||||||
listClassName,
|
listClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function buildKpis({
|
|||||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{loading ? '…' : 'онлайн'}
|
{loading ? '…' : 'в сети'}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function buildMetrics({
|
|||||||
{
|
{
|
||||||
id: 'bgp',
|
id: 'bgp',
|
||||||
title: 'BGP готовность',
|
title: 'BGP готовность',
|
||||||
label: 'Established / включённые',
|
label: 'Установлено / включено',
|
||||||
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
|
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
|
||||||
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
|
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
|
||||||
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
|
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
|
||||||
@@ -62,13 +62,13 @@ function buildMetrics({
|
|||||||
{
|
{
|
||||||
id: 'speakers',
|
id: 'speakers',
|
||||||
title: 'Спикеры',
|
title: 'Спикеры',
|
||||||
label: 'Online / всего',
|
label: 'В сети / всего',
|
||||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||||
delta:
|
delta:
|
||||||
loading || network.speakersTotal === 0
|
loading || network.speakersTotal === 0
|
||||||
? '…'
|
? '…'
|
||||||
: network.speakersOnline === network.speakersTotal
|
: network.speakersOnline === network.speakersTotal
|
||||||
? 'все online'
|
? 'все в сети'
|
||||||
: 'частично',
|
: 'частично',
|
||||||
deltaVariant:
|
deltaVariant:
|
||||||
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export function DashboardModulesGrid({
|
|||||||
description="Поиск, сортировка и быстрый переход к настройке"
|
description="Поиск, сортировка и быстрый переход к настройке"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import { useMemo, useState } from 'react'
|
|||||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import { PanelCard } from '@/components/panel-card'
|
||||||
import { Separator } from '@evobgp/ui/components/separator'
|
|
||||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||||
import type { JobRow, ModuleRow } from '@/types/api'
|
import type { JobRow, ModuleRow } from '@/types/api'
|
||||||
|
|
||||||
type FlowMode = 'jobs' | 'modules'
|
type FlowMode = 'jobs' | 'modules'
|
||||||
|
|
||||||
/** chart-13 inspired donut breakdown (Card surface). */
|
/**
|
||||||
|
* Donut distribution — chart-27 DNA: Frame + one legend (inside ChartDonutMetric).
|
||||||
|
* @see https://reui.io/preview/base/chart-27
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
export function DashboardOperationsBreakdown({
|
export function DashboardOperationsBreakdown({
|
||||||
jobs,
|
jobs,
|
||||||
modules,
|
modules,
|
||||||
@@ -32,7 +35,7 @@ export function DashboardOperationsBreakdown({
|
|||||||
return (
|
return (
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Поток операций"
|
title="Поток операций"
|
||||||
description="Распределение задач и типов модулей"
|
description="Доли статусов задач или типов модулей"
|
||||||
actions={
|
actions={
|
||||||
<AnalyticsSegmentControl
|
<AnalyticsSegmentControl
|
||||||
value={mode}
|
value={mode}
|
||||||
@@ -43,45 +46,16 @@ export function DashboardOperationsBreakdown({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
className="h-full"
|
className="min-w-0"
|
||||||
|
contentClassName="px-5 py-4"
|
||||||
>
|
>
|
||||||
<div className="p-4">
|
{loading ? (
|
||||||
{loading ? (
|
<div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
|
||||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
Загрузка…
|
||||||
Загрузка…
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
||||||
<>
|
)}
|
||||||
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
|
||||||
{slices.length > 0 ? (
|
|
||||||
<ul className="mt-4 flex min-w-0 flex-col">
|
|
||||||
{slices.map((slice, index) => {
|
|
||||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
|
||||||
return (
|
|
||||||
<li key={slice.key}>
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
|
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
|
||||||
style={{ backgroundColor: slice.color }}
|
|
||||||
/>
|
|
||||||
<span className="truncate text-sm font-medium">{slice.label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
|
|
||||||
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
|
|
||||||
{pct}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'lookup',
|
id: 'lookup',
|
||||||
title: 'Проверка IP/домена',
|
title: 'Проверка IP/домена',
|
||||||
description: 'Проверка IP в списках и BGP-сообществах.',
|
description: 'Проверка IP в списках и BGP community.',
|
||||||
to: '/lookup',
|
to: '/lookup',
|
||||||
icon: <Search aria-hidden />,
|
icon: <Search aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
@@ -16,14 +16,15 @@ const ACTIONS: QuickActionItem[] = [
|
|||||||
id: 'new-module',
|
id: 'new-module',
|
||||||
title: 'Создать модуль',
|
title: 'Создать модуль',
|
||||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||||
to: '/modules/new',
|
to: '/modules',
|
||||||
|
search: { create: true },
|
||||||
icon: <Plus aria-hidden />,
|
icon: <Plus aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'communities',
|
id: 'communities',
|
||||||
title: 'BGP-сообщества',
|
title: 'BGP community',
|
||||||
description: 'Справочник BGP-сообществ для политик экспорта.',
|
description: 'Справочник BGP community для политик экспорта.',
|
||||||
to: '/directories',
|
to: '/directories',
|
||||||
icon: <Tags aria-hidden />,
|
icon: <Tags aria-hidden />,
|
||||||
iconClassName: 'text-info',
|
iconClassName: 'text-info',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridShell } from '@/components/data-grid-shell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||||
@@ -29,7 +29,6 @@ export function DashboardRecentJobsGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
title={jobKindRu(row.original.kind)}
|
title={jobKindRu(row.original.kind)}
|
||||||
accent="mono"
|
|
||||||
subtitle={
|
subtitle={
|
||||||
row.original.meta?.module_id
|
row.original.meta?.module_id
|
||||||
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
||||||
@@ -49,7 +48,7 @@ export function DashboardRecentJobsGrid({
|
|||||||
[nameById],
|
[nameById],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
const { table, filteredCount } = useClientDataGrid({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getSearchText: (row) => {
|
getSearchText: (row) => {
|
||||||
@@ -63,16 +62,13 @@ export function DashboardRecentJobsGrid({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<DataGridShell
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет задач"
|
emptyMessage="Нет задач"
|
||||||
showPagination={false}
|
showPagination={false}
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск задач…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridShell } from '@/components/data-grid-shell'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
@@ -42,7 +42,7 @@ export function DashboardRecentRevisionsGrid({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
const { table, filteredCount } = useClientDataGrid({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getSearchText: (row) => row.id,
|
getSearchText: (row) => row.id,
|
||||||
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<DataGridShell
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет ревизий"
|
emptyMessage="Нет ревизий"
|
||||||
showPagination={false}
|
showPagination={false}
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск ревизий…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function CommunityFormDialog({
|
|||||||
<FormDrawer
|
<FormDrawer
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title={editTarget ? 'Редактировать сообщество' : 'Новое сообщество BGP'}
|
title={editTarget ? 'Редактировать community' : 'Новое BGP community'}
|
||||||
description="Тег для префиксов в фильтрах BIRD"
|
description="Тег для префиксов в фильтрах BIRD"
|
||||||
className="sm:max-w-sm"
|
className="sm:max-w-sm"
|
||||||
footer={
|
footer={
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ export function DirectoriesCommunitiesGrid({
|
|||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет сообществ"
|
emptyMessage="Нет community"
|
||||||
searchValue={globalFilter}
|
searchValue={globalFilter}
|
||||||
onSearchChange={setGlobalFilter}
|
onSearchChange={setGlobalFilter}
|
||||||
searchPlaceholder="Поиск сообществ…"
|
searchPlaceholder="Поиск community…"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function DohProfileFormDialog({
|
|||||||
if (timeoutMs.trim() !== '') {
|
if (timeoutMs.trim() !== '') {
|
||||||
const ms = Number(timeoutMs)
|
const ms = Number(timeoutMs)
|
||||||
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
|
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
|
||||||
toast.error('Timeout должен быть целым числом > 0')
|
toast.error('Таймаут должен быть целым числом больше 0')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
timeout = ms
|
timeout = ms
|
||||||
@@ -136,7 +136,7 @@ export function DohProfileFormDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="doh-timeout">Timeout, мс (опционально)</Label>
|
<Label htmlFor="doh-timeout">Таймаут, мс (необязательно)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="doh-timeout"
|
id="doh-timeout"
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
BookText,
|
BookText,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ServerCog,
|
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
@@ -72,7 +71,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
to: '/dashboard',
|
to: '/dashboard',
|
||||||
label: 'Панель',
|
label: 'Панель',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
description: 'KPI, модули и активность',
|
description: 'Метрики, модули и активность',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -82,23 +81,22 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
||||||
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
||||||
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
{ 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: 'Операции',
|
label: 'Операции',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
||||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
||||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Система',
|
label: 'Система',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
|
||||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function AppsMenu() {
|
|||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||||
className="justify-center text-sm font-medium"
|
className="justify-center text-sm font-medium"
|
||||||
>
|
>
|
||||||
Настройки
|
Настройки
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function NavUser() {
|
|||||||
setApiToken(null)
|
setApiToken(null)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||||
window.location.assign('/settings?tab=connection&reason=token-required')
|
window.location.assign('/settings?tab=ui&reason=token-required')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,10 +190,10 @@ export function NavUser() {
|
|||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||||
>
|
>
|
||||||
<SettingsIcon aria-hidden />
|
<SettingsIcon aria-hidden />
|
||||||
Настройки UI
|
Настройки
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{authOn ? (
|
{authOn ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ export function SystemMonitorPopover() {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: 'api',
|
id: 'api',
|
||||||
label: 'API health',
|
label: 'Состояние API',
|
||||||
value: healthOk ? 'OK' : '—',
|
value: healthOk ? 'норма' : '—',
|
||||||
unit: '',
|
unit: '',
|
||||||
percent: healthOk ? 100 : 0,
|
percent: healthOk ? 100 : 0,
|
||||||
icon: <HeartPulse aria-hidden />,
|
icon: <HeartPulse aria-hidden />,
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function LookupAddStep({
|
|||||||
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
|
Создайте модуль типа {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>
|
</Button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
|
|||||||
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
|
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
|
||||||
|
|
||||||
const CDN_KIND_ITEMS = [
|
const CDN_KIND_ITEMS = [
|
||||||
{ value: 'plaintext', label: 'plaintext' },
|
{ value: 'plaintext', label: 'Текст' },
|
||||||
{ value: 'json', label: 'json' },
|
{ value: 'json', label: 'JSON' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
interface ModuleCdnSourceDialogProps {
|
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">
|
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
<Label htmlFor="mod-enabled">Включён</Label>
|
<Label htmlFor="mod-enabled">Включён</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Выключенный модуль не участвует в refresh и apply.
|
Выключенный модуль не участвует в обновлении и применении.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
|
|||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { formatDateTime } from '@/lib/modules/display'
|
import { formatDateTime } from '@/lib/modules/display'
|
||||||
import { communityLabel } from '@/lib/modules/helpers'
|
import { communityLabel } from '@/lib/modules/helpers'
|
||||||
|
import { cdnSourceKindRu } from '@/lib/ui-labels'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type {
|
import type {
|
||||||
AsEntry,
|
AsEntry,
|
||||||
@@ -145,7 +146,7 @@ export function ModuleEntriesGrid({
|
|||||||
accessorKey: 'source_kind',
|
accessorKey: 'source_kind',
|
||||||
header: 'Тип',
|
header: 'Тип',
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
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 {
|
function speakerLabel(s: SpeakerRow): string {
|
||||||
if (s.role === 'master') {
|
if (s.role === 'master') {
|
||||||
const host = s.agent_domain ?? s.endpoint
|
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)}…`
|
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 />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-48',
|
className: 'w-48',
|
||||||
placeholder: 'IP или Neighbor ID…',
|
placeholder: 'IP или ID соседа…',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
id: 'neighbor_id',
|
id: 'neighbor_id',
|
||||||
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Neighbor ID" />
|
<DataGridColumnHeader column={column} title="ID соседа" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
@@ -103,7 +103,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
accent="mono"
|
accent="mono"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Neighbor ID' },
|
meta: { headerTitle: 'ID соседа' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'remote_asn',
|
accessorKey: 'remote_asn',
|
||||||
@@ -212,7 +212,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
title="Одобрить пира"
|
title="Одобрить пира"
|
||||||
description={
|
description={
|
||||||
approveTarget
|
approveTarget
|
||||||
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
? `ID соседа ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className="sm:max-w-sm"
|
className="sm:max-w-sm"
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function NetworkKpi({
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{loading ? '…' : 'онлайн'}
|
{loading ? '…' : 'в сети'}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
to: '/network',
|
to: '/network',
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ const PEER_TABS = [
|
|||||||
|
|
||||||
const SESSION_STATE_OPTIONS = [
|
const SESSION_STATE_OPTIONS = [
|
||||||
{ value: 'Established', label: 'Установлена' },
|
{ value: 'Established', label: 'Установлена' },
|
||||||
{ value: 'Idle', label: 'Idle' },
|
{ value: 'Idle', label: 'Простой' },
|
||||||
{ value: 'Active', label: 'Active' },
|
{ value: 'Active', label: 'Поиск' },
|
||||||
{ value: 'Connect', label: 'Connect' },
|
{ value: 'Connect', label: 'Соединение' },
|
||||||
{ value: 'OpenSent', label: 'OpenSent' },
|
{ value: 'OpenSent', label: 'Open отправлен' },
|
||||||
{ value: 'OpenConfirm', label: 'OpenConfirm' },
|
{ value: 'OpenConfirm', label: 'Open подтверждён' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDefaultPeerFilters(): Filter[] {
|
function createDefaultPeerFilters(): Filter[] {
|
||||||
|
|||||||
@@ -31,14 +31,13 @@ function speakerDeleteLabel(s: SpeakerRow): string {
|
|||||||
|
|
||||||
const SPEAKER_TABS = [
|
const SPEAKER_TABS = [
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
{ id: 'online', label: 'Online' },
|
{ id: 'online', label: 'В сети' },
|
||||||
{ id: 'offline', label: 'Offline' },
|
{ id: 'offline', label: 'Не в сети' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
{ value: 'primary', label: 'primary' },
|
{ value: 'replica', label: 'Реплика' },
|
||||||
{ value: 'secondary', label: 'secondary' },
|
{ value: 'master', label: 'Мастер' },
|
||||||
{ value: 'speaker', label: 'speaker' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDefaultSpeakerFilters(): Filter[] {
|
function createDefaultSpeakerFilters(): Filter[] {
|
||||||
@@ -52,7 +51,7 @@ const speakerFilterFields: FilterFieldConfig[] = [
|
|||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-52',
|
className: 'w-52',
|
||||||
placeholder: 'endpoint…',
|
placeholder: 'Адрес агента…',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'role',
|
key: 'role',
|
||||||
@@ -128,7 +127,7 @@ export function NetworkSpeakersCard({
|
|||||||
<>
|
<>
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Спикеры"
|
title="Спикеры"
|
||||||
description="BIRD-агенты на нодах tenant"
|
description="BIRD-агенты на нодах арендатора"
|
||||||
tabs={SPEAKER_TABS}
|
tabs={SPEAKER_TABS}
|
||||||
tabFilter={speakerTabFilter}
|
tabFilter={speakerTabFilter}
|
||||||
filterFields={speakerFilterFields}
|
filterFields={speakerFilterFields}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { speakerOnlineLabel } from '@/lib/ui-labels'
|
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
|
||||||
import type { SpeakerRow } from '@/types/api'
|
import type { SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||||
@@ -20,7 +20,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'role',
|
accessorKey: 'role',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
cell: ({ row }) => <CategoryBadge>{speakerRoleRu(row.original.role)}</CategoryBadge>,
|
||||||
meta: { headerTitle: 'Роль' },
|
meta: { headerTitle: 'Роль' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface PeerFormDialogProps {
|
|||||||
function speakerLabel(s: SpeakerRow): string {
|
function speakerLabel(s: SpeakerRow): string {
|
||||||
if (s.role === 'master') {
|
if (s.role === 'master') {
|
||||||
const host = s.agent_domain ?? s.endpoint
|
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)}…`
|
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Copy, TriangleAlert } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
|
import { Textarea } from '@evobgp/ui/components/textarea'
|
||||||
|
|
||||||
import { FormDrawer } from '@/components/form-drawer'
|
import { FormDrawer } from '@/components/form-drawer'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { SelectField } from '@/components/select-field'
|
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 { useCreateSpeakerMutation } from '@/queries/network'
|
||||||
import type { BgpSpeakerCreate } from '@/types/api'
|
import type { BgpSpeakerCreate, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
interface SpeakerFormDialogProps {
|
interface SpeakerFormDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -35,8 +39,20 @@ function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string)
|
|||||||
return JSON.stringify(meta)
|
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) {
|
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
|
||||||
const createMutation = useCreateSpeakerMutation()
|
const createMutation = useCreateSpeakerMutation()
|
||||||
|
const { isCopied, copyToClipboard } = useCopyToClipboard({
|
||||||
|
onCopy: () => toast.success('Команда скопирована'),
|
||||||
|
})
|
||||||
|
|
||||||
const [endpoint, setEndpoint] = useState('')
|
const [endpoint, setEndpoint] = useState('')
|
||||||
const [role, setRole] = useState('replica')
|
const [role, setRole] = useState('replica')
|
||||||
@@ -44,6 +60,13 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
const [nodeIpv4, setNodeIpv4] = useState('')
|
const [nodeIpv4, setNodeIpv4] = useState('')
|
||||||
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
|
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
|
||||||
const [bgpSourceManual, setBgpSourceManual] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -53,6 +76,10 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
setNodeIpv4('')
|
setNodeIpv4('')
|
||||||
setBgpSourceIpv4('')
|
setBgpSourceIpv4('')
|
||||||
setBgpSourceManual(false)
|
setBgpSourceManual(false)
|
||||||
|
setLetsencryptEmail('')
|
||||||
|
setCfDnsToken('')
|
||||||
|
setPanelIP('')
|
||||||
|
setCreated(null)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
function handleEndpointChange(value: string) {
|
function handleEndpointChange(value: string) {
|
||||||
@@ -74,89 +101,218 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
const ep =
|
const ep =
|
||||||
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
|
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
|
||||||
if (!ep) {
|
if (!ep) {
|
||||||
toast.error('Укажите endpoint или agent domain')
|
toast.error('Укажите конечную точку или домен агента')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const body: BgpSpeakerCreate = {
|
const body: BgpSpeakerCreate = {
|
||||||
endpoint: ep,
|
endpoint: ep,
|
||||||
role: role.trim() || 'replica',
|
role: role.trim() || 'replica',
|
||||||
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
|
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 {
|
try {
|
||||||
await createMutation.mutateAsync(body)
|
const row = await createMutation.mutateAsync(body)
|
||||||
|
if (row.install?.docker_commands) {
|
||||||
|
setCreated(row)
|
||||||
|
return
|
||||||
|
}
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} catch {
|
} catch {
|
||||||
// toast in mutation
|
// toast in mutation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showingInstall = created !== null && Boolean(installCommands)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormDrawer
|
<FormDrawer
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title="Новый спикер"
|
title={showingInstall ? 'Установка на ноду' : 'Новый спикер'}
|
||||||
description="BIRD-агент на ноде реплики или control plane"
|
description={
|
||||||
className="sm:max-w-md"
|
showingInstall
|
||||||
|
? 'Секреты показываются один раз. Скопируйте команду на VPS реплики.'
|
||||||
|
: 'BIRD-агент на ноде реплики или плоскости управления'
|
||||||
|
}
|
||||||
|
className={showingInstall ? 'sm:max-w-2xl' : 'sm:max-w-md'}
|
||||||
footer={
|
footer={
|
||||||
<>
|
showingInstall ? (
|
||||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
<>
|
||||||
Отмена
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
type="button"
|
||||||
Создать
|
onClick={() => copyToClipboard(installCommands)}
|
||||||
</LoadingButton>
|
>
|
||||||
</>
|
<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">
|
{created && installCommands ? (
|
||||||
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
<SpeakerInstallStep created={created} commands={installCommands} />
|
||||||
<Input
|
) : (
|
||||||
id="speaker-endpoint"
|
<>
|
||||||
placeholder="https://node.example.com:8443"
|
<div className="flex flex-col gap-2">
|
||||||
value={endpoint}
|
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
||||||
onChange={(e) => handleEndpointChange(e.target.value)}
|
<Input
|
||||||
/>
|
id="speaker-endpoint"
|
||||||
</div>
|
placeholder="https://node.example.com"
|
||||||
<SelectField
|
value={endpoint}
|
||||||
id="speaker-role"
|
onChange={(e) => handleEndpointChange(e.target.value)}
|
||||||
label="Роль"
|
/>
|
||||||
items={[
|
</div>
|
||||||
{ value: 'replica', label: 'Реплика' },
|
<SelectField
|
||||||
{ value: 'master', label: 'Мастер (CP)' },
|
id="speaker-role"
|
||||||
]}
|
label="Роль"
|
||||||
value={role}
|
items={[
|
||||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
{ value: 'replica', label: 'Реплика' },
|
||||||
/>
|
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||||
<div className="flex flex-col gap-2">
|
]}
|
||||||
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
value={role}
|
||||||
<Input
|
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||||
id="speaker-agent-domain"
|
/>
|
||||||
placeholder="bird-agent.example.com"
|
<div className="flex flex-col gap-2">
|
||||||
value={agentDomain}
|
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
||||||
onChange={(e) => setAgentDomain(e.target.value)}
|
<Input
|
||||||
/>
|
id="speaker-agent-domain"
|
||||||
</div>
|
placeholder="bird-agent.example.com"
|
||||||
<div className="flex flex-col gap-2">
|
value={agentDomain}
|
||||||
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
onChange={(e) => setAgentDomain(e.target.value)}
|
||||||
<Input
|
/>
|
||||||
id="speaker-node-ipv4"
|
</div>
|
||||||
placeholder="203.0.113.10"
|
<div className="flex flex-col gap-2">
|
||||||
value={nodeIpv4}
|
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
||||||
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
<Input
|
||||||
/>
|
id="speaker-node-ipv4"
|
||||||
</div>
|
placeholder="203.0.113.10"
|
||||||
<div className="flex flex-col gap-2">
|
value={nodeIpv4}
|
||||||
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
|
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
||||||
<Input
|
/>
|
||||||
id="speaker-bgp-source"
|
</div>
|
||||||
placeholder="203.0.113.10"
|
<div className="flex flex-col gap-2">
|
||||||
value={bgpSourceIpv4}
|
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
||||||
onChange={(e) => {
|
<Input
|
||||||
setBgpSourceManual(true)
|
id="speaker-bgp-source"
|
||||||
setBgpSourceIpv4(e.target.value)
|
placeholder="203.0.113.10"
|
||||||
}}
|
value={bgpSourceIpv4}
|
||||||
/>
|
onChange={(e) => {
|
||||||
</div>
|
setBgpSourceManual(true)
|
||||||
|
setBgpSourceIpv4(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isReplica ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="speaker-le-email">Email Let's Encrypt</Label>
|
||||||
|
<Input
|
||||||
|
id="speaker-le-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
value={letsencryptEmail}
|
||||||
|
onChange={(e) => setLetsencryptEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="speaker-cf-token">Cloudflare DNS API token</Label>
|
||||||
|
<Input
|
||||||
|
id="speaker-cf-token"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Zone:DNS:Edit"
|
||||||
|
value={cfDnsToken}
|
||||||
|
onChange={(e) => setCfDnsToken(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="speaker-panel-ip">IP панели (whitelist)</Label>
|
||||||
|
<Input
|
||||||
|
id="speaker-panel-ip"
|
||||||
|
placeholder="203.0.113.1/32"
|
||||||
|
value={panelIP}
|
||||||
|
onChange={(e) => setPanelIP(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{tlsIncomplete(agentDomain, letsencryptEmail, cfDnsToken, panelIP) ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<TriangleAlert />
|
||||||
|
<AlertTitle>Traefik не выпустит сертификат</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Нужны домен агента, email LE, Cloudflare token и IP панели. Иначе в
|
||||||
|
команде останутся плейсхолдеры CHANGE_ME_*.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FormDrawer>
|
</FormDrawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SpeakerInstallStep({
|
||||||
|
created,
|
||||||
|
commands,
|
||||||
|
}: {
|
||||||
|
created: SpeakerRow
|
||||||
|
commands: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Alert variant="warning">
|
||||||
|
<TriangleAlert />
|
||||||
|
<AlertTitle>Сохраните сейчас</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
agent_secret и node_token больше не будут показаны. Traefik на ноде выпускает
|
||||||
|
сертификат через DNS-01 (Cloudflare). MikroTik стучится на IP ноды:179; 80/443 —
|
||||||
|
только агент панели. Логи: docker compose logs -f bird2 evobgp-agent.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label>ID спикера</Label>
|
||||||
|
<code className="break-all font-mono text-xs">{created.id}</code>
|
||||||
|
</div>
|
||||||
|
{created.agent_secret ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label>agent_secret</Label>
|
||||||
|
<code className="break-all font-mono text-xs">{created.agent_secret}</code>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{created.node_token ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label>node_token</Label>
|
||||||
|
<code className="break-all font-mono text-xs">{created.node_token}</code>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="speaker-docker-commands">Docker-команды</Label>
|
||||||
|
<Textarea
|
||||||
|
id="speaker-docker-commands"
|
||||||
|
readOnly
|
||||||
|
value={commands}
|
||||||
|
className="min-h-64 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export function OperationsJobsGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
title={jobKindRu(row.original.kind)}
|
title={jobKindRu(row.original.kind)}
|
||||||
accent="mono"
|
|
||||||
subtitle={
|
subtitle={
|
||||||
row.original.meta?.module_id
|
row.original.meta?.module_id
|
||||||
? (nameById.get(String(row.original.meta.module_id)) ??
|
? (nameById.get(String(row.original.meta.module_id)) ??
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function OperationsRevisionsGrid({
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
||||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
|
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
||||||
confirmLabel="Откатить"
|
confirmLabel="Откатить"
|
||||||
destructive
|
destructive
|
||||||
onConfirm={() => rollbackMutation.mutate(row.original.id)}
|
onConfirm={() => rollbackMutation.mutate(row.original.id)}
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import { Boxes, Plus } from 'lucide-react'
|
import { Boxes, Plus } from 'lucide-react'
|
||||||
|
|
||||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
/** empty-state-3 pattern for first module. */
|
/** empty-state-3 pattern for first module. */
|
||||||
export function ProjectsEmptyState() {
|
export function ProjectsEmptyState({
|
||||||
|
canCreate = true,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
canCreate?: boolean
|
||||||
|
onCreate?: () => void
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<IllustratedEmptyState
|
<IllustratedEmptyState
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
title="Создайте первый модуль"
|
title="Создайте первый модуль"
|
||||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||||
action={
|
action={
|
||||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
canCreate && onCreate ? (
|
||||||
<Plus />
|
<Button size="sm" onClick={onCreate}>
|
||||||
Новый модуль
|
<Plus />
|
||||||
</Button>
|
Новый модуль
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
|||||||
export { OpsDashboard } from './ops-dashboard'
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
export { FrameDataGrid } from './frame-data-grid'
|
export { FrameDataGrid } from './frame-data-grid'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
export { SettingsShell } from './settings-shell'
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
|
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
|
||||||
@@ -47,9 +40,11 @@ function OpsDashboardSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ops dashboard: KPI → optional Quick Actions → charts → attention queue.
|
* Ops dashboard: KPI → optional Quick Actions → charts → queue.
|
||||||
|
* Queue is a sibling Frame grid — never wrap FrameDataGrid inside another Frame.
|
||||||
* @see https://reui.io/preview/base/dashboard-1
|
* @see https://reui.io/preview/base/dashboard-1
|
||||||
* @see https://reui.io/preview/base/stats-12
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
*/
|
*/
|
||||||
export function OpsDashboard({
|
export function OpsDashboard({
|
||||||
title = 'Панель управления',
|
title = 'Панель управления',
|
||||||
@@ -77,21 +72,18 @@ export function OpsDashboard({
|
|||||||
|
|
||||||
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
|
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
|
||||||
|
|
||||||
<section
|
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
|
||||||
aria-label="Аналитика"
|
|
||||||
className="flex min-w-0 flex-col gap-4"
|
|
||||||
>
|
|
||||||
{charts}
|
{charts}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label={queueTitle}>
|
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||||
<Frame dense spacing="sm" className="w-full min-w-0">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<FrameHeader>
|
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||||
<FrameTitle>{queueTitle}</FrameTitle>
|
{queueDescription ? (
|
||||||
<FrameDescription>{queueDescription}</FrameDescription>
|
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
|
||||||
</FrameHeader>
|
) : null}
|
||||||
<FramePanel>{queue}</FramePanel>
|
</div>
|
||||||
</Frame>
|
{queue}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,113 +1,17 @@
|
|||||||
import type { ReactNode } from 'react'
|
import { Outlet } from '@tanstack/react-router'
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
|
||||||
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
|
|
||||||
export interface SettingsTabConfig {
|
|
||||||
id: string
|
|
||||||
to: '/settings' | '/tenant-settings'
|
|
||||||
label: string
|
|
||||||
icon?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default nav for EvoBGP settings routes (`/settings`, `/tenant-settings`). */
|
|
||||||
const DEFAULT_TABS: SettingsTabConfig[] = [
|
|
||||||
{
|
|
||||||
id: 'ui',
|
|
||||||
to: '/settings',
|
|
||||||
label: 'Настройки UI',
|
|
||||||
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tenant',
|
|
||||||
to: '/tenant-settings',
|
|
||||||
label: 'Настройки BIRD',
|
|
||||||
icon: <ServerCogIcon className="size-4" aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
interface SettingsShellProps {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
tabs?: SettingsTabConfig[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings shell — Frame surface, settings-16 left rail.
|
* Settings layout — page chrome only.
|
||||||
* Rail stacks above content until the page container is wide enough (no viewport-only squeeze).
|
* Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
|
||||||
* @see https://reui.io/preview/base/settings-16
|
* @see https://reui.io/preview/base/settings-7
|
||||||
* @see https://reui.io/preview/base/settings-3
|
* @see https://reui.io/blocks
|
||||||
*/
|
*/
|
||||||
export function SettingsShell({
|
export function SettingsShell() {
|
||||||
title = 'Настройки',
|
|
||||||
description,
|
|
||||||
tabs = DEFAULT_TABS,
|
|
||||||
}: SettingsShellProps) {
|
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
||||||
const headerDescription =
|
|
||||||
description ??
|
|
||||||
(pathname.startsWith('/tenant-settings')
|
|
||||||
? 'Глобальные параметры BIRD и плоскости управления'
|
|
||||||
: 'Подключение UI и параметры плоскости управления')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<div className="@container mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-5">
|
<Outlet />
|
||||||
<PageHeader title={title} description={headerDescription} />
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col gap-5 @3xl:flex-row @3xl:items-start">
|
|
||||||
{tabs.length > 1 ? (
|
|
||||||
<nav
|
|
||||||
aria-label="Разделы настроек"
|
|
||||||
className="scrollbar-none flex min-w-0 gap-1 overflow-x-auto @3xl:w-44 @3xl:shrink-0 @3xl:flex-col @3xl:overflow-visible"
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const isActive = pathname.startsWith(tab.to)
|
|
||||||
const className = cn(
|
|
||||||
'flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
|
||||||
'@3xl:w-full',
|
|
||||||
isActive
|
|
||||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
|
||||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
|
||||||
)
|
|
||||||
if (tab.to === '/tenant-settings') {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.id}
|
|
||||||
to="/tenant-settings"
|
|
||||||
search={{ tab: 'bird' }}
|
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{tab.icon}
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.id}
|
|
||||||
to="/settings"
|
|
||||||
search={{ tab: 'connection' }}
|
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{tab.icon}
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function DataGridColumnFilter<TData, TValue>({
|
|||||||
<div className="hidden space-x-1 lg:flex">
|
<div className="hidden space-x-1 lg:flex">
|
||||||
{selectedValues.size > 2 ? (
|
{selectedValues.size > 2 ? (
|
||||||
<Badge variant="secondary" className="px-1 font-normal">
|
<Badge variant="secondary" className="px-1 font-normal">
|
||||||
{selectedValues.size} selected
|
{selectedValues.size} выбрано
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
options
|
options
|
||||||
@@ -94,7 +94,7 @@ function DataGridColumnFilter<TData, TValue>({
|
|||||||
<div className="max-h-[300px] overflow-y-auto">
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
{filteredOptions.length === 0 ? (
|
{filteredOptions.length === 0 ? (
|
||||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||||
No results found.
|
Ничего не найдено.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-1">
|
<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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
sizes: [5, 10, 25, 50, 100],
|
sizes: [5, 10, 25, 50, 100],
|
||||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||||
moreLimit: 5,
|
moreLimit: 5,
|
||||||
info: "{from} - {to} of {count}",
|
info: "{from}–{to} из {count}",
|
||||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||||
rowsPerPageLabel: "Rows per page",
|
rowsPerPageLabel: "Строк на странице",
|
||||||
previousPageLabel: "Go to previous page",
|
previousPageLabel: "Предыдущая страница",
|
||||||
nextPageLabel: "Go to next page",
|
nextPageLabel: "Следующая страница",
|
||||||
ellipsisText: "...",
|
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",
|
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label="Drag to reorder row"
|
aria-label="Перетащить строку"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<GripHorizontalIcon aria-hidden="true" />
|
<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",
|
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label="Drag to reorder row"
|
aria-label="Перетащить строку"
|
||||||
{...context.attributes}
|
{...context.attributes}
|
||||||
{...context.listeners}
|
{...context.listeners}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function DataGridTableDndHeader<TData>({
|
|||||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
aria-label="Drag to reorder"
|
aria-label="Перетащить для изменения порядка"
|
||||||
>
|
>
|
||||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -397,9 +397,9 @@ function DataGridTableVirtual<TData>({
|
|||||||
|
|
||||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||||
const loadingMoreMessage =
|
const loadingMoreMessage =
|
||||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
props.fetchingMoreMessage || props.loadingMessage || "Загрузка…"
|
||||||
const allRowsLoadedMessage =
|
const allRowsLoadedMessage =
|
||||||
props.allRowsLoadedMessage || "All records loaded"
|
props.allRowsLoadedMessage || "Все записи загружены"
|
||||||
|
|
||||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
setViewportElements({
|
setViewportElements({
|
||||||
|
|||||||
@@ -1241,7 +1241,7 @@ function DataGridTableEmpty() {
|
|||||||
colSpan={Math.max(visibleColumnCount, 1)}
|
colSpan={Math.max(visibleColumnCount, 1)}
|
||||||
className="text-muted-foreground py-6 text-center text-sm"
|
className="text-muted-foreground py-6 text-center text-sm"
|
||||||
>
|
>
|
||||||
{props.emptyMessage || "No data available"}
|
{props.emptyMessage || "Нет данных"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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="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">
|
<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" />
|
<Spinner className="size-5 opacity-60" />
|
||||||
{props.loadingMessage || "Loading..."}
|
{props.loadingMessage || "Загрузка…"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1266,7 +1266,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={isPinned ? "Unpin row" : "Pin row"}
|
aria-label={isPinned ? "Открепить строку" : "Закрепить строку"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isPinned) {
|
if (isPinned) {
|
||||||
row.pin(false)
|
row.pin(false)
|
||||||
@@ -1322,7 +1322,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
aria-label="Select row"
|
aria-label="Выбрать строку"
|
||||||
className="align-[inherit]"
|
className="align-[inherit]"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -1341,7 +1341,7 @@ function DataGridTableRowSelectAll() {
|
|||||||
indeterminate={isSomeSelected && !isAllSelected}
|
indeterminate={isSomeSelected && !isAllSelected}
|
||||||
disabled={isLoading || recordCount === 0}
|
disabled={isLoading || recordCount === 0}
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
aria-label="Select all"
|
aria-label="Выбрать все"
|
||||||
className="align-[inherit]"
|
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"
|
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>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{props.loadingMessage || "Loading..."}
|
{props.loadingMessage || "Загрузка…"}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -123,75 +123,70 @@ export interface FilterI18nConfig {
|
|||||||
|
|
||||||
// Default English i18n configuration
|
// Default English i18n configuration
|
||||||
export const DEFAULT_I18N: FilterI18nConfig = {
|
export const DEFAULT_I18N: FilterI18nConfig = {
|
||||||
// UI Labels
|
addFilter: "Фильтр",
|
||||||
addFilter: "Filter",
|
searchFields: "Фильтр…",
|
||||||
searchFields: "Filter...",
|
noFieldsFound: "Поля не найдены.",
|
||||||
noFieldsFound: "No filters found.",
|
noResultsFound: "Ничего не найдено.",
|
||||||
noResultsFound: "No results found.",
|
select: "Выбрать…",
|
||||||
select: "Select...",
|
true: "Да",
|
||||||
true: "True",
|
false: "Нет",
|
||||||
false: "False",
|
min: "Мин.",
|
||||||
min: "Min",
|
max: "Макс.",
|
||||||
max: "Max",
|
to: "—",
|
||||||
to: "to",
|
typeAndPressEnter: "Введите и нажмите Enter",
|
||||||
typeAndPressEnter: "Type and press Enter to add tag",
|
selected: "выбрано",
|
||||||
selected: "selected",
|
selectedCount: "выбрано",
|
||||||
selectedCount: "selected",
|
|
||||||
percent: "%",
|
percent: "%",
|
||||||
defaultCurrency: "$",
|
defaultCurrency: "$",
|
||||||
defaultColor: "#000000",
|
defaultColor: "#000000",
|
||||||
addFilterTitle: "Add filter",
|
addFilterTitle: "Добавить фильтр",
|
||||||
|
|
||||||
// Operators
|
|
||||||
operators: {
|
operators: {
|
||||||
is: "is",
|
is: "равно",
|
||||||
isNot: "is not",
|
isNot: "не равно",
|
||||||
isAnyOf: "is any of",
|
isAnyOf: "любое из",
|
||||||
isNotAnyOf: "is not any of",
|
isNotAnyOf: "кроме",
|
||||||
includesAll: "includes all",
|
includesAll: "включает все",
|
||||||
excludesAll: "excludes all",
|
excludesAll: "исключает все",
|
||||||
before: "before",
|
before: "до",
|
||||||
after: "after",
|
after: "после",
|
||||||
between: "between",
|
between: "между",
|
||||||
notBetween: "not between",
|
notBetween: "вне диапазона",
|
||||||
contains: "contains",
|
contains: "содержит",
|
||||||
notContains: "does not contain",
|
notContains: "не содержит",
|
||||||
startsWith: "starts with",
|
startsWith: "начинается с",
|
||||||
endsWith: "ends with",
|
endsWith: "заканчивается на",
|
||||||
isExactly: "is exactly",
|
isExactly: "точно",
|
||||||
equals: "equals",
|
equals: "равно",
|
||||||
notEquals: "not equals",
|
notEquals: "не равно",
|
||||||
greaterThan: "greater than",
|
greaterThan: "больше",
|
||||||
lessThan: "less than",
|
lessThan: "меньше",
|
||||||
overlaps: "overlaps",
|
overlaps: "пересекается",
|
||||||
includes: "includes",
|
includes: "включает",
|
||||||
excludes: "excludes",
|
excludes: "исключает",
|
||||||
includesAllOf: "includes all of",
|
includesAllOf: "включает все",
|
||||||
includesAnyOf: "includes any of",
|
includesAnyOf: "включает любое",
|
||||||
empty: "is empty",
|
empty: "пусто",
|
||||||
notEmpty: "is not empty",
|
notEmpty: "не пусто",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Placeholders
|
|
||||||
placeholders: {
|
placeholders: {
|
||||||
enterField: (fieldType: string) => `Enter ${fieldType}...`,
|
enterField: (fieldType: string) => `Введите ${fieldType}…`,
|
||||||
selectField: "Select...",
|
selectField: "Выбрать…",
|
||||||
searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`,
|
searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}…`,
|
||||||
enterKey: "Enter key...",
|
enterKey: "Введите ключ…",
|
||||||
enterValue: "Enter value...",
|
enterValue: "Введите значение…",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
helpers: {
|
helpers: {
|
||||||
formatOperator: (operator: string) => operator.replace(/_/g, " "),
|
formatOperator: (operator: string) => operator.replace(/_/g, " "),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Validation
|
|
||||||
validation: {
|
validation: {
|
||||||
invalidEmail: "Invalid email format",
|
invalidEmail: "Некорректный email",
|
||||||
invalidUrl: "Invalid URL format",
|
invalidUrl: "Некорректный URL",
|
||||||
invalidTel: "Invalid phone format",
|
invalidTel: "Некорректный телефон",
|
||||||
invalid: "Invalid input format",
|
invalid: "Некорректное значение",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@evobgp/ui/components/select'
|
} from '@evobgp/ui/components/select'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
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 type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
import { ScheduleCalendarView } from './schedule-calendar-view'
|
import { ScheduleCalendarView } from './schedule-calendar-view'
|
||||||
@@ -34,7 +34,7 @@ function jobTimestamp(job: JobRow): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
|
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')
|
if (filter === 'failed')
|
||||||
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
||||||
return true
|
return true
|
||||||
@@ -107,7 +107,7 @@ export function ScheduleAgendaPanel({
|
|||||||
return (
|
return (
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Календарь задач"
|
title="Календарь задач"
|
||||||
description="Задачи refresh и apply по дням"
|
description="Задачи обновления и применения по дням"
|
||||||
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col lg:flex-row">
|
<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 { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import { isRefreshJobKind } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
||||||
@@ -11,7 +12,7 @@ import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
|||||||
type JobTab = 'all' | 'refresh' | 'failed'
|
type JobTab = 'all' | 'refresh' | 'failed'
|
||||||
|
|
||||||
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
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')
|
if (tab === 'failed')
|
||||||
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
return items
|
return items
|
||||||
@@ -20,7 +21,7 @@ function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
|||||||
function tabCounts(items: JobRow[]) {
|
function tabCounts(items: JobRow[]) {
|
||||||
return {
|
return {
|
||||||
all: items.length,
|
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()))
|
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
.length,
|
.length,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function ScheduleJobsGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
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: 'Вид' },
|
meta: { headerTitle: 'Вид' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function ScheduleModulesGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
{row.original.cron_expr ??
|
{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>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Расписание' },
|
meta: { headerTitle: 'Расписание' },
|
||||||
|
|||||||
@@ -90,16 +90,16 @@ export function AppearanceSettingsTab() {
|
|||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Дашборд"
|
title="Обзор"
|
||||||
description="Блоки на экране «Обзор»"
|
description="Блоки на экране «Обзор»"
|
||||||
>
|
>
|
||||||
<SettingsFieldGroup
|
<SettingsFieldGroup
|
||||||
legend="Быстрые действия"
|
legend="Быстрые действия"
|
||||||
description="Показывать KPI-like плитки быстрых переходов под метриками."
|
description="Показывать плитки быстрых переходов под метриками."
|
||||||
>
|
>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Быстрые действия"
|
title="Быстрые действия"
|
||||||
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
|
description="Блок с частыми переходами (модули, сеть, деплой) на экране «Обзор»."
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Save } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import { FrameDataGrid } from '@/components/reui-kit'
|
||||||
|
import { SelectMenu } from '@/components/select-field'
|
||||||
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
|
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
|
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||||
|
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BIRD_SETTING_KEYS,
|
||||||
|
REVISION_SETTING_KEYS,
|
||||||
|
RUNTIME_LOGS_SETTING_KEYS,
|
||||||
|
buildPayload,
|
||||||
|
partitionSettings,
|
||||||
|
settingsKeys,
|
||||||
|
settingsQueryOptions,
|
||||||
|
type BirdSettingKey,
|
||||||
|
} from '@/queries/settings'
|
||||||
|
import { apiMutate } from '@/lib/api-client'
|
||||||
|
|
||||||
|
const RUNTIME_LOGS_ENABLED_ITEMS = [
|
||||||
|
{ value: 'true', label: 'Вкл' },
|
||||||
|
{ value: 'false', label: 'Выкл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const RUNTIME_LOGS_MODE_ITEMS = [
|
||||||
|
{ value: 'truncate', label: 'Обнулить файл' },
|
||||||
|
{ value: 'delete', label: 'Удалить файл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||||
|
bird_router_id: 'Router ID',
|
||||||
|
bird_local_ipv4: 'Локальный IPv4',
|
||||||
|
bird_local_ipv6: 'Локальный IPv6',
|
||||||
|
bird_local_asn: 'Локальный ASN',
|
||||||
|
bird_bgp_source_ipv4: 'Исходный IPv4 BGP',
|
||||||
|
bird_bgp_source_ipv6: 'Исходный IPv6 BGP',
|
||||||
|
peer_discovery_enabled: 'Автообнаружение пиров',
|
||||||
|
peer_discovery_ranges_v4: 'CIDR автообнаружения IPv4',
|
||||||
|
peer_discovery_ranges_v6: 'CIDR автообнаружения IPv6',
|
||||||
|
peer_discovery_require_external: 'Только внешние ASN',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BIRD_BOOL_ITEMS = [
|
||||||
|
{ value: 'true', label: 'Вкл' },
|
||||||
|
{ value: 'false', label: 'Выкл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
||||||
|
peer_discovery_enabled:
|
||||||
|
'Динамический диапазон соседей в 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: 'Диапазон соседей с внешним (любым чужим) ASN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BirdSettingsTab() {
|
||||||
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
||||||
|
|
||||||
|
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
||||||
|
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
||||||
|
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (partitioned) {
|
||||||
|
setBirdForm({ ...partitioned.bird })
|
||||||
|
setRevisionForm({ ...partitioned.revision })
|
||||||
|
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settingsQ.data])
|
||||||
|
|
||||||
|
const patchMutation = useMutation({
|
||||||
|
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
||||||
|
apiMutate('/v1/settings', 'PATCH', payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Параметры сохранены')
|
||||||
|
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function saveBird() {
|
||||||
|
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
||||||
|
}
|
||||||
|
function saveRevision() {
|
||||||
|
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
||||||
|
}
|
||||||
|
function saveRuntimeLogs() {
|
||||||
|
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<SettingsCard
|
||||||
|
title="Плоскость управления BIRD"
|
||||||
|
description="Глобальные параметры BIRD для обновления модулей и применения конфигурации. Сохранение доступно оператору."
|
||||||
|
footer={
|
||||||
|
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-64" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="BIRD"
|
||||||
|
description="Параметры демона и автообнаружения пиров."
|
||||||
|
>
|
||||||
|
{BIRD_SETTING_KEYS.map((key, index) => (
|
||||||
|
<SettingsSettingField
|
||||||
|
key={key}
|
||||||
|
title={BIRD_LABELS[key]}
|
||||||
|
description={BIRD_HINTS[key]}
|
||||||
|
labelFor={key}
|
||||||
|
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||||
|
stacked
|
||||||
|
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||||
|
>
|
||||||
|
{key === 'peer_discovery_enabled' ||
|
||||||
|
key === 'peer_discovery_require_external' ? (
|
||||||
|
<SelectMenu
|
||||||
|
id={key}
|
||||||
|
items={[...BIRD_BOOL_ITEMS]}
|
||||||
|
value={birdForm[key] || 'false'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||||
|
}
|
||||||
|
placeholder="Выкл"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={key}
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={birdForm[key] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={BIRD_LABELS[key]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSettingField>
|
||||||
|
))}
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Ревизии"
|
||||||
|
description="Время хранения ревизий в БД"
|
||||||
|
footer={
|
||||||
|
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-32" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Хранение"
|
||||||
|
description="Срок хранения ревизий в минутах."
|
||||||
|
>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Срок хранения"
|
||||||
|
description="Сколько минут хранить ревизии конфигурации."
|
||||||
|
labelFor="revision_retention_minutes"
|
||||||
|
stacked
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="revision_retention_minutes"
|
||||||
|
type="number"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={revisionForm.revision_retention_minutes ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRevisionForm((s) => ({
|
||||||
|
...s,
|
||||||
|
revision_retention_minutes: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Файловые логи"
|
||||||
|
description="Автоматическая очистка логов"
|
||||||
|
footer={
|
||||||
|
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-48" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Авто-очистка"
|
||||||
|
description="Расписание и лимиты файловых логов runtime."
|
||||||
|
>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Авто-очистка"
|
||||||
|
description="Включить периодическую очистку файловых логов."
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<SelectMenu
|
||||||
|
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||||
|
placeholder="Выберите"
|
||||||
|
onValueChange={(v) =>
|
||||||
|
v &&
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_enabled: v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Макс. размер файла"
|
||||||
|
description="Порог в мегабайтах, после которого срабатывает очистка."
|
||||||
|
labelFor="runtime_logs_max_file_mb"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="runtime_logs_max_file_mb"
|
||||||
|
type="number"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_max_file_mb: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Расписание"
|
||||||
|
description="Cron-выражение для авто-очистки."
|
||||||
|
labelFor="runtime_logs_auto_schedule"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="runtime_logs_auto_schedule"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_schedule: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Режим очистки"
|
||||||
|
description="Обнулить файл или удалить его."
|
||||||
|
stacked
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<SelectMenu
|
||||||
|
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||||
|
placeholder="Выберите"
|
||||||
|
onValueChange={(v) =>
|
||||||
|
v &&
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_mode: v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<FrameDataGrid
|
||||||
|
title="Дополнительные параметры"
|
||||||
|
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned?.additional ?? []}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
empty={(partitioned?.additional ?? []).length === 0}
|
||||||
|
emptyTitle="Нет дополнительных параметров"
|
||||||
|
skeleton={<div className="h-32" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => (
|
||||||
|
<SettingsKvGrid
|
||||||
|
items={items}
|
||||||
|
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</FrameDataGrid>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
|||||||
|
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Подключение к API"
|
title="Подключение к API"
|
||||||
description="Токен хранится только в этом браузере (localStorage)"
|
description="Токен хранится только в этом браузере"
|
||||||
footer={
|
footer={
|
||||||
<div className="flex w-full min-w-0 flex-wrap justify-end gap-2">
|
<div className="flex w-full min-w-0 flex-wrap justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={useDevToken}>
|
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||||
@@ -84,7 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
|||||||
title="Токен для запросов"
|
title="Токен для запросов"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
Ключ для заголовка Authorization. Управление ключами tenant — в разделе{' '}
|
Ключ для заголовка Authorization. Управление ключами арендатора — в разделе{' '}
|
||||||
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||||
Права доступа
|
Права доступа
|
||||||
</Link>
|
</Link>
|
||||||
@@ -93,7 +93,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
|||||||
}
|
}
|
||||||
titleAddon={
|
titleAddon={
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm">
|
||||||
localStorage
|
браузер
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
labelFor="settings-token"
|
labelFor="settings-token"
|
||||||
|
|||||||
@@ -23,27 +23,30 @@ import {
|
|||||||
const ADMIN_SECTIONS = [
|
const ADMIN_SECTIONS = [
|
||||||
{
|
{
|
||||||
id: 'tenant-settings',
|
id: 'tenant-settings',
|
||||||
to: '/tenant-settings' as const,
|
to: '/settings' as const,
|
||||||
|
search: { tab: 'bird' as const },
|
||||||
title: 'Параметры арендатора',
|
title: 'Параметры арендатора',
|
||||||
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
||||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
badge: { label: 'оператор', variant: 'warning-light' as const },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'access',
|
id: 'access',
|
||||||
to: '/access' as const,
|
to: '/access' as const,
|
||||||
|
search: undefined,
|
||||||
title: 'Права доступа',
|
title: 'Права доступа',
|
||||||
description: 'API-ключи tenant, роли и управление доступом.',
|
description: 'API-ключи арендатора, роли и управление доступом.',
|
||||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
badge: { label: 'оператор', variant: 'warning-light' as const },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'monitoring',
|
id: 'monitoring',
|
||||||
to: '/monitoring' as const,
|
to: '/monitoring' as const,
|
||||||
|
search: undefined,
|
||||||
title: 'Мониторинг',
|
title: 'Мониторинг',
|
||||||
description: 'Метрики, состояние jobs и observability control plane.',
|
description: 'Метрики, состояние задач и наблюдаемость плоскости управления.',
|
||||||
icon: <ActivityIcon aria-hidden="true" />,
|
icon: <ActivityIcon aria-hidden="true" />,
|
||||||
badge: { label: 'viewer+', variant: 'info-light' as const },
|
badge: { label: 'просмотр+', variant: 'info-light' as const },
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -51,8 +54,8 @@ export function SectionsSettingsTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Разделы control plane"
|
title="Разделы плоскости управления"
|
||||||
description="Параметры tenant и операции — отдельно от настроек браузера"
|
description="Параметры арендатора и операции — отдельно от настроек браузера"
|
||||||
>
|
>
|
||||||
<ItemGroup className="gap-0">
|
<ItemGroup className="gap-0">
|
||||||
{ADMIN_SECTIONS.map((section, index) => (
|
{ADMIN_SECTIONS.map((section, index) => (
|
||||||
@@ -76,7 +79,17 @@ export function SectionsSettingsTab() {
|
|||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
<ItemActions className="shrink-0 justify-end self-center">
|
<ItemActions className="shrink-0 justify-end self-center">
|
||||||
<Button variant="outline" size="sm" render={<Link to={section.to} />}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
render={
|
||||||
|
section.to === '/settings' ? (
|
||||||
|
<Link to="/settings" search={{ tab: 'bird' }} />
|
||||||
|
) : (
|
||||||
|
<Link to={section.to} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
Открыть
|
Открыть
|
||||||
</Button>
|
</Button>
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function SessionSettingsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
|
<SettingsCard title="Текущая сессия" description="Проверка токена через API сессии">
|
||||||
<QueryState
|
<QueryState
|
||||||
data={sessionQ.data}
|
data={sessionQ.data}
|
||||||
isLoading={sessionQ.isLoading && hasStoredToken}
|
isLoading={sessionQ.isLoading && hasStoredToken}
|
||||||
@@ -65,7 +65,7 @@ export function SessionSettingsTab() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
<ItemDescription className="leading-5">
|
<ItemDescription className="leading-5">
|
||||||
Tenant:{' '}
|
Арендатор:{' '}
|
||||||
<code className="text-foreground font-mono text-xs break-all">
|
<code className="text-foreground font-mono text-xs break-all">
|
||||||
{session.tenant_id}
|
{session.tenant_id}
|
||||||
</code>
|
</code>
|
||||||
@@ -84,7 +84,7 @@ export function SessionSettingsTab() {
|
|||||||
>
|
>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Роль"
|
title="Роль"
|
||||||
description="Определяет доступ к операциям control plane и CRUD."
|
description="Определяет доступ к операциям плоскости управления и изменению данных."
|
||||||
titleAddon={
|
titleAddon={
|
||||||
sessionQ.data ? (
|
sessionQ.data ? (
|
||||||
<Badge variant="info-light" size="sm">
|
<Badge variant="info-light" size="sm">
|
||||||
@@ -101,7 +101,7 @@ export function SessionSettingsTab() {
|
|||||||
>
|
>
|
||||||
{sessionQ.data ? (
|
{sessionQ.data ? (
|
||||||
<p className="text-muted-foreground text-sm break-words">
|
<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>.
|
<code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,45 +1,138 @@
|
|||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
import { useIsMobile } from '@/hooks/use-mobile'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@evobgp/ui/components/tabs'
|
||||||
|
|
||||||
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
||||||
|
import { BirdSettingsTab } from './bird-settings-tab'
|
||||||
import { ConnectionSettingsTab } from './connection-settings-tab'
|
import { ConnectionSettingsTab } from './connection-settings-tab'
|
||||||
import { SectionsSettingsTab } from './sections-settings-tab'
|
|
||||||
import { SessionSettingsTab } from './session-settings-tab'
|
import { SessionSettingsTab } from './session-settings-tab'
|
||||||
import {
|
import {
|
||||||
SETTINGS_TAB_ITEMS,
|
SETTINGS_TAB_ITEMS,
|
||||||
type SettingsTab,
|
type SettingsSection,
|
||||||
|
type SettingsTabItem,
|
||||||
} from './settings-tabs-data'
|
} from './settings-tabs-data'
|
||||||
|
|
||||||
|
function UiSettingsPanel({ tokenRequired }: { tokenRequired: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
||||||
|
<SessionSettingsTab />
|
||||||
|
<AppearanceSettingsTab />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsNavigation({
|
||||||
|
isMobile,
|
||||||
|
activeValue,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
isMobile: boolean
|
||||||
|
activeValue: string
|
||||||
|
items: SettingsTabItem[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('min-w-0', isMobile ? 'w-full' : 'w-40 shrink-0')}>
|
||||||
|
{isMobile ? (
|
||||||
|
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||||
|
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
|
||||||
|
{items.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
|
||||||
|
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
|
||||||
|
{items.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
|
||||||
|
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified settings — settings-7 AccountSettings 1:1 (header + vertical Tabs).
|
||||||
|
* Surface of cards stays Frame (`SettingsCard`), not shadcn Card.
|
||||||
|
* @see https://reui.io/preview/base/settings-7
|
||||||
|
* @see https://reui.io/blocks
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
export function SettingsPageShell({
|
export function SettingsPageShell({
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
tokenRequired,
|
tokenRequired,
|
||||||
|
showBird,
|
||||||
}: {
|
}: {
|
||||||
activeTab: SettingsTab
|
activeTab: SettingsSection
|
||||||
onTabChange: (tab: SettingsTab) => void
|
onTabChange: (tab: SettingsSection) => void
|
||||||
tokenRequired: boolean
|
tokenRequired: boolean
|
||||||
|
showBird: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const items = showBird
|
||||||
|
? SETTINGS_TAB_ITEMS
|
||||||
|
: SETTINGS_TAB_ITEMS.filter((tab) => tab.value === 'ui')
|
||||||
|
const resolvedTab = activeTab === 'bird' && showBird ? 'bird' : 'ui'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BadgeTabs
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
|
||||||
value={activeTab}
|
<header className="px-1">
|
||||||
onValueChange={(value) => onTabChange(value as SettingsTab)}
|
<h1 className="text-xl font-semibold tracking-tight">Настройки</h1>
|
||||||
items={SETTINGS_TAB_ITEMS.map((tab) => ({
|
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||||
value: tab.value,
|
Интерфейс UI и глобальные параметры BIRD в одном разделе.
|
||||||
label: tab.label,
|
</p>
|
||||||
icon: tab.icon,
|
</header>
|
||||||
}))}
|
|
||||||
>
|
<Tabs
|
||||||
<TabsContent value="connection" className="mt-0">
|
value={resolvedTab}
|
||||||
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
onValueChange={(value) => onTabChange(value as SettingsSection)}
|
||||||
</TabsContent>
|
orientation={isMobile ? 'horizontal' : 'vertical'}
|
||||||
<TabsContent value="session" className="mt-0">
|
className="w-full gap-4 lg:gap-8"
|
||||||
<SessionSettingsTab />
|
>
|
||||||
</TabsContent>
|
<SettingsNavigation
|
||||||
<TabsContent value="appearance" className="mt-0">
|
isMobile={isMobile}
|
||||||
<AppearanceSettingsTab />
|
activeValue={resolvedTab}
|
||||||
</TabsContent>
|
items={items}
|
||||||
<TabsContent value="sections" className="mt-0">
|
/>
|
||||||
<SectionsSettingsTab />
|
|
||||||
</TabsContent>
|
<div className="min-w-0 flex-1">
|
||||||
</BadgeTabs>
|
<TabsContent value="ui" className="mt-0">
|
||||||
|
<UiSettingsPanel tokenRequired={tokenRequired} />
|
||||||
|
</TabsContent>
|
||||||
|
{showBird ? (
|
||||||
|
<TabsContent value="bird" className="mt-0">
|
||||||
|
<BirdSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,32 @@
|
|||||||
import {
|
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||||
KeyRoundIcon,
|
|
||||||
MonitorSmartphoneIcon,
|
|
||||||
PaletteIcon,
|
|
||||||
SlidersHorizontalIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { type ReactNode } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
|
export type SettingsSection = 'ui' | 'bird'
|
||||||
|
|
||||||
export type SettingsTabItem = {
|
export type SettingsTabItem = {
|
||||||
value: SettingsTab
|
value: SettingsSection
|
||||||
label: string
|
label: string
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Side rail items — settings-7 AccountSettings DNA. */
|
||||||
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||||
{
|
{
|
||||||
value: 'connection',
|
value: 'ui',
|
||||||
label: 'Подключение',
|
label: 'Настройки UI',
|
||||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
icon: <SettingsIcon aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'session',
|
value: 'bird',
|
||||||
label: 'Сессия',
|
label: 'Настройки BIRD',
|
||||||
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
|
icon: <ServerCogIcon aria-hidden="true" />,
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'appearance',
|
|
||||||
label: 'Оформление',
|
|
||||||
icon: <PaletteIcon aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'sections',
|
|
||||||
label: 'Разделы',
|
|
||||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function parseSettingsTab(value: unknown): SettingsTab {
|
const LEGACY_BIRD = new Set(['bird', 'revision', 'runtime-logs', 'additional'])
|
||||||
if (
|
|
||||||
value === 'session' ||
|
/** Maps current and legacy `?tab=` values onto the unified settings rail. */
|
||||||
value === 'appearance' ||
|
export function parseSettingsSection(value: unknown): SettingsSection {
|
||||||
value === 'sections'
|
if (typeof value === 'string' && LEGACY_BIRD.has(value)) return 'bird'
|
||||||
) {
|
return 'ui'
|
||||||
return value
|
|
||||||
}
|
|
||||||
return 'connection'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { apiKeyRoleRu } from '@/lib/ui-labels'
|
|||||||
|
|
||||||
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
|
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
|
||||||
{ value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
|
{ value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
|
||||||
{ value: 'editor', label: `${apiKeyRoleRu('editor')} — CRUD без применения` },
|
{ value: 'editor', label: `${apiKeyRoleRu('editor')} — изменение данных без применения` },
|
||||||
{ value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
|
{ value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
|
||||||
{ value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
|
{ value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -346,11 +346,17 @@ export function permissionForPath(pathname: string): string | null {
|
|||||||
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||||
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||||
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||||
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
|
if (pathname.startsWith('/tenant-settings') || pathname.startsWith('/settings')) {
|
||||||
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
|
return canOpenSettings() ? null : 'bgp:settings:read'
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Unified `/settings` — UI (`settings:read`) or BIRD (`tenant_settings:admin`). */
|
||||||
|
export function canOpenSettings(): boolean {
|
||||||
|
return can('bgp:settings:read') || can('bgp:tenant_settings:admin')
|
||||||
|
}
|
||||||
|
|
||||||
const FALLBACK_PATH = '/dashboard'
|
const FALLBACK_PATH = '/dashboard'
|
||||||
|
|
||||||
/** First path in the sidebar the current user may open. */
|
/** First path in the sidebar the current user may open. */
|
||||||
@@ -365,7 +371,6 @@ export function firstAllowedPath(): string {
|
|||||||
'/schedule',
|
'/schedule',
|
||||||
'/monitoring',
|
'/monitoring',
|
||||||
'/access',
|
'/access',
|
||||||
'/tenant-settings',
|
|
||||||
'/settings',
|
'/settings',
|
||||||
]
|
]
|
||||||
for (const path of candidates) {
|
for (const path of candidates) {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('isReadyCheckOk', () => {
|
|||||||
|
|
||||||
describe('readyCheckStatusLabel', () => {
|
describe('readyCheckStatusLabel', () => {
|
||||||
it('labels memory and failures', () => {
|
it('labels memory and failures', () => {
|
||||||
expect(readyCheckStatusLabel('memory', true)).toBe('Memory')
|
expect(readyCheckStatusLabel('memory', true)).toBe('В памяти')
|
||||||
expect(readyCheckStatusLabel('ok', true)).toBe('В норме')
|
expect(readyCheckStatusLabel('ok', true)).toBe('В норме')
|
||||||
expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно')
|
expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function readyCheckStatusLabel(value: ReadyCheckValue, ok: boolean): stri
|
|||||||
}
|
}
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const n = value.trim().toLowerCase()
|
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 === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме'
|
||||||
if (n) return value
|
if (n) return value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,28 @@ export function moduleTypeRu(type: string): string {
|
|||||||
|
|
||||||
const JOB_KIND_RU: Record<string, string> = {
|
const JOB_KIND_RU: Record<string, string> = {
|
||||||
module_refresh: 'Обновление модуля',
|
module_refresh: 'Обновление модуля',
|
||||||
|
tenant_refresh: 'Обновление тенанта',
|
||||||
|
peer_reconcile: 'Согласование пиров',
|
||||||
|
deploy_apply: 'Применение на спикеры',
|
||||||
apply: 'Применение конфигурации',
|
apply: 'Применение конфигурации',
|
||||||
|
revision_rollback: 'Откат ревизии',
|
||||||
rollback: 'Откат ревизии',
|
rollback: 'Откат ревизии',
|
||||||
bird_reload: 'Перезагрузка BIRD',
|
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 {
|
export function jobKindRu(kind: string): string {
|
||||||
@@ -61,18 +80,46 @@ const JOB_STATUS_RU: Record<string, string> = {
|
|||||||
paused: 'Приостановлен',
|
paused: 'Приостановлен',
|
||||||
disabled: 'Выключен',
|
disabled: 'Выключен',
|
||||||
archived: 'В архиве',
|
archived: 'В архиве',
|
||||||
block: 'block',
|
block: 'Блокировать',
|
||||||
accept: 'accept',
|
accept: 'Принимать',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jobStatusRu(status: string): string {
|
export function jobStatusRu(status: string): string {
|
||||||
return JOB_STATUS_RU[status.toLowerCase()] ?? status
|
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 {
|
export function bgpSessionStateRu(state: string | null | undefined): string {
|
||||||
if (!state) return '—'
|
if (!state) return '—'
|
||||||
if (state === 'Established') return 'Установлена'
|
return BGP_SESSION_STATE_RU[state] ?? state
|
||||||
return 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 {
|
export function speakerOnlineLabel(agentOk: boolean | undefined): string {
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ export function useCreateCommunityMutation() {
|
|||||||
mutationFn: (body: BgpCommunityCreate) =>
|
mutationFn: (body: BgpCommunityCreate) =>
|
||||||
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
|
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Сообщество создано')
|
toast.success('Community создано')
|
||||||
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
||||||
},
|
},
|
||||||
onError: (e) =>
|
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 }) =>
|
mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) =>
|
||||||
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }),
|
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Сообщество обновлено')
|
toast.success('Community обновлено')
|
||||||
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
||||||
},
|
},
|
||||||
onError: (e) =>
|
onError: (e) =>
|
||||||
toast.error(e instanceof Error ? e.message : 'Не удалось обновить сообщество'),
|
toast.error(e instanceof Error ? e.message : 'Не удалось обновить community'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
|||||||
import { apiJSON, apiMutate } from '@/lib/api-client'
|
import { apiJSON, apiMutate } from '@/lib/api-client'
|
||||||
import { overviewKeys } from '@/queries/overview'
|
import { overviewKeys } from '@/queries/overview'
|
||||||
import type {
|
import type {
|
||||||
|
ModuleCreate,
|
||||||
ModulePatch,
|
ModulePatch,
|
||||||
ModuleRow,
|
ModuleRow,
|
||||||
ModulesResponse,
|
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() {
|
export function useUpdateModuleMutation() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const Route = createFileRoute('/_auth')({
|
|||||||
if (!raw || !normalizeApiToken(raw)) {
|
if (!raw || !normalizeApiToken(raw)) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: '/settings',
|
to: '/settings',
|
||||||
search: { tab: 'connection', reason: 'token-required' },
|
search: { tab: 'ui', reason: 'token-required' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,31 +1,39 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
||||||
import { type SettingsTab } from '@/components/settings/settings-tabs-data'
|
import {
|
||||||
|
parseSettingsSection,
|
||||||
const settingsSearchSchema = z.object({
|
type SettingsSection,
|
||||||
tab: z
|
} from '@/components/settings/settings-tabs-data'
|
||||||
.enum(['connection', 'session', 'appearance', 'sections'])
|
import { can } from '@/lib/auth'
|
||||||
.catch('connection'),
|
|
||||||
reason: z.enum(['token-required']).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/_settings/settings')({
|
export const Route = createFileRoute('/_auth/_settings/settings')({
|
||||||
component: SettingsComponent,
|
component: SettingsComponent,
|
||||||
validateSearch: (search) => settingsSearchSchema.parse(search),
|
validateSearch: (search: Record<string, unknown>): {
|
||||||
|
tab: SettingsSection
|
||||||
|
reason?: 'token-required'
|
||||||
|
} => ({
|
||||||
|
tab: parseSettingsSection(search.tab),
|
||||||
|
...(search.reason === 'token-required'
|
||||||
|
? { reason: 'token-required' as const }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
function SettingsComponent() {
|
function SettingsComponent() {
|
||||||
const navigate = Route.useNavigate()
|
const navigate = Route.useNavigate()
|
||||||
const { tab, reason } = Route.useSearch()
|
const { tab, reason } = Route.useSearch()
|
||||||
const tokenRequired = reason === 'token-required'
|
const tokenRequired = reason === 'token-required'
|
||||||
|
const showBird = !tokenRequired && can('bgp:tenant_settings:admin')
|
||||||
|
const activeTab: SettingsSection =
|
||||||
|
tokenRequired || (tab === 'bird' && !showBird) ? 'ui' : tab
|
||||||
|
|
||||||
function handleTabChange(nextTab: SettingsTab) {
|
function handleTabChange(nextTab: SettingsSection) {
|
||||||
void navigate({
|
void navigate({
|
||||||
search: (prev) => ({
|
search: (prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
tab: nextTab,
|
tab: nextTab,
|
||||||
|
reason: nextTab === 'ui' ? prev.reason : undefined,
|
||||||
}),
|
}),
|
||||||
replace: true,
|
replace: true,
|
||||||
})
|
})
|
||||||
@@ -33,9 +41,10 @@ function SettingsComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsPageShell
|
<SettingsPageShell
|
||||||
activeTab={tab}
|
activeTab={activeTab}
|
||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
tokenRequired={tokenRequired}
|
tokenRequired={tokenRequired}
|
||||||
|
showBird={showBird}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,377 +1,8 @@
|
|||||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { Save } from 'lucide-react'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
|
||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
|
||||||
import { FrameDataGrid } from '@/components/reui-kit'
|
|
||||||
import { SelectMenu } from '@/components/select-field'
|
|
||||||
import { SettingsCard } from '@/components/settings/settings-card'
|
|
||||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
|
||||||
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
|
||||||
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
|
||||||
import { QueryState } from '@/components/query-state'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
|
||||||
|
|
||||||
import {
|
|
||||||
BIRD_SETTING_KEYS,
|
|
||||||
REVISION_SETTING_KEYS,
|
|
||||||
RUNTIME_LOGS_SETTING_KEYS,
|
|
||||||
buildPayload,
|
|
||||||
partitionSettings,
|
|
||||||
settingsKeys,
|
|
||||||
settingsQueryOptions,
|
|
||||||
type BirdSettingKey,
|
|
||||||
} from '@/queries/settings'
|
|
||||||
import { apiMutate } from '@/lib/api-client'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
|
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
|
||||||
component: TenantSettingsComponent,
|
beforeLoad: () => {
|
||||||
validateSearch: (search: Record<string, unknown>) => ({
|
throw redirect({ to: '/settings', search: { tab: 'bird' } })
|
||||||
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
|
},
|
||||||
? search.tab
|
component: () => null,
|
||||||
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const RUNTIME_LOGS_ENABLED_ITEMS = [
|
|
||||||
{ value: 'true', label: 'Вкл' },
|
|
||||||
{ value: 'false', label: 'Выкл' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const RUNTIME_LOGS_MODE_ITEMS = [
|
|
||||||
{ value: 'truncate', label: 'обнулить (truncate)' },
|
|
||||||
{ value: 'delete', label: 'удалить файл (delete)' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
|
||||||
bird_router_id: 'Router ID',
|
|
||||||
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',
|
|
||||||
peer_discovery_enabled: 'Автообнаружение пиров',
|
|
||||||
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
|
|
||||||
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
|
|
||||||
peer_discovery_require_external: 'Только external ASN',
|
|
||||||
}
|
|
||||||
|
|
||||||
const BIRD_BOOL_ITEMS = [
|
|
||||||
{ value: 'true', label: 'Вкл' },
|
|
||||||
{ value: 'false', label: 'Выкл' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
|
||||||
peer_discovery_enabled:
|
|
||||||
'Dynamic neighbor range в 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)',
|
|
||||||
}
|
|
||||||
|
|
||||||
function TenantSettingsComponent() {
|
|
||||||
const search = useSearch({ from: '/_auth/_settings/tenant-settings' })
|
|
||||||
const navigate = Route.useNavigate()
|
|
||||||
const settingsQ = useQuery(settingsQueryOptions())
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
|
||||||
|
|
||||||
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
|
||||||
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
|
||||||
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (partitioned) {
|
|
||||||
setBirdForm({ ...partitioned.bird })
|
|
||||||
setRevisionForm({ ...partitioned.revision })
|
|
||||||
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [settingsQ.data])
|
|
||||||
|
|
||||||
const patchMutation = useMutation({
|
|
||||||
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
|
||||||
apiMutate('/v1/settings', 'PATCH', payload),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Параметры сохранены')
|
|
||||||
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
|
||||||
})
|
|
||||||
|
|
||||||
function saveBird() {
|
|
||||||
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
|
||||||
}
|
|
||||||
function saveRevision() {
|
|
||||||
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
|
||||||
}
|
|
||||||
function saveRuntimeLogs() {
|
|
||||||
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 flex-col gap-6">
|
|
||||||
<BadgeTabs
|
|
||||||
value={search.tab}
|
|
||||||
onValueChange={(tab) =>
|
|
||||||
navigate({
|
|
||||||
search: { tab: tab as 'bird' | 'revision' | 'runtime-logs' | 'additional' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
items={[
|
|
||||||
{ value: 'bird', label: 'BIRD' },
|
|
||||||
{ value: 'revision', label: 'Ревизии' },
|
|
||||||
{ value: 'runtime-logs', label: 'Файловые логи' },
|
|
||||||
{ value: 'additional', label: 'Дополнительно' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TabsContent value="bird" className="mt-0">
|
|
||||||
<SettingsCard
|
|
||||||
title="BIRD control plane"
|
|
||||||
description="Глобальные параметры BIRD для pipeline refresh/apply. Сохранение — роль operator."
|
|
||||||
footer={
|
|
||||||
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
|
||||||
<Save />
|
|
||||||
Сохранить
|
|
||||||
</LoadingButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<QueryState
|
|
||||||
data={partitioned}
|
|
||||||
isLoading={settingsQ.isLoading}
|
|
||||||
isError={settingsQ.isError}
|
|
||||||
error={settingsQ.error}
|
|
||||||
skeleton={<div className="h-64" />}
|
|
||||||
onRetry={() => settingsQ.refetch()}
|
|
||||||
>
|
|
||||||
{() => (
|
|
||||||
<SettingsFieldGroup
|
|
||||||
legend="BIRD"
|
|
||||||
description="Параметры демона и автообнаружения пиров."
|
|
||||||
>
|
|
||||||
{BIRD_SETTING_KEYS.map((key, index) => (
|
|
||||||
<SettingsSettingField
|
|
||||||
key={key}
|
|
||||||
title={BIRD_LABELS[key]}
|
|
||||||
description={BIRD_HINTS[key]}
|
|
||||||
labelFor={key}
|
|
||||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
|
||||||
stacked
|
|
||||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
|
||||||
>
|
|
||||||
{key === 'peer_discovery_enabled' ||
|
|
||||||
key === 'peer_discovery_require_external' ? (
|
|
||||||
<SelectMenu
|
|
||||||
id={key}
|
|
||||||
items={[...BIRD_BOOL_ITEMS]}
|
|
||||||
value={birdForm[key] || 'false'}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
|
||||||
}
|
|
||||||
placeholder="Выкл"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
id={key}
|
|
||||||
className="w-full min-w-0"
|
|
||||||
value={birdForm[key] ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder={BIRD_LABELS[key]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSettingField>
|
|
||||||
))}
|
|
||||||
</SettingsFieldGroup>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</SettingsCard>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="revision" className="mt-0">
|
|
||||||
<SettingsCard
|
|
||||||
title="Ревизии"
|
|
||||||
description="Время хранения ревизий в БД"
|
|
||||||
footer={
|
|
||||||
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
|
|
||||||
<Save />
|
|
||||||
Сохранить
|
|
||||||
</LoadingButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<QueryState
|
|
||||||
data={partitioned}
|
|
||||||
isLoading={settingsQ.isLoading}
|
|
||||||
isError={settingsQ.isError}
|
|
||||||
error={settingsQ.error}
|
|
||||||
skeleton={<div className="h-32" />}
|
|
||||||
onRetry={() => settingsQ.refetch()}
|
|
||||||
>
|
|
||||||
{() => (
|
|
||||||
<SettingsFieldGroup
|
|
||||||
legend="Retention"
|
|
||||||
description="Срок хранения ревизий в минутах."
|
|
||||||
>
|
|
||||||
<SettingsSettingField
|
|
||||||
title="Retention"
|
|
||||||
description="Сколько минут хранить ревизии конфигурации."
|
|
||||||
labelFor="revision_retention_minutes"
|
|
||||||
stacked
|
|
||||||
last
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="revision_retention_minutes"
|
|
||||||
type="number"
|
|
||||||
className="w-full min-w-0"
|
|
||||||
value={revisionForm.revision_retention_minutes ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setRevisionForm((s) => ({
|
|
||||||
...s,
|
|
||||||
revision_retention_minutes: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsSettingField>
|
|
||||||
</SettingsFieldGroup>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</SettingsCard>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="runtime-logs" className="mt-0">
|
|
||||||
<SettingsCard
|
|
||||||
title="Файловые логи"
|
|
||||||
description="Автоматическая очистка логов"
|
|
||||||
footer={
|
|
||||||
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
|
||||||
<Save />
|
|
||||||
Сохранить
|
|
||||||
</LoadingButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<QueryState
|
|
||||||
data={partitioned}
|
|
||||||
isLoading={settingsQ.isLoading}
|
|
||||||
isError={settingsQ.isError}
|
|
||||||
error={settingsQ.error}
|
|
||||||
skeleton={<div className="h-48" />}
|
|
||||||
onRetry={() => settingsQ.refetch()}
|
|
||||||
>
|
|
||||||
{() => (
|
|
||||||
<SettingsFieldGroup
|
|
||||||
legend="Авто-очистка"
|
|
||||||
description="Расписание и лимиты файловых логов runtime."
|
|
||||||
>
|
|
||||||
<SettingsSettingField
|
|
||||||
title="Авто-очистка"
|
|
||||||
description="Включить периодическую очистку файловых логов."
|
|
||||||
stacked
|
|
||||||
>
|
|
||||||
<SelectMenu
|
|
||||||
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
|
||||||
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
|
||||||
placeholder="Выберите"
|
|
||||||
onValueChange={(v) =>
|
|
||||||
v &&
|
|
||||||
setRuntimeLogsForm((s) => ({
|
|
||||||
...s,
|
|
||||||
runtime_logs_auto_enabled: v,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsSettingField>
|
|
||||||
<SettingsSettingField
|
|
||||||
title="Макс. размер файла"
|
|
||||||
description="Порог в мегабайтах, после которого срабатывает очистка."
|
|
||||||
labelFor="runtime_logs_max_file_mb"
|
|
||||||
stacked
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="runtime_logs_max_file_mb"
|
|
||||||
type="number"
|
|
||||||
className="w-full min-w-0"
|
|
||||||
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setRuntimeLogsForm((s) => ({
|
|
||||||
...s,
|
|
||||||
runtime_logs_max_file_mb: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsSettingField>
|
|
||||||
<SettingsSettingField
|
|
||||||
title="Расписание"
|
|
||||||
description="Cron-выражение для авто-очистки."
|
|
||||||
labelFor="runtime_logs_auto_schedule"
|
|
||||||
stacked
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="runtime_logs_auto_schedule"
|
|
||||||
className="w-full min-w-0"
|
|
||||||
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setRuntimeLogsForm((s) => ({
|
|
||||||
...s,
|
|
||||||
runtime_logs_auto_schedule: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsSettingField>
|
|
||||||
<SettingsSettingField
|
|
||||||
title="Режим очистки"
|
|
||||||
description="Обнулить файл или удалить его."
|
|
||||||
stacked
|
|
||||||
last
|
|
||||||
>
|
|
||||||
<SelectMenu
|
|
||||||
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
|
||||||
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
|
||||||
placeholder="Выберите"
|
|
||||||
onValueChange={(v) =>
|
|
||||||
v &&
|
|
||||||
setRuntimeLogsForm((s) => ({
|
|
||||||
...s,
|
|
||||||
runtime_logs_auto_mode: v,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsSettingField>
|
|
||||||
</SettingsFieldGroup>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</SettingsCard>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="additional" className="mt-0">
|
|
||||||
<FrameDataGrid
|
|
||||||
title="Дополнительные параметры"
|
|
||||||
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
|
|
||||||
>
|
|
||||||
<QueryState
|
|
||||||
data={partitioned?.additional ?? []}
|
|
||||||
isLoading={settingsQ.isLoading}
|
|
||||||
isError={settingsQ.isError}
|
|
||||||
error={settingsQ.error}
|
|
||||||
empty={(partitioned?.additional ?? []).length === 0}
|
|
||||||
emptyTitle="Нет дополнительных параметров"
|
|
||||||
skeleton={<div className="h-32" />}
|
|
||||||
onRetry={() => settingsQ.refetch()}
|
|
||||||
>
|
|
||||||
{(items) => (
|
|
||||||
<SettingsKvGrid
|
|
||||||
items={items}
|
|
||||||
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</FrameDataGrid>
|
|
||||||
</TabsContent>
|
|
||||||
</BadgeTabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function AccessComponent() {
|
|||||||
icon: <KeyRound className="size-4" />,
|
icon: <KeyRound className="size-4" />,
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant="primary-light" size="sm">
|
<Badge variant="primary-light" size="sm">
|
||||||
в tenant
|
в арендаторе
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -89,16 +89,16 @@ function AccessComponent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionKindLabel =
|
const sessionKindLabel =
|
||||||
session?.kind === 'jwt' ? 'Portal JWT' : session?.kind === 'apikey' ? 'API-ключ' : null
|
session?.kind === 'jwt' ? 'JWT портала' : session?.kind === 'apikey' ? 'API-ключ' : null
|
||||||
|
|
||||||
const sessionAccessLabel = (() => {
|
const sessionAccessLabel = (() => {
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
if (session.kind === 'jwt' || session.is_admin || (session.permissions?.length ?? 0) > 0) {
|
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'
|
if (sessionCanManageApiKeys(session)) return 'bgp:access:admin'
|
||||||
return session.permissions?.length
|
return session.permissions?.length
|
||||||
? session.permissions.slice(0, 3).join(', ')
|
? session.permissions.slice(0, 3).join(', ')
|
||||||
: 'без access:admin'
|
: 'без права access:admin'
|
||||||
}
|
}
|
||||||
return session.role || '—'
|
return session.role || '—'
|
||||||
})()
|
})()
|
||||||
@@ -107,7 +107,7 @@ function AccessComponent() {
|
|||||||
<div className="flex min-w-0 flex-col gap-6">
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Права доступа"
|
title="Права доступа"
|
||||||
description="API-ключи control plane и текущая сессия Bearer-токена."
|
description="API-ключи плоскости управления и текущая сессия."
|
||||||
actions={
|
actions={
|
||||||
canManageKeys ? (
|
canManageKeys ? (
|
||||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
@@ -121,7 +121,7 @@ function AccessComponent() {
|
|||||||
{session ? (
|
{session ? (
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Текущая сессия"
|
title="Текущая сессия"
|
||||||
description="Tenant и права текущего Bearer (API-ключ или portal JWT)."
|
description="Арендатор и права текущей сессии (API-ключ или JWT портала)."
|
||||||
>
|
>
|
||||||
<ItemGroup className="gap-0">
|
<ItemGroup className="gap-0">
|
||||||
<Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
|
<Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
|
||||||
@@ -141,7 +141,7 @@ function AccessComponent() {
|
|||||||
</div>
|
</div>
|
||||||
<ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
|
<ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
|
||||||
<span className="break-all">
|
<span className="break-all">
|
||||||
Tenant:{' '}
|
Арендатор:{' '}
|
||||||
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
||||||
</span>
|
</span>
|
||||||
<span className="break-words">Доступ: {sessionAccessLabel}</span>
|
<span className="break-words">Доступ: {sessionAccessLabel}</span>
|
||||||
@@ -157,7 +157,7 @@ function AccessComponent() {
|
|||||||
Не удалось определить сессию. Укажите токен в{' '}
|
Не удалось определить сессию. Укажите токен в{' '}
|
||||||
<Link
|
<Link
|
||||||
to="/settings"
|
to="/settings"
|
||||||
search={{ tab: 'connection' }}
|
search={{ tab: 'ui' }}
|
||||||
className="text-primary underline-offset-4 hover:underline"
|
className="text-primary underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
настройках
|
настройках
|
||||||
@@ -188,8 +188,8 @@ function AccessComponent() {
|
|||||||
) : session ? (
|
) : session ? (
|
||||||
<SettingsCard title="API-ключи">
|
<SettingsCard title="API-ключи">
|
||||||
<p className="text-muted-foreground px-5 py-4 text-sm break-words">
|
<p className="text-muted-foreground px-5 py-4 text-sm break-words">
|
||||||
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
|
Управление API-ключами доступно роли <strong>оператор</strong> (API-ключ) или JWT портала
|
||||||
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
|
с правом администратора / <code className="text-xs">bgp:access:admin</code>.
|
||||||
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
|
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
|
||||||
</p>
|
</p>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Button } from '@evobgp/ui/components/button'
|
|||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
||||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
|
||||||
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
|
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
|
||||||
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
||||||
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
||||||
@@ -15,7 +14,7 @@ import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-o
|
|||||||
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
|
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
|
||||||
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
|
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
|
||||||
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
|
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
|
||||||
import { OpsDashboard } from '@/components/reui-kit'
|
import { FrameDataGrid, OpsDashboard } from '@/components/reui-kit'
|
||||||
import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface'
|
import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface'
|
||||||
import {
|
import {
|
||||||
moduleNameById,
|
moduleNameById,
|
||||||
@@ -111,7 +110,7 @@ function DashboardComponent() {
|
|||||||
peers={peers}
|
peers={peers}
|
||||||
speakers={speakers}
|
speakers={speakers}
|
||||||
/>
|
/>
|
||||||
<div className="grid min-w-0 gap-4">
|
<div className="grid min-w-0 items-start gap-4">
|
||||||
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
|
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
|
||||||
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
|
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
|
||||||
</div>
|
</div>
|
||||||
@@ -122,28 +121,26 @@ function DashboardComponent() {
|
|||||||
queueDescription="Последние фоновые операции и история конфигураций"
|
queueDescription="Последние фоновые операции и история конфигураций"
|
||||||
queue={
|
queue={
|
||||||
<div className={chartPanelGridClassName}>
|
<div className={chartPanelGridClassName}>
|
||||||
<DashboardFramePanel
|
<FrameDataGrid
|
||||||
title="Недавние задачи"
|
title="Недавние задачи"
|
||||||
description="Последние фоновые операции"
|
description="Последние фоновые операции"
|
||||||
className="h-full min-w-0"
|
|
||||||
>
|
>
|
||||||
{activityLoading ? (
|
{activityLoading ? (
|
||||||
<Skeleton className="m-4 h-24 w-auto" />
|
<Skeleton className="m-4 h-24 w-auto" />
|
||||||
) : (
|
) : (
|
||||||
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
|
<DashboardRecentJobsGrid jobs={jobs.slice(0, 8)} nameById={nameById} isLoading={refreshing} />
|
||||||
)}
|
)}
|
||||||
</DashboardFramePanel>
|
</FrameDataGrid>
|
||||||
<DashboardFramePanel
|
<FrameDataGrid
|
||||||
title="Последние ревизии"
|
title="Последние ревизии"
|
||||||
description="История конфигураций"
|
description="История конфигураций"
|
||||||
className="h-full min-w-0"
|
|
||||||
>
|
>
|
||||||
{activityLoading ? (
|
{activityLoading ? (
|
||||||
<Skeleton className="m-4 h-24 w-auto" />
|
<Skeleton className="m-4 h-24 w-auto" />
|
||||||
) : (
|
) : (
|
||||||
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
||||||
)}
|
)}
|
||||||
</DashboardFramePanel>
|
</FrameDataGrid>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function DirectoriesComponent() {
|
|||||||
|
|
||||||
const items: KpiStatItem[] = [
|
const items: KpiStatItem[] = [
|
||||||
{
|
{
|
||||||
label: 'Сообщества BGP',
|
label: 'BGP community',
|
||||||
value: communities.length,
|
value: communities.length,
|
||||||
icon: <Tags className="size-4" />,
|
icon: <Tags className="size-4" />,
|
||||||
footer: (
|
footer: (
|
||||||
@@ -70,7 +70,7 @@ function DirectoriesComponent() {
|
|||||||
icon: <BookText className="size-4" />,
|
icon: <BookText className="size-4" />,
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm">
|
||||||
все модули tenant
|
все модули арендатора
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -114,7 +114,7 @@ function DirectoriesComponent() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Справочники"
|
title="Справочники"
|
||||||
description="Сообщества BGP и DoH-профили для резолвинга доменов"
|
description="BGP community и DoH-профили для резолвинга доменов"
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -136,13 +136,13 @@ function DirectoriesComponent() {
|
|||||||
<BadgeTabs
|
<BadgeTabs
|
||||||
defaultValue="communities"
|
defaultValue="communities"
|
||||||
items={[
|
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' },
|
{ value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<TabsContent value="communities" className="mt-0">
|
<TabsContent value="communities" className="mt-0">
|
||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
title="Сообщества BGP"
|
title="BGP community"
|
||||||
description="Теги для префиксов в фильтрах BIRD"
|
description="Теги для префиксов в фильтрах BIRD"
|
||||||
actions={addCommunityButton}
|
actions={addCommunityButton}
|
||||||
>
|
>
|
||||||
@@ -152,7 +152,7 @@ function DirectoriesComponent() {
|
|||||||
isError={communitiesQ.isError}
|
isError={communitiesQ.isError}
|
||||||
error={communitiesQ.error}
|
error={communitiesQ.error}
|
||||||
empty={communities.length === 0}
|
empty={communities.length === 0}
|
||||||
emptyTitle="Нет сообществ"
|
emptyTitle="Нет community"
|
||||||
emptyAction={addCommunityButton}
|
emptyAction={addCommunityButton}
|
||||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||||
onRetry={() => communitiesQ.refetch()}
|
onRetry={() => communitiesQ.refetch()}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Link, createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Plus, RefreshCw } from 'lucide-react'
|
import { Plus, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
@@ -6,18 +6,45 @@ import { Button } from '@evobgp/ui/components/button'
|
|||||||
|
|
||||||
import { FrameDataGrid } from '@/components/reui-kit'
|
import { FrameDataGrid } from '@/components/reui-kit'
|
||||||
import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
|
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 { ModulesListGrid } from '@/components/modules/modules-list-grid'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
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'
|
import { modulesListQueryOptions } from '@/queries/modules'
|
||||||
|
|
||||||
|
function parseCreateFlag(value: unknown): boolean {
|
||||||
|
return value === true || value === '1' || value === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/modules/')({
|
export const Route = createFileRoute('/_auth/modules/')({
|
||||||
component: ModulesListComponent,
|
component: ModulesListComponent,
|
||||||
|
validateSearch: (search: Record<string, unknown>): { create?: boolean } => {
|
||||||
|
if (parseCreateFlag(search.create)) return { create: true }
|
||||||
|
return {}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function ModulesListComponent() {
|
function ModulesListComponent() {
|
||||||
|
const { create } = Route.useSearch()
|
||||||
|
const navigate = Route.useNavigate()
|
||||||
const query = useQuery(modulesListQueryOptions())
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -42,10 +69,12 @@ function ModulesListComponent() {
|
|||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
title="Все модули"
|
title="Все модули"
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
canWrite ? (
|
||||||
<Plus />
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
Создать
|
<Plus />
|
||||||
</Button>
|
Создать
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<QueryState
|
<QueryState
|
||||||
@@ -54,7 +83,12 @@ function ModulesListComponent() {
|
|||||||
isError={query.isError}
|
isError={query.isError}
|
||||||
error={query.error}
|
error={query.error}
|
||||||
empty={query.data?.items?.length === 0}
|
empty={query.data?.items?.length === 0}
|
||||||
emptyContent={<ProjectsEmptyState />}
|
emptyContent={
|
||||||
|
<ProjectsEmptyState
|
||||||
|
canCreate={canWrite}
|
||||||
|
onCreate={() => setCreateOpen(true)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||||
onRetry={() => query.refetch()}
|
onRetry={() => query.refetch()}
|
||||||
>
|
>
|
||||||
@@ -66,6 +100,16 @@ function ModulesListComponent() {
|
|||||||
)}
|
)}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</FrameDataGrid>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,7 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, redirect } 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'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/modules/new')({
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ function MonitoringComponent() {
|
|||||||
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
|
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
|
||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
title="Доступность и готовность"
|
title="Доступность и готовность"
|
||||||
description="GET /v1/health · GET /v1/ready"
|
description="Проверки живучести и готовности (/v1/health, /v1/ready)"
|
||||||
>
|
>
|
||||||
<QueryState
|
<QueryState
|
||||||
data={readyQ.data}
|
data={readyQ.data}
|
||||||
@@ -148,7 +148,7 @@ function MonitoringComponent() {
|
|||||||
BGP на API-хосте
|
BGP на API-хосте
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
description="GET /v1/bird/status"
|
description="Статус BIRD на хосте API"
|
||||||
className="h-full"
|
className="h-full"
|
||||||
contentClassName="px-5 py-4"
|
contentClassName="px-5 py-4"
|
||||||
>
|
>
|
||||||
@@ -169,7 +169,7 @@ function MonitoringComponent() {
|
|||||||
<div className="flex flex-col gap-2 md:gap-3">
|
<div className="flex flex-col gap-2 md:gap-3">
|
||||||
<SegmentedProgressCard
|
<SegmentedProgressCard
|
||||||
title="Задачи"
|
title="Задачи"
|
||||||
description="Последние 100 задач · GET /v1/jobs"
|
description="Последние 100 задач"
|
||||||
primary={{
|
primary={{
|
||||||
value: jobs.filter((j) => j.status === 'running' || j.status === 'queued').length,
|
value: jobs.filter((j) => j.status === 'running' || j.status === 'queued').length,
|
||||||
label: 'Активных',
|
label: 'Активных',
|
||||||
@@ -230,11 +230,10 @@ function MonitoringComponent() {
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
|
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
|
||||||
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
|
<code className="text-xs">PostgreSQL</code>, затем хранилище и очередь задач в проверках.
|
||||||
и <code className="text-xs">jobs</code> в проверках.
|
|
||||||
</li>
|
</li>
|
||||||
<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>, затем состояние пиров в Сети.
|
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -250,7 +249,7 @@ function MonitoringComponent() {
|
|||||||
<IllustratedEmptyState
|
<IllustratedEmptyState
|
||||||
icon={Database}
|
icon={Database}
|
||||||
title="PostgreSQL"
|
title="PostgreSQL"
|
||||||
description="Статус соединения и пул отображаются в readiness-проверке на вкладке «Система» (check postgres)."
|
description="Состояние соединения и пула видно в проверке готовности на вкладке «Система»."
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -258,7 +257,7 @@ function MonitoringComponent() {
|
|||||||
<IllustratedEmptyState
|
<IllustratedEmptyState
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title="Файловые логи"
|
title="Файловые логи"
|
||||||
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в tenant-settings."
|
description="Логи API и конвейера задаются переменной EVOBGP_LOG_* и управляются в Настройках BIRD."
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</BadgeTabs>
|
</BadgeTabs>
|
||||||
|
|||||||
@@ -763,7 +763,7 @@ export interface paths {
|
|||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Зарегистрировать спикер
|
* Зарегистрировать спикер
|
||||||
* @description Реплика, canary и т.д.
|
* @description Реплика или master. Для replica 201 содержит agent_secret, node_token и install.docker_commands (bird2 + agent + Traefik LE DNS-01) — один раз.
|
||||||
*/
|
*/
|
||||||
post: operations["createSpeaker"];
|
post: operations["createSpeaker"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -2733,11 +2733,38 @@ export interface components {
|
|||||||
role: string;
|
role: string;
|
||||||
/** @description URL agent или https://AGENT_DOMAIN */
|
/** @description URL agent или https://AGENT_DOMAIN */
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
/** @description JSON-объект. Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4), agent_domain, agent_secret (генерируется при создании если пуст). */
|
/** @description JSON-объект (строка или object). Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4), agent_domain, agent_secret (генерируется при создании если пуст). */
|
||||||
meta_json?: string;
|
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;
|
[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: {
|
BgpSpeakerPatch: {
|
||||||
role?: string;
|
role?: string;
|
||||||
endpoint?: string;
|
endpoint?: string;
|
||||||
@@ -4576,13 +4603,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Ресурс создан. */
|
/** @description Ресурс создан. Для replica — одноразовый install-сниппет. */
|
||||||
201: {
|
201: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["BgpSpeaker"];
|
"application/json": components["schemas"]["BgpSpeakerCreated"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
default: components["responses"]["DefaultProblem"];
|
default: components["responses"]["DefaultProblem"];
|
||||||
|
|||||||
@@ -293,13 +293,24 @@ export type SpeakerRow = {
|
|||||||
last_dispatch_error?: string | null
|
last_dispatch_error?: string | null
|
||||||
meta_json?: Record<string, unknown>
|
meta_json?: Record<string, unknown>
|
||||||
agent_secret?: string
|
agent_secret?: string
|
||||||
|
node_token?: string
|
||||||
|
bundle_pubkey_base64?: string
|
||||||
|
install?: SpeakerInstall
|
||||||
live?: SpeakerLiveStatus
|
live?: SpeakerLiveStatus
|
||||||
}
|
}
|
||||||
|
export type SpeakerInstall = {
|
||||||
|
docker_commands?: string
|
||||||
|
compose_yaml?: string
|
||||||
|
}
|
||||||
export type SpeakersResponse = Page<SpeakerRow>
|
export type SpeakersResponse = Page<SpeakerRow>
|
||||||
export type BgpSpeakerCreate = {
|
export type BgpSpeakerCreate = {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
role?: 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>
|
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
|||||||
# Default BIRD 2 config for EvoBGP Docker stack (operator extends with include "bird.d/*.conf";).
|
# Default BIRD 2 config for EvoBGP Docker stack (operator extends with include "bird.d/*.conf";).
|
||||||
router id 192.0.2.1;
|
router id 192.0.2.1;
|
||||||
|
log stderr all;
|
||||||
|
|
||||||
protocol device {
|
protocol device {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,12 @@
|
|||||||
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls up -d
|
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls up -d
|
||||||
#
|
#
|
||||||
# Profiles:
|
# Profiles:
|
||||||
# production (default) — bird2 host + agent + evobgp-edge
|
# production (default) — bird2 (speaker-net, 179:179) + agent + evobgp-edge
|
||||||
# plain — bird2 + agent без Traefik (lab)
|
# plain — bird2 + agent без Traefik (lab)
|
||||||
# fallback — + sync-bundle polling
|
# fallback — + sync-bundle polling
|
||||||
|
#
|
||||||
|
# BGP TCP/179 as on the control plane. Overlay sets router id / local.
|
||||||
|
# Logs: docker compose logs -f bird2 evobgp-agent
|
||||||
|
|
||||||
name: evobgp-remote-speaker
|
name: evobgp-remote-speaker
|
||||||
|
|
||||||
@@ -24,13 +27,18 @@ services:
|
|||||||
profiles: ["production", "plain", "fallback"]
|
profiles: ["production", "plain", "fallback"]
|
||||||
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
|
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
# sysctls нельзя с network_mode: host — включите ip_forward на VPS (см. docs/remote-speakers.md)
|
sysctls:
|
||||||
|
net.ipv4.ip_forward: "1"
|
||||||
|
net.ipv6.conf.all.forwarding: "1"
|
||||||
|
ports:
|
||||||
|
- "179:179/tcp"
|
||||||
volumes:
|
volumes:
|
||||||
- bird_etc:/etc/bird
|
- bird_etc:/etc/bird
|
||||||
- bird_run:/run/bird
|
- bird_run:/run/bird
|
||||||
|
networks:
|
||||||
|
- speaker-net
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|
||||||
evobgp-agent:
|
evobgp-agent:
|
||||||
|
|||||||
+36
-23
@@ -2,29 +2,38 @@
|
|||||||
|
|
||||||
## CI (Gitea Actions)
|
## 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 для локальной отладки.
|
||||||
|
|
||||||
| Было | Стало |
|
BuildKit кэширует `/go/pkg/mod`, `~/.cache/go-build` и pnpm store через `RUN --mount=type=cache`. На CI mounts живут, пока named builder `evobgp` не удаляют (`cleanup: false`).
|
||||||
|------|--------|
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
Кэш registry (переменные bake):
|
### Слои и runtime
|
||||||
|
|
||||||
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache` — **запись** только из target `go-build-all`
|
| Образ | Runtime base | Заметка |
|
||||||
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache` — **запись** только из target `web-build`
|
|-------|----------------|---------|
|
||||||
|
| 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 SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
export VERSION=dev
|
export VERSION=dev
|
||||||
export BUILD_TIME=
|
export BUILD_TIME=
|
||||||
sh write-bake-override.sh
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl go-images
|
||||||
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 web-images
|
||||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.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
|
```bash
|
||||||
docker build -f deploy/docker/gobinary/Dockerfile \
|
docker build -f deploy/docker/gobinary/Dockerfile \
|
||||||
--target build-all \
|
--target build-all \
|
||||||
--build-arg BIN=evobgp-api \
|
-t evobgp-build-all:local .
|
||||||
-t evobgp-api:local .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime-образы в bake ожидают stage `build-all` (через bake contexts), не отдельный `--build-arg BIN` на старый target `build`.
|
Runtime-образы в bake ожидают stage `build-all` (через bake contexts).
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
# BIRD из репозитория Ubuntu (Noble 24.04 LTS) — актуальнее пакета Debian bookworm.
|
# BIRD из репозитория Ubuntu (Noble 24.04 LTS) — актуальнее пакета Debian bookworm.
|
||||||
# Сборка из корня репозитория: docker build -f deploy/docker/bird2/Dockerfile .
|
# Stage bird2-base общий с evobgp-agent (одинаковые слои на speaker-VPS).
|
||||||
# Зеркало ECR Public вместо прямого pull с Docker Hub.
|
ARG BASE_UBUNTU=docker.io/library/ubuntu:noble
|
||||||
FROM public.ecr.aws/docker/library/ubuntu:noble
|
|
||||||
RUN apt-get update \
|
FROM ${BASE_UBUNTU} AS bird2-base
|
||||||
&& apt-get install -y --no-install-recommends bird2 \
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
--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
|
COPY deploy/bird/bird.conf /etc/bird/bird.conf
|
||||||
RUN mkdir -p /etc/bird/bird.d
|
RUN mkdir -p /etc/bird/bird.d
|
||||||
EXPOSE 179
|
EXPOSE 179
|
||||||
|
|||||||
+110
-27
@@ -1,7 +1,7 @@
|
|||||||
# Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl).
|
# Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl).
|
||||||
# context = "../.." — корень репозитория (относительно этого файла).
|
# context = "../.." — корень репо. Bake резолвит context от cwd (не от HCL),
|
||||||
# dockerfile — путь от корня репозитория (относительно context).
|
# поэтому вызов из deploy/docker/ либо BUILDX_BAKE_FILE_RELATIVE_PATHS=1.
|
||||||
# Переменные: REGISTRY, IMAGE_TAG, CACHE_REF_GO, CACHE_REF_WEB
|
# Переменные Bake читаются из окружения (см. docs.docker.com/build/bake/variables/).
|
||||||
|
|
||||||
variable "REGISTRY" {
|
variable "REGISTRY" {
|
||||||
default = "git.shx.one/evobgp"
|
default = "git.shx.one/evobgp"
|
||||||
@@ -35,14 +35,40 @@ variable "CACHE_REF_WEB" {
|
|||||||
default = ""
|
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" {
|
function "go-cache-from" {
|
||||||
params = []
|
params = []
|
||||||
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : []
|
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
# Экспорт кэша — только go-build-all / web-build (один writer на ref).
|
# Экспорт кэша — один writer на ref (параллельный cache-to в один ref ломает manifest).
|
||||||
# Несколько target с cache-to в один ref и mode=max дают гонку в registry
|
|
||||||
# (content descriptor not found при параллельном bake).
|
|
||||||
function "go-cache-to-export" {
|
function "go-cache-to-export" {
|
||||||
params = []
|
params = []
|
||||||
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO},mode=max"] : []
|
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"] : []
|
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" {
|
group "default" {
|
||||||
targets = ["go-images", "web-images", "evobgp-bird2"]
|
targets = ["go-images", "web-images", "evobgp-bird2"]
|
||||||
}
|
}
|
||||||
@@ -75,26 +111,38 @@ group "go-images" {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
# web-deps / web-build — только зависимости (contexts), без tags; при --push в группе не указывать.
|
|
||||||
group "web-images" {
|
group "web-images" {
|
||||||
targets = ["evobgp-web", "evobgp-web-all"]
|
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-образы ---
|
# --- Go: go mod download → все cmd/* → birdc (один раз) → runtime-образы ---
|
||||||
|
|
||||||
target "go-deps" {
|
target "go-deps" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "deps"
|
target = "deps"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
cache-from = go-cache-from()
|
cache-from = go-cache-from()
|
||||||
}
|
}
|
||||||
|
|
||||||
target "go-build-all" {
|
target "go-build-all" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "build-all"
|
target = "build-all"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
args = {
|
args = {
|
||||||
VERSION = VERSION
|
VERSION = VERSION
|
||||||
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
||||||
@@ -108,18 +156,19 @@ target "go-build-all" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "go-birdc" {
|
target "go-birdc" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "birdc"
|
target = "birdc"
|
||||||
platforms = ["linux/amd64"]
|
cache-from = birdc-cache-from()
|
||||||
cache-from = go-cache-from()
|
cache-to = birdc-cache-to-export()
|
||||||
}
|
}
|
||||||
|
|
||||||
target "_go-runtime" {
|
target "_go-runtime" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "runtime"
|
target = "runtime"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
build-all = "target:go-build-all"
|
build-all = "target:go-build-all"
|
||||||
}
|
}
|
||||||
@@ -127,15 +176,15 @@ target "_go-runtime" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "_go-runtime-birdc" {
|
target "_go-runtime-birdc" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "runtime-birdc"
|
target = "runtime-birdc"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
build-all = "target:go-build-all"
|
build-all = "target:go-build-all"
|
||||||
birdc = "target:go-birdc"
|
birdc = "target:go-birdc"
|
||||||
}
|
}
|
||||||
cache-from = go-cache-from()
|
cache-from = concat(go-cache-from(), birdc-cache-from())
|
||||||
}
|
}
|
||||||
|
|
||||||
function "image-tags" {
|
function "image-tags" {
|
||||||
@@ -187,43 +236,62 @@ target "evobgp-render" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-deploy" {
|
target "evobgp-deploy" {
|
||||||
inherits = ["_go-runtime"]
|
inherits = ["_go-runtime-birdc"]
|
||||||
args = { BIN = "evobgp-deploy" }
|
args = { BIN = "evobgp-deploy" }
|
||||||
tags = image-tags("evobgp-deploy")
|
tags = image-tags("evobgp-deploy")
|
||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-node" {
|
target "evobgp-node" {
|
||||||
inherits = ["_go-runtime"]
|
inherits = ["_go-runtime-birdc"]
|
||||||
args = { BIN = "evobgp-node" }
|
args = { BIN = "evobgp-node" }
|
||||||
tags = image-tags("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" {
|
target "evobgp-agent" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-agent/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-agent/Dockerfile"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
build-all = "target:go-build-all"
|
build-all = "target:go-build-all"
|
||||||
|
bird2-base = "target:evobgp-bird2-base"
|
||||||
}
|
}
|
||||||
cache-from = go-cache-from()
|
cache-from = go-cache-from()
|
||||||
tags = image-tags("evobgp-agent")
|
tags = image-tags("evobgp-agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Web: npm ci (кэш) → build → nginx ---
|
# --- Web ---
|
||||||
|
|
||||||
target "web-deps" {
|
target "web-deps" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
target = "deps"
|
target = "deps"
|
||||||
platforms = ["linux/amd64"]
|
args = {
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
BASE_NGINX = BASE_NGINX
|
||||||
|
}
|
||||||
cache-from = web-cache-from()
|
cache-from = web-cache-from()
|
||||||
}
|
}
|
||||||
|
|
||||||
target "web-build" {
|
target "web-build" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
target = "build"
|
target = "build"
|
||||||
platforms = ["linux/amd64"]
|
args = {
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
BASE_NGINX = BASE_NGINX
|
||||||
|
}
|
||||||
contexts = {
|
contexts = {
|
||||||
deps = "target:web-deps"
|
deps = "target:web-deps"
|
||||||
}
|
}
|
||||||
@@ -232,34 +300,49 @@ target "web-build" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-web" {
|
target "evobgp-web" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
target = "web"
|
target = "web"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
web-artifacts = "target:web-build"
|
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()
|
cache-from = web-cache-from()
|
||||||
tags = image-tags("evobgp-web")
|
tags = image-tags("evobgp-web")
|
||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-web-all" {
|
target "evobgp-web-all" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
target = "web"
|
target = "web"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
web-artifacts = "target:web-build"
|
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()
|
cache-from = web-cache-from()
|
||||||
tags = image-tags("evobgp-web-all")
|
tags = image-tags("evobgp-web-all")
|
||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-bird2" {
|
target "evobgp-bird2" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/bird2/Dockerfile"
|
dockerfile = "deploy/docker/bird2/Dockerfile"
|
||||||
platforms = ["linux/amd64"]
|
target = "bird2"
|
||||||
tags = image-tags("evobgp-bird2")
|
args = {
|
||||||
|
BASE_UBUNTU = BASE_UBUNTU
|
||||||
|
}
|
||||||
|
contexts = {
|
||||||
|
bird2-base = "target:evobgp-bird2-base"
|
||||||
|
}
|
||||||
|
tags = image-tags("evobgp-bird2")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# Агент: бинарь из общего build-all (docker-bake.hcl → contexts.build-all), bird2 из apt.
|
# Агент: бинарь из bake context build-all, runtime = тот же bird2-base, что evobgp-bird2.
|
||||||
FROM public.ecr.aws/docker/library/ubuntu:noble
|
FROM bird2-base
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends bird2 ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
ARG BIN=evobgp-agent
|
ARG BIN=evobgp-agent
|
||||||
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp-agent
|
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp-agent
|
||||||
WORKDIR /etc/bird
|
WORKDIR /etc/bird
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# React + Vite статическая панель EvoBGP + nginx.
|
# React + Vite статическая панель EvoBGP + nginx.
|
||||||
# Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build).
|
# Финальный 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
|
WORKDIR /repo
|
||||||
RUN corepack enable && corepack prepare [email protected] --activate
|
RUN corepack enable && corepack prepare [email protected] --activate
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
@@ -16,11 +17,13 @@ FROM deps AS build
|
|||||||
COPY tsconfig.base.json ./
|
COPY tsconfig.base.json ./
|
||||||
COPY apps/web/ ./apps/web/
|
COPY apps/web/ ./apps/web/
|
||||||
COPY packages/ui/ ./packages/ui/
|
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
|
ARG EVOBGP_UPSTREAM=evobgp-api
|
||||||
COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf
|
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 \
|
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
|
COPY --from=web-artifacts /repo/apps/web/dist /usr/share/nginx/html
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 256;
|
||||||
|
|
||||||
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
|
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
|
||||||
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
|
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# Универсальная сборка бинарей cmd/* (ARG BIN) или всех сразу (target build-all).
|
# Универсальная сборка бинарей 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
|
# 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
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
FROM deps AS build-all
|
FROM deps AS build-all
|
||||||
COPY . .
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG GIT_SHA=unknown
|
ARG GIT_SHA=unknown
|
||||||
ARG BUILD_TIME=
|
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 \
|
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||||
set -eux; \
|
set -eux; \
|
||||||
mkdir -p /out; \
|
mkdir -p /out; \
|
||||||
@@ -29,12 +33,12 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
|||||||
|
|
||||||
# Один бинарь (локальная сборка); в CI — build-all + runtime.
|
# Один бинарь (локальная сборка); в CI — build-all + runtime.
|
||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
COPY . .
|
|
||||||
ARG BIN=evobgp-api
|
ARG BIN=evobgp-api
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG GIT_SHA=unknown
|
ARG GIT_SHA=unknown
|
||||||
ARG BUILD_TIME=
|
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 \
|
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||||
CGO_ENABLED=0 go build -trimpath \
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
-ldflags="-s -w \
|
-ldflags="-s -w \
|
||||||
@@ -43,29 +47,35 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
|||||||
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
|
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
|
||||||
-o /out/evobgp "./cmd/${BIN}"
|
-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
|
ARG BIRD_VERSION=2.14
|
||||||
COPY deploy/docker/bird/bird-from-source.sh /tmp/bird-from-source.sh
|
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 \
|
&& BIRD_VERSION="${BIRD_VERSION}" /tmp/bird-from-source.sh \
|
||||||
&& rm -f /tmp/bird-from-source.sh
|
&& rm -f /tmp/bird-from-source.sh
|
||||||
|
|
||||||
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS runtime-base
|
# scheduler / ingest / render — static Go, без shell.
|
||||||
RUN apt-get update \
|
FROM ${BASE_DISTROLESS} AS runtime
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
FROM runtime-base AS runtime
|
|
||||||
ARG BIN=evobgp-api
|
ARG BIN=evobgp-api
|
||||||
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
|
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
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/usr/local/bin/evobgp"]
|
ENTRYPOINT ["/usr/local/bin/evobgp"]
|
||||||
|
|
||||||
FROM runtime AS runtime-birdc
|
# api / all / deploy / node — bird -p (parse-check) + birdc configure.
|
||||||
# birdc: динамическая линковка readline + ncurses (debian bookworm).
|
# Демон BIRD в этом контейнере не запускается; процесс bird — в образе evobgp-bird2.
|
||||||
RUN apt-get update \
|
FROM ${BASE_DEBIAN} AS runtime-birdc
|
||||||
&& apt-get install -y --no-install-recommends libreadline8 libncurses6 \
|
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/*
|
&& 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"]
|
||||||
|
|||||||
@@ -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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user