Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5255cd2d30 | ||
|
|
3723ba7ed1 | ||
|
|
c5148ac4a0 | ||
|
|
e6e319a275 | ||
|
|
869b13cb57 | ||
|
|
e190785d4f | ||
|
|
3fd05ff833 | ||
|
|
e7f24f0be4 | ||
|
|
1bfe460e4b | ||
|
|
e55d2c5aba | ||
|
|
f63e9b5fd0 | ||
|
|
0148d4ca37 | ||
|
|
e0ecabb22f | ||
|
|
da301b1a94 | ||
|
|
653bc6cc91 | ||
|
|
e0d695f2a4 | ||
|
|
dfc33dcb99 | ||
|
|
44f70443c3 | ||
|
|
357ed4ce7b | ||
|
|
35e262b5ae | ||
|
|
8267141136 |
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ alwaysApply: true
|
||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||
|------------|-------------|------------------|-------|
|
||||
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `npx @redocly/cli lint` |
|
||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `pnpm exec redocly lint` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ alwaysApply: true
|
||||
## Dependency Management
|
||||
|
||||
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
|
||||
*Проверка:* `go.mod`, `.gitea/workflows/ci.yaml`.
|
||||
*Проверка:* `go.mod`, `.gitea/workflows/quality.yaml`.
|
||||
|
||||
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
|
||||
*Проверка:* review.
|
||||
@@ -123,10 +123,10 @@ alwaysApply: true
|
||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||
*Проверка:* CI job `bird2`.
|
||||
|
||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`.
|
||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/quality.yaml`.
|
||||
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
||||
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `pnpm exec redocly lint docs/openapi.yaml`.
|
||||
*Проверка:* CI job `openapi`.
|
||||
|
||||
---
|
||||
@@ -229,7 +229,7 @@ alwaysApply: true
|
||||
```powershell
|
||||
go vet ./...
|
||||
go test ./... -race -count=1
|
||||
npx @redocly/cli lint docs/openapi.yaml
|
||||
pnpm exec redocly lint docs/openapi.yaml
|
||||
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
||||
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||
|
||||
@@ -6,18 +6,18 @@ alwaysApply: false
|
||||
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -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
|
||||
+47
-22
@@ -1,57 +1,82 @@
|
||||
# Gitea Actions
|
||||
|
||||
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
||||
|
||||
| Job | PR | push в main |
|
||||
|-----|-----|-------------|
|
||||
| changes, openapi, web, go, bird2 | quality gates | quality gates |
|
||||
| commitlint | да | — |
|
||||
| **release** | — | semantic-release + docker push (один run) |
|
||||
| Workflow | Когда | Что |
|
||||
|----------|--------|-----|
|
||||
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
|
||||
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, go, bird2, commitlint, docker-check |
|
||||
|
||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||
|
||||
## CI (quality gates)
|
||||
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` → `go` / `openapi` и т.д. (см. `ci.yaml`).
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/api.md` / `docs/access.md` / `docs/openapi.yaml`) quality jobs не запускают.
|
||||
|
||||
На **pull request** — **commitlint** (Conventional Commits).
|
||||
На **pull request** — **commitlint**. При изменении `deploy/docker/**` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||
|
||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
|
||||
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`, `go env GOMODCACHE` / `GOCACHE`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||
|
||||
## Release (job в ci.yaml)
|
||||
Кэшируется целиком: pnpm store + `node_modules` + corepack; Go modules + GOCACHE + `golangci-lint` в `GOBIN`. При hit: `pnpm install --offline`, `go mod download` без сети. `setup-go cache:` и `golangci-lint-action` не используем — они завязаны на `hashFiles`.
|
||||
|
||||
После успешных quality gates на **push в main** job **release**:
|
||||
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||
|
||||
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR) и **publish** (CD).
|
||||
|
||||
## CD (job publish)
|
||||
|
||||
После успешных quality gates на **push в main** job **publish**:
|
||||
|
||||
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
|
||||
3. Зеркало base-образов в `evobgp-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evobgp` (`cleanup: false`).
|
||||
|
||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||
|
||||
Повтор упавшего **release** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-release).
|
||||
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||
|
||||
### Секреты
|
||||
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||
|
||||
### Теги образов
|
||||
|
||||
```text
|
||||
git.shts.su/<owner>/<имя>:latest
|
||||
git.shts.su/<owner>/<имя>:v1.2.3
|
||||
git.shts.su/<owner>/<имя>:1.2.3
|
||||
git.shts.su/<owner>/<имя>:<short-sha>
|
||||
git.shts.su/<owner>/<имя>:sha-<full-sha>
|
||||
git.shx.one/<owner>/<имя>:latest
|
||||
git.shx.one/<owner>/<имя>:v1.2.3
|
||||
git.shx.one/<owner>/<имя>:1.2.3
|
||||
git.shx.one/<owner>/<имя>:<short-sha>
|
||||
git.shx.one/<owner>/<имя>:sha-<full-sha>
|
||||
```
|
||||
|
||||
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
||||
|
||||
Кэш сборки: `evobgp-buildcache:{go,web,birdc}-buildcache` и `evobgp-buildcache:base-*`.
|
||||
|
||||
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
||||
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
docker pull git.shts.su/myuser/evobgp-api:1.2.3
|
||||
docker pull git.shx.one/myuser/evobgp-api:1.2.3
|
||||
```
|
||||
|
||||
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
||||
|
||||
## act_runner: cache server
|
||||
|
||||
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||
|
||||
В `config.yaml` runner:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
enabled: true
|
||||
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||
port: 8088
|
||||
```
|
||||
|
||||
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||
|
||||
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||
|
||||
@@ -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
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
||||
# ---------------------------------------------------------------------------
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
openapi: ${{ steps.detect.outputs.openapi }}
|
||||
go: ${{ steps.detect.outputs.go }}
|
||||
web: ${{ steps.detect.outputs.web }}
|
||||
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
||||
docker_go: ${{ steps.detect.outputs.docker_go }}
|
||||
docker_web: ${{ steps.detect.outputs.docker_web }}
|
||||
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: detect
|
||||
name: Detect changed paths per module
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
openapi=false
|
||||
go=false
|
||||
web=false
|
||||
bird_conf=false
|
||||
docker_go=false
|
||||
docker_web=false
|
||||
docker_bird=false
|
||||
|
||||
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
|
||||
set_all_flags_true() {
|
||||
openapi=true
|
||||
go=true
|
||||
web=true
|
||||
bird_conf=true
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
FILES="$(git diff --name-only "$base" "$head")"
|
||||
else
|
||||
before="${{ github.event.before }}"
|
||||
after="${{ github.sha }}"
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
FILES="$(git diff --name-only "$before" "$after")"
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
# CI/CD инфраструктура — все узлы quality gates
|
||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
docs/openapi.yaml|redocly.yaml)
|
||||
openapi=true
|
||||
;;
|
||||
docs/api.md|docs/access.md)
|
||||
openapi=true
|
||||
go=true
|
||||
;;
|
||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||
;;
|
||||
apps/web/*|packages/ui/*|packages/shared/*)
|
||||
web=true
|
||||
;;
|
||||
deploy/bird/*)
|
||||
bird_conf=true
|
||||
go=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*)
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
go=true
|
||||
;;
|
||||
go.mod|go.sum|go.work)
|
||||
go=true
|
||||
;;
|
||||
migrations/*)
|
||||
go=true
|
||||
;;
|
||||
cmd/*|internal/*|*.go)
|
||||
go=true
|
||||
bird_conf=true
|
||||
;;
|
||||
docs/*)
|
||||
go=true
|
||||
;;
|
||||
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
go=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
openapi:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: Lint OpenAPI (Redocly)
|
||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
||||
- name: Check OpenAPI→TS codegen is fresh
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
chmod +x scripts/check-openapi-gen.sh
|
||||
sh scripts/check-openapi-gen.sh
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
web:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: pnpm install, typecheck, lint, test, build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run test
|
||||
pnpm --filter @evobgp/web run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
go:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||
run: sh scripts/lint-httpapi.sh
|
||||
- name: Check migration pairs (DEP-03)
|
||||
run: sh scripts/check-migrations-pair.sh
|
||||
- name: Validate remote speaker compose
|
||||
run: sh scripts/validate-remote-speaker-compose.sh
|
||||
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.64.8
|
||||
install-mode: goinstall
|
||||
- name: Test
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
out="${RUNNER_TEMP}/evobgp-bin"
|
||||
mkdir -p "$out"
|
||||
for d in cmd/*/; do
|
||||
name="$(basename "$d")"
|
||||
go build -o "$out/$name" "./$d"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
bird2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, go]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.changes.result == 'success' &&
|
||||
needs.go.result != 'failure' &&
|
||||
(needs.changes.outputs.go == 'true' ||
|
||||
needs.changes.outputs.bird_conf == 'true' ||
|
||||
needs.changes.outputs.docker_bird == 'true' ||
|
||||
needs.changes.outputs.docker_go == 'true')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
||||
$SUDO apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
||||
bird --version
|
||||
- name: bird -p on all scenario bird.conf files
|
||||
env:
|
||||
WORKSPACE: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
WS="${WORKSPACE:-$PWD}"
|
||||
cd "$WS"
|
||||
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
||||
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
||||
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
||||
exit 1
|
||||
fi
|
||||
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
||||
echo "==> $conf"
|
||||
bird -c "$WS/$conf" -p
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
commitlint:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- name: Lint commit messages
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
npm ci
|
||||
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
|
||||
# ---------------------------------------------------------------------------
|
||||
release:
|
||||
needs: [changes, openapi, web, go, bird2]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||
needs.changes.result == 'success' &&
|
||||
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
|
||||
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
|
||||
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
|
||||
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
persist-credentials: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- name: Install release tooling
|
||||
run: npm ci
|
||||
- name: Verify releasable commit messages
|
||||
run: node scripts/commit/verify-release-commits.mjs
|
||||
- name: Semantic release
|
||||
run: npx semantic-release
|
||||
env:
|
||||
GITEA_URL: https://git.shts.su
|
||||
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.shts.su
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shts.su/${{ 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.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
cd "${{ github.workspace }}/deploy/docker"
|
||||
sh write-bake-override.sh
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||
-f docker-bake.hcl -f docker-bake.override.hcl default --push
|
||||
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: true
|
||||
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
@@ -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 *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@
|
||||
[
|
||||
"@markwylde/semantic-release-gitea",
|
||||
{
|
||||
"giteaUrl": "https://git.shts.su",
|
||||
"giteaUrl": "https://git.shx.one",
|
||||
"assets": [
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
|
||||
@@ -22,8 +22,8 @@ Control plane для управления префиксами, модулями
|
||||
|
||||
```powershell
|
||||
cd deploy\compose
|
||||
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shts.su/<owner>
|
||||
docker login git.shts.su
|
||||
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shx.one/<owner>
|
||||
docker login git.shx.one
|
||||
docker compose --profile microvps pull
|
||||
docker compose --profile microvps up -d
|
||||
```
|
||||
@@ -50,6 +50,6 @@ docker compose --profile reference up -d
|
||||
|
||||
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
||||
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
|
||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CD run**, что и релиз (job `publish`)
|
||||
|
||||
Лицензия и условия использования — по политике владельца репозитория.
|
||||
|
||||
@@ -49,7 +49,8 @@ export function AccessApiKeysCard({
|
||||
<>
|
||||
<FrameDataGrid
|
||||
title="API-ключи"
|
||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
||||
description="Полный токен показывается только при создании и ротации."
|
||||
className="min-w-0"
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { ApiKey } from '@/types/api'
|
||||
|
||||
@@ -34,7 +35,14 @@ export function AccessApiKeysGrid({
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.name}
|
||||
accent="primary"
|
||||
subtitle={`${row.original.prefix}…`}
|
||||
/>
|
||||
),
|
||||
minSize: 160,
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
@@ -45,14 +53,6 @@ export function AccessApiKeysGrid({
|
||||
),
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'prefix',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}…</span>
|
||||
),
|
||||
meta: { headerTitle: 'Префикс' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
@@ -77,11 +77,11 @@ export function AccessApiKeysGrid({
|
||||
{
|
||||
id: 'last_used_at',
|
||||
accessorFn: (row) => row.last_used_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Последнее использование' },
|
||||
meta: { headerTitle: 'Использован' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -90,7 +90,7 @@ export function AccessApiKeysGrid({
|
||||
cell: ({ row }) => {
|
||||
const k = row.original
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
@@ -149,6 +149,7 @@ export function AccessApiKeysGrid({
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ключей"
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск API-ключей…"
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@evobgp/ui/components/chart'
|
||||
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)}>
|
||||
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="count"
|
||||
|
||||
@@ -27,10 +27,10 @@ export function MonitoringHealthCard({
|
||||
return (
|
||||
<DonutBreakdownCard
|
||||
title="Доступность системы"
|
||||
description="GET /v1/health · GET /v1/ready"
|
||||
description="Проверки живучести и готовности"
|
||||
slices={slices}
|
||||
centerLabel="Проверки"
|
||||
badge={healthOk ? 'API OK' : undefined}
|
||||
badge={healthOk ? 'API в норме' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,18 +40,18 @@ export function NetworkOverviewAnalyticsCard({
|
||||
/>
|
||||
<SegmentedProgressCard
|
||||
title="Спикеры"
|
||||
description="Доступность live-агентов"
|
||||
description="Доступность агентов"
|
||||
primary={{
|
||||
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
label: 'Online',
|
||||
label: 'В сети',
|
||||
percent: speakersPct,
|
||||
}}
|
||||
secondary={{
|
||||
value: net.speakersTotal - net.speakersOnline,
|
||||
label: 'Offline',
|
||||
label: 'Не в сети',
|
||||
percent: 100 - speakersPct,
|
||||
}}
|
||||
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -41,14 +41,17 @@ export function BadgeTabs({
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
orientation="horizontal"
|
||||
className={cn('w-full', className)}
|
||||
className={cn('w-full min-w-0', className)}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
|
||||
className={cn(
|
||||
'mb-3.5 w-full min-w-0 justify-start gap-4',
|
||||
listClassName,
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
||||
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{item.count !== undefined ? (
|
||||
|
||||
@@ -50,7 +50,7 @@ export function DashboardActivityTimeline({
|
||||
<DashboardFramePanel
|
||||
title="Недавняя активность"
|
||||
description="Задачи, ревизии и сетевые события"
|
||||
className="h-full"
|
||||
className="h-full min-w-0"
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
||||
@@ -82,7 +82,7 @@ export function DashboardActivityTimeline({
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="min-w-0 pb-1 text-foreground">
|
||||
<TimelineTitle className="text-sm leading-snug font-normal">
|
||||
<TimelineTitle className="text-sm leading-snug font-normal break-words">
|
||||
{item.message}
|
||||
</TimelineTitle>
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -82,7 +82,7 @@ function buildKpis({
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||
label: 'Пиры Established',
|
||||
label: 'Пиры установлены',
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
||||
@@ -94,13 +94,13 @@ function buildKpis({
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
label: 'Спикеры online',
|
||||
label: 'Спикеры в сети',
|
||||
footer: (
|
||||
<Badge
|
||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'live-снимок'}
|
||||
{loading ? '…' : 'в сети'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ function buildMetrics({
|
||||
{
|
||||
id: 'bgp',
|
||||
title: 'BGP готовность',
|
||||
label: 'Established / включённые',
|
||||
label: 'Установлено / включено',
|
||||
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
|
||||
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
|
||||
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
|
||||
@@ -62,13 +62,13 @@ function buildMetrics({
|
||||
{
|
||||
id: 'speakers',
|
||||
title: 'Спикеры',
|
||||
label: 'Online / всего',
|
||||
label: 'В сети / всего',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
delta:
|
||||
loading || network.speakersTotal === 0
|
||||
? '…'
|
||||
: network.speakersOnline === network.speakersTotal
|
||||
? 'все online'
|
||||
? 'все в сети'
|
||||
: 'частично',
|
||||
deltaVariant:
|
||||
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
import {
|
||||
@@ -51,20 +52,19 @@ import {
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at'
|
||||
type ModuleSort = 'name' | 'type' | 'priority'
|
||||
type EnabledFilter = 'all' | 'enabled' | 'disabled'
|
||||
|
||||
const sortLabels: Record<ModuleSort, string> = {
|
||||
name: 'Название',
|
||||
type: 'Тип',
|
||||
priority: 'Приоритет',
|
||||
last_refreshed_at: 'Обновлено',
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
|
||||
|
||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
||||
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }]
|
||||
return [{ id: sortBy, desc: false }]
|
||||
}
|
||||
|
||||
export function DashboardModulesGrid({
|
||||
@@ -119,9 +119,10 @@ export function DashboardModulesGrid({
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
||||
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" />
|
||||
<DataGridPrimaryCell title={row.original.name} accent="primary" />
|
||||
</div>
|
||||
),
|
||||
minSize: 180,
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
@@ -138,30 +139,9 @@ export function DashboardModulesGrid({
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
|
||||
),
|
||||
size: 88,
|
||||
meta: { headerTitle: 'Приоритет' },
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.last_refreshed_at
|
||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
sortingFn: (a, b) =>
|
||||
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
@@ -185,8 +165,9 @@ export function DashboardModulesGrid({
|
||||
<DashboardFramePanel
|
||||
title="Модули"
|
||||
description="Поиск, сортировка и быстрый переход к настройке"
|
||||
className="min-w-0"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
|
||||
<PlusIcon />
|
||||
Создать
|
||||
</Button>
|
||||
@@ -204,14 +185,14 @@ export function DashboardModulesGrid({
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
width: 'fixed',
|
||||
width: 'auto',
|
||||
}}
|
||||
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<InputGroup className="w-full min-w-40 sm:max-w-xs">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
|
||||
<InputGroup className="w-full min-w-0 @3xl:max-w-xs">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
|
||||
</InputGroupAddon>
|
||||
@@ -337,8 +318,8 @@ export function DashboardModulesGrid({
|
||||
<div className="border-t px-4 py-3">
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridPagination
|
||||
{...DATA_GRID_PAGINATION_RU}
|
||||
sizes={[10, 15, 20]}
|
||||
info="{from}–{to} из {count}"
|
||||
className="py-0"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -57,7 +57,7 @@ export function DashboardNetworkHealth({
|
||||
}
|
||||
primary={{
|
||||
value: loading ? '—' : `${utilization}%`,
|
||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры online',
|
||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
|
||||
percent: loading ? 0 : utilization,
|
||||
badge: (
|
||||
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
|
||||
@@ -67,7 +67,7 @@ export function DashboardNetworkHealth({
|
||||
}}
|
||||
secondary={{
|
||||
value: loading ? '—' : offline,
|
||||
label: mode === 'peers' ? 'Не Established' : 'Offline',
|
||||
label: mode === 'peers' ? 'Не установлены' : 'Не в сети',
|
||||
percent: total > 0 ? Math.round((offline / total) * 100) : 0,
|
||||
}}
|
||||
footer={
|
||||
|
||||
@@ -3,13 +3,16 @@ import { useMemo, useState } from 'react'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, ModuleRow } from '@/types/api'
|
||||
|
||||
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({
|
||||
jobs,
|
||||
modules,
|
||||
@@ -32,7 +35,7 @@ export function DashboardOperationsBreakdown({
|
||||
return (
|
||||
<PanelCard
|
||||
title="Поток операций"
|
||||
description="Распределение задач и типов модулей"
|
||||
description="Доли статусов задач или типов модулей"
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
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 ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
||||
)}
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'lookup',
|
||||
title: 'Проверка IP/домена',
|
||||
description: 'Membership в списках и community (entry + snapshot).',
|
||||
description: 'Проверка IP в списках и BGP community.',
|
||||
to: '/lookup',
|
||||
icon: <Search aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
@@ -16,14 +16,15 @@ const ACTIONS: QuickActionItem[] = [
|
||||
id: 'new-module',
|
||||
title: 'Создать модуль',
|
||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||
to: '/modules/new',
|
||||
to: '/modules',
|
||||
search: { create: true },
|
||||
icon: <Plus aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
{
|
||||
id: 'communities',
|
||||
title: 'BGP-сообщества',
|
||||
description: 'Справочник communities для политик экспорта.',
|
||||
title: 'BGP community',
|
||||
description: 'Справочник BGP community для политик экспорта.',
|
||||
to: '/directories',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
@@ -31,7 +32,7 @@ const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'network',
|
||||
title: 'Сеть',
|
||||
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
||||
description: 'Обзор пиров, спикеров и живых BGP-сессий.',
|
||||
to: '/network',
|
||||
search: { tab: 'overview' },
|
||||
icon: <Network aria-hidden />,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
@@ -29,7 +29,6 @@ export function DashboardRecentJobsGrid({
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={jobKindRu(row.original.kind)}
|
||||
accent="mono"
|
||||
subtitle={
|
||||
row.original.meta?.module_id
|
||||
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
||||
@@ -49,7 +48,7 @@ export function DashboardRecentJobsGrid({
|
||||
[nameById],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
const { table, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => {
|
||||
@@ -63,16 +62,13 @@ export function DashboardRecentJobsGrid({
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
showPagination={false}
|
||||
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 { 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 { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
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,
|
||||
columns,
|
||||
getSearchText: (row) => row.id,
|
||||
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ревизий"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск ревизий…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function DataGridShell<TData extends object>({
|
||||
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||
tableLayout={tableLayout}
|
||||
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
|
||||
className={className}
|
||||
className={cn('min-w-0', className)}
|
||||
onRowClick={onRowClick}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function CommunityFormDialog({
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={editTarget ? 'Редактировать сообщество' : 'Новое сообщество BGP'}
|
||||
title={editTarget ? 'Редактировать community' : 'Новое BGP community'}
|
||||
description="Тег для префиксов в фильтрах BIRD"
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
|
||||
@@ -73,10 +73,10 @@ export function DirectoriesCommunitiesGrid({
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет сообществ"
|
||||
emptyMessage="Нет community"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск сообществ…"
|
||||
searchPlaceholder="Поиск community…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function DohProfileFormDialog({
|
||||
if (timeoutMs.trim() !== '') {
|
||||
const ms = Number(timeoutMs)
|
||||
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
|
||||
toast.error('Timeout должен быть целым числом > 0')
|
||||
toast.error('Таймаут должен быть целым числом больше 0')
|
||||
return
|
||||
}
|
||||
timeout = ms
|
||||
@@ -136,7 +136,7 @@ export function DohProfileFormDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-timeout">Timeout, мс (опционально)</Label>
|
||||
<Label htmlFor="doh-timeout">Таймаут, мс (необязательно)</Label>
|
||||
<Input
|
||||
id="doh-timeout"
|
||||
type="number"
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Settings,
|
||||
BookText,
|
||||
KeyRound,
|
||||
ServerCog,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -72,7 +71,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
to: '/dashboard',
|
||||
label: 'Панель',
|
||||
icon: LayoutDashboard,
|
||||
description: 'KPI, модули и активность',
|
||||
description: 'Метрики, модули и активность',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -82,23 +81,22 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
||||
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
||||
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
||||
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
|
||||
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Операции',
|
||||
items: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Система',
|
||||
items: [
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
|
||||
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -91,7 +91,7 @@ export function AppsMenu() {
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настройки
|
||||
|
||||
@@ -136,7 +136,7 @@ export function NavUser() {
|
||||
setApiToken(null)
|
||||
if (typeof window !== 'undefined') {
|
||||
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>
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||
>
|
||||
<SettingsIcon aria-hidden />
|
||||
Настройки UI
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
{authOn ? (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -94,8 +94,8 @@ export function SystemMonitorPopover() {
|
||||
() => [
|
||||
{
|
||||
id: 'api',
|
||||
label: 'API health',
|
||||
value: healthOk ? 'OK' : '—',
|
||||
label: 'Состояние API',
|
||||
value: healthOk ? 'норма' : '—',
|
||||
unit: '',
|
||||
percent: healthOk ? 100 : 0,
|
||||
icon: <HeartPulse aria-hidden />,
|
||||
|
||||
@@ -128,7 +128,7 @@ export function LookupAddStep({
|
||||
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
||||
<AlertDescription>
|
||||
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
|
||||
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
|
||||
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules" search={{ create: true }} />}>
|
||||
Перейти к модулям
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
|
||||
@@ -14,8 +14,8 @@ import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
|
||||
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
|
||||
|
||||
const CDN_KIND_ITEMS = [
|
||||
{ value: 'plaintext', label: 'plaintext' },
|
||||
{ value: 'json', label: 'json' },
|
||||
{ value: 'plaintext', label: 'Текст' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
] as const
|
||||
|
||||
interface ModuleCdnSourceDialogProps {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { dohProfileShortLabel } from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
|
||||
import { useCreateModuleMutation } from '@/queries/modules'
|
||||
import type {
|
||||
BgpCommunity,
|
||||
DohProfile,
|
||||
DohResolverPolicy,
|
||||
ModuleCreate,
|
||||
ModuleRow,
|
||||
ModuleType,
|
||||
} from '@/types/api'
|
||||
|
||||
interface ModuleCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
communities: BgpCommunity[]
|
||||
dohProfiles: DohProfile[]
|
||||
onCreated?: (mod: ModuleRow) => void
|
||||
}
|
||||
|
||||
/** @see https://reui.io/preview/base/form-7 */
|
||||
/** @see https://reui.io/preview/base/sheet-8 */
|
||||
|
||||
const MODULE_TYPE_ITEMS: { value: ModuleType; label: string }[] = [
|
||||
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') },
|
||||
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
|
||||
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
|
||||
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
|
||||
]
|
||||
|
||||
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
|
||||
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
|
||||
{ value: 'failover', label: dohPolicyRu('failover') },
|
||||
{ value: 'union', label: dohPolicyRu('union') },
|
||||
]
|
||||
|
||||
export function ModuleCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
communities,
|
||||
dohProfiles,
|
||||
onCreated,
|
||||
}: ModuleCreateDialogProps) {
|
||||
const createMutation = useCreateModuleMutation()
|
||||
|
||||
const [type, setType] = useState<ModuleType>('IP_RANGES')
|
||||
const [name, setName] = useState('')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [priority, setPriority] = useState('0')
|
||||
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
|
||||
const [cronExpr, setCronExpr] = useState('')
|
||||
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
|
||||
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
|
||||
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
|
||||
|
||||
const isDomains = type === 'DOMAINS'
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setType('IP_RANGES')
|
||||
setName('')
|
||||
setEnabled(true)
|
||||
setPriority('0')
|
||||
setRefreshIntervalSec('')
|
||||
setCronExpr('')
|
||||
setDefaultCommunityId(null)
|
||||
setDohResolverPolicy('primary_only')
|
||||
setDohProfileIds([])
|
||||
}, [open])
|
||||
|
||||
function toggleDohProfile(id: string, checked: boolean) {
|
||||
setDohProfileIds((prev) => {
|
||||
if (checked) {
|
||||
if (prev.includes(id)) return prev
|
||||
return [...prev, id]
|
||||
}
|
||||
return prev.filter((x) => x !== id)
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
toast.error('Укажите название модуля')
|
||||
return
|
||||
}
|
||||
|
||||
const priorityNum = Number(priority)
|
||||
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
|
||||
toast.error('Приоритет должен быть целым числом')
|
||||
return
|
||||
}
|
||||
|
||||
let refresh: number | undefined
|
||||
if (refreshIntervalSec.trim() !== '') {
|
||||
const n = Number(refreshIntervalSec)
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
toast.error('Интервал обновления должен быть целым числом ≥ 0')
|
||||
return
|
||||
}
|
||||
refresh = n
|
||||
}
|
||||
|
||||
const body: ModuleCreate = {
|
||||
type,
|
||||
name: trimmedName,
|
||||
enabled,
|
||||
priority: priorityNum,
|
||||
}
|
||||
if (refresh !== undefined) {
|
||||
body.refresh_interval_sec = refresh
|
||||
}
|
||||
const cron = cronExpr.trim()
|
||||
if (cron) {
|
||||
body.cron_expr = cron
|
||||
}
|
||||
if (defaultCommunityId) {
|
||||
body.default_community_id = defaultCommunityId
|
||||
}
|
||||
if (isDomains) {
|
||||
body.doh_resolver_policy = dohResolverPolicy
|
||||
body.doh_profile_ids = dohProfileIds
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createMutation.mutateAsync(body)
|
||||
onOpenChange(false)
|
||||
onCreated?.(created)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новый модуль"
|
||||
description="Тип задаётся один раз. Записи добавляются на карточке модуля."
|
||||
className="sm:max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
id="mod-create-type"
|
||||
label="Тип"
|
||||
items={MODULE_TYPE_ITEMS}
|
||||
value={type}
|
||||
onValueChange={(v) => {
|
||||
if (v) setType(v)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-name">Название</Label>
|
||||
<Input
|
||||
id="mod-create-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Имя модуля"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<Label htmlFor="mod-create-enabled">Включён</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выключенный модуль не участвует в обновлении и применении.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="mod-create-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-priority">Приоритет</Label>
|
||||
<Input
|
||||
id="mod-create-priority"
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
|
||||
<Input
|
||||
id="mod-create-interval"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="пусто = по умолчанию"
|
||||
value={refreshIntervalSec}
|
||||
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
|
||||
<Input
|
||||
id="mod-create-cron"
|
||||
placeholder="0 * * * *"
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CommunitySelect
|
||||
id="mod-create-community"
|
||||
label="Community по умолчанию"
|
||||
value={defaultCommunityId}
|
||||
onValueChange={setDefaultCommunityId}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
|
||||
{isDomains ? (
|
||||
<>
|
||||
<SelectField
|
||||
id="mod-create-doh-policy"
|
||||
label="Политика DoH"
|
||||
items={DOH_POLICY_ITEMS}
|
||||
value={dohResolverPolicy}
|
||||
onValueChange={(v) => {
|
||||
if (v) setDohResolverPolicy(v)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>DoH профили</Label>
|
||||
{dohProfiles.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
{dohProfiles.map((p) => {
|
||||
const checked = dohProfileIds.includes(p.id)
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
htmlFor={`mod-create-doh-${p.id}`}
|
||||
className="flex cursor-pointer items-start gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={`mod-create-doh-${p.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{dohProfileShortLabel(p.id, dohProfiles)}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs" title={p.url}>
|
||||
{p.url}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ export function ModuleEditDialog({
|
||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<Label htmlFor="mod-enabled">Включён</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выключенный модуль не участвует в refresh и apply.
|
||||
Выключенный модуль не участвует в обновлении и применении.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { formatDateTime } from '@/lib/modules/display'
|
||||
import { communityLabel } from '@/lib/modules/helpers'
|
||||
import { cdnSourceKindRu } from '@/lib/ui-labels'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type {
|
||||
AsEntry,
|
||||
@@ -145,7 +146,7 @@ export function ModuleEntriesGrid({
|
||||
accessorKey: 'source_kind',
|
||||
header: 'Тип',
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<CategoryBadge>{row.original.source_kind}</CategoryBadge>
|
||||
<CategoryBadge>{cdnSourceKindRu(row.original.source_kind)}</CategoryBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@ import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
|
||||
function speakerLabel(s: SpeakerRow): string {
|
||||
if (s.role === 'master') {
|
||||
const host = s.agent_domain ?? s.endpoint
|
||||
return host ? `CP · ${host}` : 'CP (master)'
|
||||
return host ? `Плоскость · ${host}` : 'Плоскость управления'
|
||||
}
|
||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||
}
|
||||
@@ -51,7 +51,7 @@ const filterFields: FilterFieldConfig[] = [
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-48',
|
||||
placeholder: 'IP или Neighbor ID…',
|
||||
placeholder: 'IP или ID соседа…',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -94,7 +94,7 @@ export function NetworkDiscoveredPeersCard({
|
||||
id: 'neighbor_id',
|
||||
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Neighbor ID" />
|
||||
<DataGridColumnHeader column={column} title="ID соседа" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
@@ -103,7 +103,7 @@ export function NetworkDiscoveredPeersCard({
|
||||
accent="mono"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Neighbor ID' },
|
||||
meta: { headerTitle: 'ID соседа' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'remote_asn',
|
||||
@@ -212,7 +212,7 @@ export function NetworkDiscoveredPeersCard({
|
||||
title="Одобрить пира"
|
||||
description={
|
||||
approveTarget
|
||||
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
||||
? `ID соседа ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
||||
: undefined
|
||||
}
|
||||
className="sm:max-w-sm"
|
||||
|
||||
@@ -30,7 +30,7 @@ export function NetworkKpi({
|
||||
id: 'peers-established',
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
label: 'Пиры Established',
|
||||
label: 'Пиры установлены',
|
||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
@@ -44,7 +44,7 @@ export function NetworkKpi({
|
||||
id: 'speakers-online',
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Спикеры online',
|
||||
label: 'Спикеры в сети',
|
||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
footer: (
|
||||
<Badge
|
||||
@@ -55,7 +55,7 @@ export function NetworkKpi({
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'live'}
|
||||
{loading ? '…' : 'в сети'}
|
||||
</Badge>
|
||||
),
|
||||
to: '/network',
|
||||
|
||||
@@ -32,18 +32,18 @@ function peerDeleteLabel(p: PeerRow): string {
|
||||
|
||||
const PEER_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'established', label: 'Established' },
|
||||
{ id: 'established', label: 'Установлены' },
|
||||
{ id: 'pending', label: 'Ожидание' },
|
||||
{ id: 'disabled', label: 'Выключены' },
|
||||
]
|
||||
|
||||
const SESSION_STATE_OPTIONS = [
|
||||
{ value: 'Established', label: 'Established' },
|
||||
{ value: 'Idle', label: 'Idle' },
|
||||
{ value: 'Active', label: 'Active' },
|
||||
{ value: 'Connect', label: 'Connect' },
|
||||
{ value: 'OpenSent', label: 'OpenSent' },
|
||||
{ value: 'OpenConfirm', label: 'OpenConfirm' },
|
||||
{ value: 'Established', label: 'Установлена' },
|
||||
{ value: 'Idle', label: 'Простой' },
|
||||
{ value: 'Active', label: 'Поиск' },
|
||||
{ value: 'Connect', label: 'Соединение' },
|
||||
{ value: 'OpenSent', label: 'Open отправлен' },
|
||||
{ value: 'OpenConfirm', label: 'Open подтверждён' },
|
||||
]
|
||||
|
||||
function createDefaultPeerFilters(): Filter[] {
|
||||
|
||||
@@ -31,14 +31,13 @@ function speakerDeleteLabel(s: SpeakerRow): string {
|
||||
|
||||
const SPEAKER_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'online', label: 'Online' },
|
||||
{ id: 'offline', label: 'Offline' },
|
||||
{ id: 'online', label: 'В сети' },
|
||||
{ id: 'offline', label: 'Не в сети' },
|
||||
]
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'primary', label: 'primary' },
|
||||
{ value: 'secondary', label: 'secondary' },
|
||||
{ value: 'speaker', label: 'speaker' },
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер' },
|
||||
]
|
||||
|
||||
function createDefaultSpeakerFilters(): Filter[] {
|
||||
@@ -52,7 +51,7 @@ const speakerFilterFields: FilterFieldConfig[] = [
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'endpoint…',
|
||||
placeholder: 'Адрес агента…',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
@@ -128,7 +127,7 @@ export function NetworkSpeakersCard({
|
||||
<>
|
||||
<ResourcePage
|
||||
title="Спикеры"
|
||||
description="BIRD-агенты на нодах tenant"
|
||||
description="BIRD-агенты на нодах арендатора"
|
||||
tabs={SPEAKER_TABS}
|
||||
tabFilter={speakerTabFilter}
|
||||
filterFields={speakerFilterFields}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { speakerOnlineLabel } from '@/lib/ui-labels'
|
||||
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||
@@ -20,7 +20,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
||||
cell: ({ row }) => <CategoryBadge>{speakerRoleRu(row.original.role)}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ interface PeerFormDialogProps {
|
||||
function speakerLabel(s: SpeakerRow): string {
|
||||
if (s.role === 'master') {
|
||||
const host = s.agent_domain ?? s.endpoint
|
||||
return host ? `CP · ${host}` : 'CP (master)'
|
||||
return host ? `Плоскость · ${host}` : 'Плоскость управления'
|
||||
}
|
||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export function PeerFormDialog({
|
||||
}
|
||||
const asn = Number(remoteAsn)
|
||||
if (!asn || asn <= 0) {
|
||||
toast.error('Remote ASN должен быть больше 0')
|
||||
toast.error('ASN соседа должен быть больше 0')
|
||||
return
|
||||
}
|
||||
const body: BgpPeerCreate = {
|
||||
@@ -132,7 +132,7 @@ export function PeerFormDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="peer-asn">Remote ASN</Label>
|
||||
<Label htmlFor="peer-asn">ASN соседа</Label>
|
||||
<Input
|
||||
id="peer-asn"
|
||||
type="number"
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Copy, TriangleAlert } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { Textarea } from '@evobgp/ui/components/textarea'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { useCreateSpeakerMutation } from '@/queries/network'
|
||||
import type { BgpSpeakerCreate } from '@/types/api'
|
||||
import type { BgpSpeakerCreate, SpeakerRow } from '@/types/api'
|
||||
|
||||
interface SpeakerFormDialogProps {
|
||||
open: boolean
|
||||
@@ -35,8 +39,20 @@ function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string)
|
||||
return JSON.stringify(meta)
|
||||
}
|
||||
|
||||
function tlsIncomplete(
|
||||
agentDomain: string,
|
||||
letsencryptEmail: string,
|
||||
cfToken: string,
|
||||
panelIP: string,
|
||||
): boolean {
|
||||
return !agentDomain.trim() || !letsencryptEmail.trim() || !cfToken.trim() || !panelIP.trim()
|
||||
}
|
||||
|
||||
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
|
||||
const createMutation = useCreateSpeakerMutation()
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({
|
||||
onCopy: () => toast.success('Команда скопирована'),
|
||||
})
|
||||
|
||||
const [endpoint, setEndpoint] = useState('')
|
||||
const [role, setRole] = useState('replica')
|
||||
@@ -44,6 +60,13 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
const [nodeIpv4, setNodeIpv4] = useState('')
|
||||
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
|
||||
const [bgpSourceManual, setBgpSourceManual] = useState(false)
|
||||
const [letsencryptEmail, setLetsencryptEmail] = useState('')
|
||||
const [cfDnsToken, setCfDnsToken] = useState('')
|
||||
const [panelIP, setPanelIP] = useState('')
|
||||
const [created, setCreated] = useState<SpeakerRow | null>(null)
|
||||
|
||||
const isReplica = role === 'replica'
|
||||
const installCommands = created?.install?.docker_commands ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -53,6 +76,10 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
setNodeIpv4('')
|
||||
setBgpSourceIpv4('')
|
||||
setBgpSourceManual(false)
|
||||
setLetsencryptEmail('')
|
||||
setCfDnsToken('')
|
||||
setPanelIP('')
|
||||
setCreated(null)
|
||||
}, [open])
|
||||
|
||||
function handleEndpointChange(value: string) {
|
||||
@@ -74,89 +101,217 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
const ep =
|
||||
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
|
||||
if (!ep) {
|
||||
toast.error('Укажите endpoint или agent domain')
|
||||
toast.error('Укажите конечную точку или домен агента')
|
||||
return
|
||||
}
|
||||
const body: BgpSpeakerCreate = {
|
||||
endpoint: ep,
|
||||
role: role.trim() || 'replica',
|
||||
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
|
||||
control_plane_url: window.location.origin,
|
||||
}
|
||||
if (isReplica) {
|
||||
if (letsencryptEmail.trim()) body.letsencrypt_email = letsencryptEmail.trim()
|
||||
if (cfDnsToken.trim()) body.cf_dns_api_token = cfDnsToken.trim()
|
||||
if (panelIP.trim()) body.panel_ip_whitelist = panelIP.trim()
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync(body)
|
||||
const row = await createMutation.mutateAsync(body)
|
||||
if (row.install?.docker_commands) {
|
||||
setCreated(row)
|
||||
return
|
||||
}
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
const showingInstall = created !== null && Boolean(installCommands)
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новый спикер"
|
||||
description="BIRD-агент на ноде реплики или control plane"
|
||||
className="sm:max-w-md"
|
||||
title={showingInstall ? 'Установка на ноду' : 'Новый спикер'}
|
||||
description={
|
||||
showingInstall
|
||||
? 'Секреты показываются один раз. Скопируйте команду на VPS реплики.'
|
||||
: 'BIRD-агент на ноде реплики или плоскости управления'
|
||||
}
|
||||
className={showingInstall ? 'sm:max-w-2xl' : 'sm:max-w-md'}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
showingInstall ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(installCommands)}
|
||||
>
|
||||
<Copy />
|
||||
{isCopied ? 'Скопировано' : 'Копировать команду'}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||
Готово
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
||||
<Input
|
||||
id="speaker-endpoint"
|
||||
placeholder="https://node.example.com:8443"
|
||||
value={endpoint}
|
||||
onChange={(e) => handleEndpointChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="speaker-role"
|
||||
label="Роль"
|
||||
items={[
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер (CP)' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
||||
<Input
|
||||
id="speaker-agent-domain"
|
||||
placeholder="bird-agent.example.com"
|
||||
value={agentDomain}
|
||||
onChange={(e) => setAgentDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
||||
<Input
|
||||
id="speaker-node-ipv4"
|
||||
placeholder="203.0.113.10"
|
||||
value={nodeIpv4}
|
||||
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
|
||||
<Input
|
||||
id="speaker-bgp-source"
|
||||
placeholder="203.0.113.10"
|
||||
value={bgpSourceIpv4}
|
||||
onChange={(e) => {
|
||||
setBgpSourceManual(true)
|
||||
setBgpSourceIpv4(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{created && installCommands ? (
|
||||
<SpeakerInstallStep created={created} commands={installCommands} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
||||
<Input
|
||||
id="speaker-endpoint"
|
||||
placeholder="https://node.example.com"
|
||||
value={endpoint}
|
||||
onChange={(e) => handleEndpointChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="speaker-role"
|
||||
label="Роль"
|
||||
items={[
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
||||
<Input
|
||||
id="speaker-agent-domain"
|
||||
placeholder="bird-agent.example.com"
|
||||
value={agentDomain}
|
||||
onChange={(e) => setAgentDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
||||
<Input
|
||||
id="speaker-node-ipv4"
|
||||
placeholder="203.0.113.10"
|
||||
value={nodeIpv4}
|
||||
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
||||
<Input
|
||||
id="speaker-bgp-source"
|
||||
placeholder="203.0.113.10"
|
||||
value={bgpSourceIpv4}
|
||||
onChange={(e) => {
|
||||
setBgpSourceManual(true)
|
||||
setBgpSourceIpv4(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isReplica ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-le-email">Email Let's Encrypt</Label>
|
||||
<Input
|
||||
id="speaker-le-email"
|
||||
type="email"
|
||||
placeholder="ops@example.com"
|
||||
value={letsencryptEmail}
|
||||
onChange={(e) => setLetsencryptEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-cf-token">Cloudflare DNS API token</Label>
|
||||
<Input
|
||||
id="speaker-cf-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="Zone:DNS:Edit"
|
||||
value={cfDnsToken}
|
||||
onChange={(e) => setCfDnsToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-panel-ip">IP панели (whitelist)</Label>
|
||||
<Input
|
||||
id="speaker-panel-ip"
|
||||
placeholder="203.0.113.1/32"
|
||||
value={panelIP}
|
||||
onChange={(e) => setPanelIP(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{tlsIncomplete(agentDomain, letsencryptEmail, cfDnsToken, panelIP) ? (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Traefik не выпустит сертификат</AlertTitle>
|
||||
<AlertDescription>
|
||||
Нужны домен агента, email LE, Cloudflare token и IP панели. Иначе в
|
||||
команде останутся плейсхолдеры CHANGE_ME_*.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function SpeakerInstallStep({
|
||||
created,
|
||||
commands,
|
||||
}: {
|
||||
created: SpeakerRow
|
||||
commands: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Сохраните сейчас</AlertTitle>
|
||||
<AlertDescription>
|
||||
agent_secret и node_token больше не будут показаны. Traefik на ноде выпускает
|
||||
сертификат через DNS-01 (Cloudflare).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>ID спикера</Label>
|
||||
<code className="break-all font-mono text-xs">{created.id}</code>
|
||||
</div>
|
||||
{created.agent_secret ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>agent_secret</Label>
|
||||
<code className="break-all font-mono text-xs">{created.agent_secret}</code>
|
||||
</div>
|
||||
) : null}
|
||||
{created.node_token ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>node_token</Label>
|
||||
<code className="break-all font-mono text-xs">{created.node_token}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-docker-commands">Docker-команды</Label>
|
||||
<Textarea
|
||||
id="speaker-docker-commands"
|
||||
readOnly
|
||||
value={commands}
|
||||
className="min-h-64 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ export function OperationsJobsGrid({
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={jobKindRu(row.original.kind)}
|
||||
accent="mono"
|
||||
subtitle={
|
||||
row.original.meta?.module_id
|
||||
? (nameById.get(String(row.original.meta.module_id)) ??
|
||||
|
||||
@@ -77,7 +77,7 @@ export function OperationsRevisionsGrid({
|
||||
</Button>
|
||||
}
|
||||
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
|
||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
||||
confirmLabel="Откатить"
|
||||
destructive
|
||||
onConfirm={() => rollbackMutation.mutate(row.original.id)}
|
||||
|
||||
@@ -8,12 +8,16 @@ interface PageHeaderProps {
|
||||
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
{description ? (
|
||||
<p className="text-muted-foreground max-w-prose text-sm break-words">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function PanelCard({
|
||||
<Frame
|
||||
dense
|
||||
spacing={spacing}
|
||||
className={cn(panelCardClassName, className)}
|
||||
className={cn(panelCardClassName, 'min-w-0', className)}
|
||||
>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Boxes, Plus } from 'lucide-react'
|
||||
|
||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
/** empty-state-3 pattern for first module. */
|
||||
export function ProjectsEmptyState() {
|
||||
export function ProjectsEmptyState({
|
||||
canCreate = true,
|
||||
onCreate,
|
||||
}: {
|
||||
canCreate?: boolean
|
||||
onCreate?: () => void
|
||||
}) {
|
||||
return (
|
||||
<IllustratedEmptyState
|
||||
icon={Boxes}
|
||||
title="Создайте первый модуль"
|
||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||
action={
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Новый модуль
|
||||
</Button>
|
||||
canCreate && onCreate ? (
|
||||
<Button size="sm" onClick={onCreate}>
|
||||
<Plus />
|
||||
Новый модуль
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -29,10 +29,10 @@ export function FrameDataGrid({
|
||||
}) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
||||
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3 border-b">
|
||||
<FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? (
|
||||
|
||||
@@ -24,4 +24,4 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { FrameDataGrid } from './frame-data-grid'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
export { SettingsShell } from './settings-shell'
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
|
||||
/**
|
||||
* Shared grid column classes for hybrid KPI / Quick Actions tiles.
|
||||
* Container queries only — never 5–6 columns on laptop width (tiles overlap).
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
if (count <= 6) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
|
||||
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4'
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ export function KpiStatGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
|
||||
@@ -27,7 +20,7 @@ interface OpsDashboardProps {
|
||||
}
|
||||
|
||||
const rootClassName =
|
||||
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
||||
'text-foreground @container flex w-full min-w-0 flex-col gap-4 md:gap-6'
|
||||
|
||||
function OpsDashboardSkeleton() {
|
||||
return (
|
||||
@@ -37,7 +30,7 @@ function OpsDashboardSkeleton() {
|
||||
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||
</header>
|
||||
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||
<div className="grid gap-2 @3xl:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
@@ -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/stats-12
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function OpsDashboard({
|
||||
title = 'Панель управления',
|
||||
@@ -77,21 +72,18 @@ export function OpsDashboard({
|
||||
|
||||
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
|
||||
|
||||
<section
|
||||
aria-label="Аналитика"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
>
|
||||
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label={queueTitle}>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>{queueTitle}</FrameTitle>
|
||||
<FrameDescription>{queueDescription}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>{queue}</FramePanel>
|
||||
</Frame>
|
||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||
{queueDescription ? (
|
||||
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{queue}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
import { FILTERS_I18N_RU } from '@/lib/filters-i18n'
|
||||
import { applyFiltersToData } from './filter-utils'
|
||||
|
||||
export interface ResourcePageTab {
|
||||
@@ -322,6 +324,7 @@ export function ResourcePage<T extends object>({
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
i18n={FILTERS_I18N_RU}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
@@ -355,11 +358,8 @@ export function ResourcePage<T extends object>({
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination
|
||||
{...DATA_GRID_PAGINATION_RU}
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
/>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
|
||||
@@ -1,120 +1,17 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
|
||||
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
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, no hairline Separator under header.
|
||||
* @see https://reui.io/preview/base/settings-16
|
||||
* @see https://reui.io/preview/base/settings-3
|
||||
* Settings layout — page chrome only.
|
||||
* Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
|
||||
* @see https://reui.io/preview/base/settings-7
|
||||
* @see https://reui.io/blocks
|
||||
*/
|
||||
export function SettingsShell({
|
||||
title = 'Настройки',
|
||||
description = 'Подключение UI и параметры плоскости управления',
|
||||
tabs = DEFAULT_TABS,
|
||||
}: SettingsShellProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
export function SettingsShell() {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
||||
<PageHeader title={title} description={description} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-5',
|
||||
isMobile ? 'flex-col' : 'flex-row items-start',
|
||||
)}
|
||||
>
|
||||
{tabs.length > 1 ? (
|
||||
<nav
|
||||
aria-label="Разделы настроек"
|
||||
className={cn(
|
||||
'flex gap-1',
|
||||
isMobile
|
||||
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
||||
: 'w-44 shrink-0 flex-col',
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = pathname.startsWith(tab.to)
|
||||
const className = cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
isMobile && 'shrink-0',
|
||||
!isMobile && '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>
|
||||
<Outlet />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge variant="secondary" className="px-1 font-normal">
|
||||
{selectedValues.size} selected
|
||||
{selectedValues.size} выбрано
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
@@ -94,7 +94,7 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
No results found.
|
||||
Ничего не найдено.
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
@@ -170,7 +170,7 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
}}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
Clear filters
|
||||
Сбросить фильтры
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -39,11 +39,11 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
info: "{from} - {to} of {count}",
|
||||
info: "{from}–{to} из {count}",
|
||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||
rowsPerPageLabel: "Rows per page",
|
||||
previousPageLabel: "Go to previous page",
|
||||
nextPageLabel: "Go to next page",
|
||||
rowsPerPageLabel: "Строк на странице",
|
||||
previousPageLabel: "Предыдущая страница",
|
||||
nextPageLabel: "Следующая страница",
|
||||
ellipsisText: "...",
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
aria-label="Перетащить строку"
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
@@ -89,7 +89,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
aria-label="Перетащить строку"
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
|
||||
@@ -105,7 +105,7 @@ function DataGridTableDndHeader<TData>({
|
||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
aria-label="Перетащить для изменения порядка"
|
||||
>
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
@@ -397,9 +397,9 @@ function DataGridTableVirtual<TData>({
|
||||
|
||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||
const loadingMoreMessage =
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Загрузка…"
|
||||
const allRowsLoadedMessage =
|
||||
props.allRowsLoadedMessage || "All records loaded"
|
||||
props.allRowsLoadedMessage || "Все записи загружены"
|
||||
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
|
||||
@@ -1241,7 +1241,7 @@ function DataGridTableEmpty() {
|
||||
colSpan={Math.max(visibleColumnCount, 1)}
|
||||
className="text-muted-foreground py-6 text-center text-sm"
|
||||
>
|
||||
{props.emptyMessage || "No data available"}
|
||||
{props.emptyMessage || "Нет данных"}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -1254,7 +1254,7 @@ function DataGridTableLoader() {
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium">
|
||||
<Spinner className="size-5 opacity-60" />
|
||||
{props.loadingMessage || "Loading..."}
|
||||
{props.loadingMessage || "Загрузка…"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1266,7 +1266,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isPinned ? "Unpin row" : "Pin row"}
|
||||
aria-label={isPinned ? "Открепить строку" : "Закрепить строку"}
|
||||
onClick={() => {
|
||||
if (isPinned) {
|
||||
row.pin(false)
|
||||
@@ -1322,7 +1322,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
aria-label="Выбрать строку"
|
||||
className="align-[inherit]"
|
||||
/>
|
||||
</>
|
||||
@@ -1341,7 +1341,7 @@ function DataGridTableRowSelectAll() {
|
||||
indeterminate={isSomeSelected && !isAllSelected}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
aria-label="Выбрать все"
|
||||
className="align-[inherit]"
|
||||
/>
|
||||
)
|
||||
@@ -1408,7 +1408,7 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{props.loadingMessage || "Loading..."}
|
||||
{props.loadingMessage || "Загрузка…"}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -123,75 +123,70 @@ export interface FilterI18nConfig {
|
||||
|
||||
// Default English i18n configuration
|
||||
export const DEFAULT_I18N: FilterI18nConfig = {
|
||||
// UI Labels
|
||||
addFilter: "Filter",
|
||||
searchFields: "Filter...",
|
||||
noFieldsFound: "No filters found.",
|
||||
noResultsFound: "No results found.",
|
||||
select: "Select...",
|
||||
true: "True",
|
||||
false: "False",
|
||||
min: "Min",
|
||||
max: "Max",
|
||||
to: "to",
|
||||
typeAndPressEnter: "Type and press Enter to add tag",
|
||||
selected: "selected",
|
||||
selectedCount: "selected",
|
||||
addFilter: "Фильтр",
|
||||
searchFields: "Фильтр…",
|
||||
noFieldsFound: "Поля не найдены.",
|
||||
noResultsFound: "Ничего не найдено.",
|
||||
select: "Выбрать…",
|
||||
true: "Да",
|
||||
false: "Нет",
|
||||
min: "Мин.",
|
||||
max: "Макс.",
|
||||
to: "—",
|
||||
typeAndPressEnter: "Введите и нажмите Enter",
|
||||
selected: "выбрано",
|
||||
selectedCount: "выбрано",
|
||||
percent: "%",
|
||||
defaultCurrency: "$",
|
||||
defaultColor: "#000000",
|
||||
addFilterTitle: "Add filter",
|
||||
addFilterTitle: "Добавить фильтр",
|
||||
|
||||
// Operators
|
||||
operators: {
|
||||
is: "is",
|
||||
isNot: "is not",
|
||||
isAnyOf: "is any of",
|
||||
isNotAnyOf: "is not any of",
|
||||
includesAll: "includes all",
|
||||
excludesAll: "excludes all",
|
||||
before: "before",
|
||||
after: "after",
|
||||
between: "between",
|
||||
notBetween: "not between",
|
||||
contains: "contains",
|
||||
notContains: "does not contain",
|
||||
startsWith: "starts with",
|
||||
endsWith: "ends with",
|
||||
isExactly: "is exactly",
|
||||
equals: "equals",
|
||||
notEquals: "not equals",
|
||||
greaterThan: "greater than",
|
||||
lessThan: "less than",
|
||||
overlaps: "overlaps",
|
||||
includes: "includes",
|
||||
excludes: "excludes",
|
||||
includesAllOf: "includes all of",
|
||||
includesAnyOf: "includes any of",
|
||||
empty: "is empty",
|
||||
notEmpty: "is not empty",
|
||||
is: "равно",
|
||||
isNot: "не равно",
|
||||
isAnyOf: "любое из",
|
||||
isNotAnyOf: "кроме",
|
||||
includesAll: "включает все",
|
||||
excludesAll: "исключает все",
|
||||
before: "до",
|
||||
after: "после",
|
||||
between: "между",
|
||||
notBetween: "вне диапазона",
|
||||
contains: "содержит",
|
||||
notContains: "не содержит",
|
||||
startsWith: "начинается с",
|
||||
endsWith: "заканчивается на",
|
||||
isExactly: "точно",
|
||||
equals: "равно",
|
||||
notEquals: "не равно",
|
||||
greaterThan: "больше",
|
||||
lessThan: "меньше",
|
||||
overlaps: "пересекается",
|
||||
includes: "включает",
|
||||
excludes: "исключает",
|
||||
includesAllOf: "включает все",
|
||||
includesAnyOf: "включает любое",
|
||||
empty: "пусто",
|
||||
notEmpty: "не пусто",
|
||||
},
|
||||
|
||||
// Placeholders
|
||||
placeholders: {
|
||||
enterField: (fieldType: string) => `Enter ${fieldType}...`,
|
||||
selectField: "Select...",
|
||||
searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`,
|
||||
enterKey: "Enter key...",
|
||||
enterValue: "Enter value...",
|
||||
enterField: (fieldType: string) => `Введите ${fieldType}…`,
|
||||
selectField: "Выбрать…",
|
||||
searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}…`,
|
||||
enterKey: "Введите ключ…",
|
||||
enterValue: "Введите значение…",
|
||||
},
|
||||
|
||||
// Helper functions
|
||||
helpers: {
|
||||
formatOperator: (operator: string) => operator.replace(/_/g, " "),
|
||||
},
|
||||
|
||||
// Validation
|
||||
validation: {
|
||||
invalidEmail: "Invalid email format",
|
||||
invalidUrl: "Invalid URL format",
|
||||
invalidTel: "Invalid phone format",
|
||||
invalid: "Invalid input format",
|
||||
invalidEmail: "Некорректный email",
|
||||
invalidUrl: "Некорректный URL",
|
||||
invalidTel: "Некорректный телефон",
|
||||
invalid: "Некорректное значение",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import { isRefreshJobKind, jobKindRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
import { ScheduleCalendarView } from './schedule-calendar-view'
|
||||
@@ -34,7 +34,7 @@ function jobTimestamp(job: JobRow): string | undefined {
|
||||
}
|
||||
|
||||
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
|
||||
if (filter === 'refresh') return job.kind === 'module_refresh'
|
||||
if (filter === 'refresh') return isRefreshJobKind(job.kind)
|
||||
if (filter === 'failed')
|
||||
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
||||
return true
|
||||
@@ -107,7 +107,7 @@ export function ScheduleAgendaPanel({
|
||||
return (
|
||||
<PanelCard
|
||||
title="Календарь задач"
|
||||
description="Задачи refresh и apply по дням"
|
||||
description="Задачи обновления и применения по дням"
|
||||
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
@@ -133,7 +133,9 @@ export function ScheduleAgendaPanel({
|
||||
</div>
|
||||
<Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
|
||||
<SelectTrigger size="sm" className="w-full sm:w-44">
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
{FILTER_ITEMS.find((item) => item.value === filter)?.label}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_ITEMS.map((item) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import { isRefreshJobKind } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
||||
@@ -11,7 +12,7 @@ import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
||||
type JobTab = 'all' | 'refresh' | 'failed'
|
||||
|
||||
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||
if (tab === 'refresh') return items.filter((j) => j.kind === 'module_refresh')
|
||||
if (tab === 'refresh') return items.filter((j) => isRefreshJobKind(j.kind))
|
||||
if (tab === 'failed')
|
||||
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||
return items
|
||||
@@ -20,7 +21,7 @@ function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||
function tabCounts(items: JobRow[]) {
|
||||
return {
|
||||
all: items.length,
|
||||
refresh: items.filter((j) => j.kind === 'module_refresh').length,
|
||||
refresh: items.filter((j) => isRefreshJobKind(j.kind)).length,
|
||||
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||
.length,
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function ScheduleJobsGrid({
|
||||
{
|
||||
accessorKey: 'kind',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} accent="mono" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} />,
|
||||
meta: { headerTitle: 'Вид' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ScheduleModulesGrid({
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{row.original.cron_expr ??
|
||||
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec}s` : '—')}
|
||||
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec} с` : '—')}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Расписание' },
|
||||
|
||||
@@ -44,7 +44,7 @@ export function AppearanceSettingsTab() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard
|
||||
title="Тема интерфейса"
|
||||
description="Быстрый переключатель также доступен в боковой панели"
|
||||
@@ -61,6 +61,7 @@ export function AppearanceSettingsTab() {
|
||||
мгновенно
|
||||
</Badge>
|
||||
}
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<ToggleGroup
|
||||
@@ -72,7 +73,7 @@ export function AppearanceSettingsTab() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Тема интерфейса"
|
||||
className="w-full sm:w-auto"
|
||||
className="w-full min-w-0"
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = option.icon
|
||||
@@ -89,16 +90,16 @@ export function AppearanceSettingsTab() {
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Дашборд"
|
||||
title="Обзор"
|
||||
description="Блоки на экране «Обзор»"
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Быстрые действия"
|
||||
description="Показывать KPI-like плитки быстрых переходов под метриками."
|
||||
description="Показывать плитки быстрых переходов под метриками."
|
||||
>
|
||||
<SettingRow
|
||||
title="Быстрые действия"
|
||||
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
|
||||
description="Блок с частыми переходами (модули, сеть, деплой) на экране «Обзор»."
|
||||
last
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Save } from 'lucide-react'
|
||||
import { KeyRound, Save } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SettingRow } from '@/components/settings/setting-row'
|
||||
import { SettingsCard } from '@/components/settings/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
DEV_API_TOKEN,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
TOKEN_STORAGE_KEY,
|
||||
} from '@/lib/api-client'
|
||||
import { authKeys } from '@/queries/auth'
|
||||
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
|
||||
@@ -50,21 +50,22 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
{tokenRequired ? (
|
||||
<Alert>
|
||||
<Alert variant="warning">
|
||||
<KeyRound aria-hidden />
|
||||
<AlertTitle>Нужен API-токен</AlertTitle>
|
||||
<AlertDescription>
|
||||
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
|
||||
dev».
|
||||
Сохраните токен ниже или нажмите «Использовать dev».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<SettingsCard
|
||||
title="Подключение к API"
|
||||
description="Токен хранится только в этом браузере (localStorage)"
|
||||
description="Токен хранится только в этом браузере"
|
||||
footer={
|
||||
<div className="flex w-full 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}>
|
||||
Использовать dev
|
||||
</Button>
|
||||
@@ -83,8 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
||||
title="Токен для запросов"
|
||||
description={
|
||||
<>
|
||||
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
|
||||
ключами tenant — в разделе{' '}
|
||||
Ключ для заголовка Authorization. Управление ключами арендатора — в разделе{' '}
|
||||
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||
Права доступа
|
||||
</Link>
|
||||
@@ -93,16 +93,18 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
||||
}
|
||||
titleAddon={
|
||||
<Badge variant="outline" size="sm">
|
||||
localStorage
|
||||
браузер
|
||||
</Badge>
|
||||
}
|
||||
labelFor="settings-token"
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<Input
|
||||
id="settings-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className="w-full min-w-0"
|
||||
value={token}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
placeholder="dev или API-ключ"
|
||||
|
||||
@@ -23,42 +23,45 @@ import {
|
||||
const ADMIN_SECTIONS = [
|
||||
{
|
||||
id: 'tenant-settings',
|
||||
to: '/tenant-settings' as const,
|
||||
to: '/settings' as const,
|
||||
search: { tab: 'bird' as const },
|
||||
title: 'Параметры арендатора',
|
||||
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
||||
badge: { label: 'оператор', variant: 'warning-light' as const },
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
to: '/access' as const,
|
||||
search: undefined,
|
||||
title: 'Права доступа',
|
||||
description: 'API-ключи tenant, роли и управление доступом.',
|
||||
description: 'API-ключи арендатора, роли и управление доступом.',
|
||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
||||
badge: { label: 'оператор', variant: 'warning-light' as const },
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
to: '/monitoring' as const,
|
||||
search: undefined,
|
||||
title: 'Мониторинг',
|
||||
description: 'Метрики, состояние jobs и observability control plane.',
|
||||
description: 'Метрики, состояние задач и наблюдаемость плоскости управления.',
|
||||
icon: <ActivityIcon aria-hidden="true" />,
|
||||
badge: { label: 'viewer+', variant: 'info-light' as const },
|
||||
badge: { label: 'просмотр+', variant: 'info-light' as const },
|
||||
},
|
||||
] as const
|
||||
|
||||
export function SectionsSettingsTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard
|
||||
title="Разделы control plane"
|
||||
description="Параметры tenant и операции — отдельно от настроек браузера"
|
||||
title="Разделы плоскости управления"
|
||||
description="Параметры арендатора и операции — отдельно от настроек браузера"
|
||||
>
|
||||
<ItemGroup className="gap-0">
|
||||
{ADMIN_SECTIONS.map((section, index) => (
|
||||
<Fragment key={section.id}>
|
||||
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
||||
<Item className="min-h-0 min-w-0 flex-wrap items-center gap-4 px-5 py-3.5">
|
||||
<ItemMedia className="self-center!">
|
||||
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||
{section.icon}
|
||||
@@ -66,7 +69,7 @@ export function SectionsSettingsTab() {
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||
<ItemTitle className="gap-2 leading-5">
|
||||
<ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
|
||||
{section.title}
|
||||
<Badge variant={section.badge.variant} size="xs">
|
||||
{section.badge.label}
|
||||
@@ -76,7 +79,17 @@ export function SectionsSettingsTab() {
|
||||
</ItemContent>
|
||||
|
||||
<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>
|
||||
</ItemActions>
|
||||
|
||||
@@ -35,8 +35,8 @@ export function SessionSettingsTab() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard title="Текущая сессия" description="Проверка токена через API сессии">
|
||||
<QueryState
|
||||
data={sessionQ.data}
|
||||
isLoading={sessionQ.isLoading && hasStoredToken}
|
||||
@@ -58,15 +58,17 @@ export function SessionSettingsTab() {
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||
<ItemTitle className="gap-2 leading-5">
|
||||
<ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
|
||||
Этот браузер
|
||||
<Badge variant="success-light" size="xs">
|
||||
Активна
|
||||
</Badge>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="leading-5">
|
||||
Tenant:{' '}
|
||||
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
||||
Арендатор:{' '}
|
||||
<code className="text-foreground font-mono text-xs break-all">
|
||||
{session.tenant_id}
|
||||
</code>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
@@ -82,7 +84,7 @@ export function SessionSettingsTab() {
|
||||
>
|
||||
<SettingRow
|
||||
title="Роль"
|
||||
description="Определяет доступ к операциям control plane и CRUD."
|
||||
description="Определяет доступ к операциям плоскости управления и изменению данных."
|
||||
titleAddon={
|
||||
sessionQ.data ? (
|
||||
<Badge variant="info-light" size="sm">
|
||||
@@ -94,12 +96,13 @@ export function SessionSettingsTab() {
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
stacked
|
||||
last
|
||||
>
|
||||
{sessionQ.data ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
|
||||
<code className="font-mono text-xs">{sessionQ.data.tenant_id}</code>.
|
||||
<p className="text-muted-foreground text-sm break-words">
|
||||
Ключ с ролью <strong>{ROLE_LABELS[sessionQ.data.role] ?? sessionQ.data.role}</strong> у арендатора{' '}
|
||||
<code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -10,31 +10,59 @@ import {
|
||||
FieldTitle,
|
||||
} from '@evobgp/ui/components/field'
|
||||
|
||||
interface SettingRowProps {
|
||||
export interface SettingRowProps {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
|
||||
separated?: boolean
|
||||
compact?: boolean
|
||||
stacked?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
className?: string
|
||||
titleAddon?: ReactNode
|
||||
}
|
||||
|
||||
/** Settings row — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */
|
||||
/**
|
||||
* Settings row — settings-3 / settings-16 DNA.
|
||||
* Control is flex-1 + min-w-0 (never a fixed w-78) so label and input do not overlap.
|
||||
* @see https://reui.io/preview/base/settings-3
|
||||
* @see https://reui.io/preview/base/settings-16
|
||||
*/
|
||||
export function SettingRow({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
last,
|
||||
separated = false,
|
||||
compact,
|
||||
stacked,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
className,
|
||||
titleAddon,
|
||||
}: SettingRowProps) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-5 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Field
|
||||
orientation={stacked ? 'vertical' : 'responsive'}
|
||||
className={cn(
|
||||
'min-w-0 gap-4 px-5 py-3.5',
|
||||
!stacked &&
|
||||
'@md/field-group:items-start @md/field-group:*:min-w-0 @md/field-group:*:max-w-full',
|
||||
!last && !separated && 'border-border/40 border-b',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 flex-col gap-0.5',
|
||||
!stacked && '@md/field-group:max-w-sm',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
@@ -44,20 +72,33 @@ export function SettingRow({
|
||||
</div>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
<FieldDescription className="text-sm break-words">{description}</FieldDescription>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
className={cn('min-w-0 @md/field-group:w-78', contentClassName)}
|
||||
className={cn(
|
||||
'w-full min-w-0 @md/field-group:flex-1',
|
||||
stacked
|
||||
? 'max-w-none'
|
||||
: compact
|
||||
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
|
||||
: '@md/field-group:max-w-[min(100%,34rem)]',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full justify-start @md/field-group:justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-w-0 justify-start',
|
||||
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
{separated && !last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SettingsCard({
|
||||
footerClassName,
|
||||
}: SettingsCardProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
||||
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
<FrameHeader className="gap-0 border-b px-5 py-3">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
|
||||
@@ -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 { BirdSettingsTab } from './bird-settings-tab'
|
||||
import { ConnectionSettingsTab } from './connection-settings-tab'
|
||||
import { SectionsSettingsTab } from './sections-settings-tab'
|
||||
import { SessionSettingsTab } from './session-settings-tab'
|
||||
import {
|
||||
SETTINGS_TAB_ITEMS,
|
||||
type SettingsTab,
|
||||
type SettingsSection,
|
||||
type SettingsTabItem,
|
||||
} 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({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
tokenRequired,
|
||||
showBird,
|
||||
}: {
|
||||
activeTab: SettingsTab
|
||||
onTabChange: (tab: SettingsTab) => void
|
||||
activeTab: SettingsSection
|
||||
onTabChange: (tab: SettingsSection) => void
|
||||
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 (
|
||||
<BadgeTabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => onTabChange(value as SettingsTab)}
|
||||
items={SETTINGS_TAB_ITEMS.map((tab) => ({
|
||||
value: tab.value,
|
||||
label: tab.label,
|
||||
icon: tab.icon,
|
||||
}))}
|
||||
>
|
||||
<TabsContent value="connection" className="mt-0">
|
||||
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
||||
</TabsContent>
|
||||
<TabsContent value="session" className="mt-0">
|
||||
<SessionSettingsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="appearance" className="mt-0">
|
||||
<AppearanceSettingsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="sections" className="mt-0">
|
||||
<SectionsSettingsTab />
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
|
||||
<header className="px-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">Настройки</h1>
|
||||
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||
Интерфейс UI и глобальные параметры BIRD в одном разделе.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
value={resolvedTab}
|
||||
onValueChange={(value) => onTabChange(value as SettingsSection)}
|
||||
orientation={isMobile ? 'horizontal' : 'vertical'}
|
||||
className="w-full gap-4 lg:gap-8"
|
||||
>
|
||||
<SettingsNavigation
|
||||
isMobile={isMobile}
|
||||
activeValue={resolvedTab}
|
||||
items={items}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<TabsContent value="ui" className="mt-0">
|
||||
<UiSettingsPanel tokenRequired={tokenRequired} />
|
||||
</TabsContent>
|
||||
{showBird ? (
|
||||
<TabsContent value="bird" className="mt-0">
|
||||
<BirdSettingsTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { type ComponentProps, type ReactNode } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from '@evobgp/ui/components/field'
|
||||
import { SettingRow } from '@/components/settings/setting-row'
|
||||
|
||||
/** settings-3 row pattern for tenant/UI settings. */
|
||||
/** Tenant settings row — same SettingRow DNA as UI settings. */
|
||||
export function SettingsSettingField({
|
||||
title,
|
||||
description,
|
||||
@@ -20,38 +12,34 @@ export function SettingsSettingField({
|
||||
last,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
stacked,
|
||||
}: {
|
||||
title: string
|
||||
description: ReactNode
|
||||
description?: ReactNode
|
||||
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
stacked?: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
{badge ? (
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<FieldDescription className="text-xs">{description}</FieldDescription>
|
||||
</div>
|
||||
<FieldContent className={cn('min-w-0 @md/field-group:w-78', contentClassName)}>
|
||||
{children}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
<SettingRow
|
||||
title={title}
|
||||
description={description}
|
||||
last={last}
|
||||
labelFor={labelFor}
|
||||
contentClassName={contentClassName}
|
||||
stacked={stacked}
|
||||
titleAddon={
|
||||
badge ? (
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,49 +1,32 @@
|
||||
import {
|
||||
KeyRoundIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
PaletteIcon,
|
||||
SlidersHorizontalIcon,
|
||||
} from 'lucide-react'
|
||||
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
|
||||
export type SettingsSection = 'ui' | 'bird'
|
||||
|
||||
export type SettingsTabItem = {
|
||||
value: SettingsTab
|
||||
value: SettingsSection
|
||||
label: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
/** Side rail items — settings-7 AccountSettings DNA. */
|
||||
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||
{
|
||||
value: 'connection',
|
||||
label: 'Подключение',
|
||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||
value: 'ui',
|
||||
label: 'Настройки UI',
|
||||
icon: <SettingsIcon aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'session',
|
||||
label: 'Сессия',
|
||||
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'appearance',
|
||||
label: 'Оформление',
|
||||
icon: <PaletteIcon aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'sections',
|
||||
label: 'Разделы',
|
||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||
value: 'bird',
|
||||
label: 'Настройки BIRD',
|
||||
icon: <ServerCogIcon aria-hidden="true" />,
|
||||
},
|
||||
]
|
||||
|
||||
export function parseSettingsTab(value: unknown): SettingsTab {
|
||||
if (
|
||||
value === 'session' ||
|
||||
value === 'appearance' ||
|
||||
value === 'sections'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
return 'connection'
|
||||
const LEGACY_BIRD = new Set(['bird', 'revision', 'runtime-logs', 'additional'])
|
||||
|
||||
/** Maps current and legacy `?tab=` values onto the unified settings rail. */
|
||||
export function parseSettingsSection(value: unknown): SettingsSection {
|
||||
if (typeof value === 'string' && LEGACY_BIRD.has(value)) return 'bird'
|
||||
return 'ui'
|
||||
}
|
||||
|
||||
@@ -39,11 +39,11 @@ export function AnalyticsDashboardSkeleton() {
|
||||
</div>
|
||||
|
||||
<div className={dashboardMainSidebarClassName}>
|
||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-8" />
|
||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-4" />
|
||||
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
|
||||
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className={chartPanelGridClassName}>
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { apiKeyRoleRu } from '@/lib/ui-labels'
|
||||
|
||||
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
|
||||
{ value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
|
||||
{ value: 'editor', label: `${apiKeyRoleRu('editor')} — CRUD без применения` },
|
||||
{ value: 'editor', label: `${apiKeyRoleRu('editor')} — изменение данных без применения` },
|
||||
{ value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
|
||||
{ value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
|
||||
]
|
||||
|
||||
@@ -346,11 +346,17 @@ export function permissionForPath(pathname: string): string | null {
|
||||
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
|
||||
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
|
||||
if (pathname.startsWith('/tenant-settings') || pathname.startsWith('/settings')) {
|
||||
return canOpenSettings() ? null : 'bgp:settings:read'
|
||||
}
|
||||
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'
|
||||
|
||||
/** First path in the sidebar the current user may open. */
|
||||
@@ -365,7 +371,6 @@ export function firstAllowedPath(): string {
|
||||
'/schedule',
|
||||
'/monitoring',
|
||||
'/access',
|
||||
'/tenant-settings',
|
||||
'/settings',
|
||||
]
|
||||
for (const path of candidates) {
|
||||
|
||||
@@ -44,6 +44,7 @@ export const DATA_GRID_MESSAGES_RU = {
|
||||
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
|
||||
...DATA_GRID_TABLE_LAYOUT,
|
||||
dense: true,
|
||||
width: 'auto',
|
||||
}
|
||||
|
||||
export function createTextGlobalFilter<TData extends object>(
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
DEFAULT_I18N,
|
||||
type FilterI18nConfig,
|
||||
} from '@/components/reui/filters'
|
||||
|
||||
const OPERATOR_LABEL_RU: Record<string, string> = {
|
||||
is: 'равно',
|
||||
is_not: 'не равно',
|
||||
is_any_of: 'любое из',
|
||||
is_not_any_of: 'кроме',
|
||||
includes_all: 'включает все',
|
||||
excludes_all: 'исключает все',
|
||||
before: 'до',
|
||||
after: 'после',
|
||||
between: 'между',
|
||||
not_between: 'вне диапазона',
|
||||
contains: 'содержит',
|
||||
not_contains: 'не содержит',
|
||||
starts_with: 'начинается с',
|
||||
ends_with: 'заканчивается на',
|
||||
equals: 'равно',
|
||||
not_equals: 'не равно',
|
||||
greater_than: 'больше',
|
||||
less_than: 'меньше',
|
||||
overlaps: 'пересекается',
|
||||
includes: 'включает',
|
||||
excludes: 'исключает',
|
||||
empty: 'пусто',
|
||||
not_empty: 'не пусто',
|
||||
}
|
||||
|
||||
export const FILTERS_I18N_RU: FilterI18nConfig = {
|
||||
...DEFAULT_I18N,
|
||||
addFilter: 'Фильтр',
|
||||
searchFields: 'Фильтр…',
|
||||
noFieldsFound: 'Поля не найдены.',
|
||||
noResultsFound: 'Ничего не найдено.',
|
||||
select: 'Выбрать…',
|
||||
true: 'Да',
|
||||
false: 'Нет',
|
||||
min: 'Мин.',
|
||||
max: 'Макс.',
|
||||
to: '—',
|
||||
typeAndPressEnter: 'Введите и нажмите Enter',
|
||||
selected: 'выбрано',
|
||||
selectedCount: 'выбрано',
|
||||
addFilterTitle: 'Добавить фильтр',
|
||||
operators: {
|
||||
...DEFAULT_I18N.operators,
|
||||
is: 'равно',
|
||||
isNot: 'не равно',
|
||||
isAnyOf: 'любое из',
|
||||
isNotAnyOf: 'кроме',
|
||||
includesAll: 'включает все',
|
||||
excludesAll: 'исключает все',
|
||||
before: 'до',
|
||||
after: 'после',
|
||||
between: 'между',
|
||||
notBetween: 'вне диапазона',
|
||||
contains: 'содержит',
|
||||
notContains: 'не содержит',
|
||||
startsWith: 'начинается с',
|
||||
endsWith: 'заканчивается на',
|
||||
isExactly: 'точно',
|
||||
equals: 'равно',
|
||||
notEquals: 'не равно',
|
||||
greaterThan: 'больше',
|
||||
lessThan: 'меньше',
|
||||
overlaps: 'пересекается',
|
||||
includes: 'включает',
|
||||
excludes: 'исключает',
|
||||
includesAllOf: 'включает все',
|
||||
includesAnyOf: 'включает любое',
|
||||
empty: 'пусто',
|
||||
notEmpty: 'не пусто',
|
||||
},
|
||||
placeholders: {
|
||||
enterField: (fieldType: string) => `Введите ${fieldType}…`,
|
||||
selectField: 'Выбрать…',
|
||||
searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}…`,
|
||||
enterKey: 'Введите ключ…',
|
||||
enterValue: 'Введите значение…',
|
||||
},
|
||||
helpers: {
|
||||
formatOperator: (operator: string) =>
|
||||
OPERATOR_LABEL_RU[operator] ?? operator.replace(/_/g, ' '),
|
||||
},
|
||||
validation: {
|
||||
invalidEmail: 'Некорректный email',
|
||||
invalidUrl: 'Некорректный URL',
|
||||
invalidTel: 'Некорректный телефон',
|
||||
invalid: 'Некорректное значение',
|
||||
},
|
||||
}
|
||||
@@ -38,7 +38,7 @@ describe('isReadyCheckOk', () => {
|
||||
|
||||
describe('readyCheckStatusLabel', () => {
|
||||
it('labels memory and failures', () => {
|
||||
expect(readyCheckStatusLabel('memory', true)).toBe('Memory')
|
||||
expect(readyCheckStatusLabel('memory', true)).toBe('В памяти')
|
||||
expect(readyCheckStatusLabel('ok', true)).toBe('В норме')
|
||||
expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно')
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ export function readyCheckStatusLabel(value: ReadyCheckValue, ok: boolean): stri
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const n = value.trim().toLowerCase()
|
||||
if (n === 'memory') return 'Memory'
|
||||
if (n === 'memory') return 'В памяти'
|
||||
if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме'
|
||||
if (n) return value
|
||||
}
|
||||
|
||||
@@ -30,9 +30,28 @@ export function moduleTypeRu(type: string): string {
|
||||
|
||||
const JOB_KIND_RU: Record<string, string> = {
|
||||
module_refresh: 'Обновление модуля',
|
||||
tenant_refresh: 'Обновление тенанта',
|
||||
peer_reconcile: 'Согласование пиров',
|
||||
deploy_apply: 'Применение на спикеры',
|
||||
apply: 'Применение конфигурации',
|
||||
revision_rollback: 'Откат ревизии',
|
||||
rollback: 'Откат ревизии',
|
||||
bird_reload: 'Перезагрузка BIRD',
|
||||
postgres_metrics_refresh: 'Метрики PostgreSQL',
|
||||
postgres_slow_query_aggregate: 'Медленные запросы PostgreSQL',
|
||||
postgres_table_bloat_estimate: 'Bloat таблиц PostgreSQL',
|
||||
postgres_index_usage_analyze: 'Использование индексов PostgreSQL',
|
||||
postgres_autovacuum_lag_detect: 'Отставание autovacuum',
|
||||
postgres_vacuum: 'VACUUM PostgreSQL',
|
||||
postgres_vacuum_analyze: 'VACUUM ANALYZE PostgreSQL',
|
||||
postgres_analyze: 'ANALYZE PostgreSQL',
|
||||
postgres_reindex: 'REINDEX PostgreSQL',
|
||||
postgres_cleanup: 'Очистка PostgreSQL',
|
||||
maintenance_policy_run: 'Политика обслуживания',
|
||||
}
|
||||
|
||||
export function isRefreshJobKind(kind: string): boolean {
|
||||
return kind === 'module_refresh' || kind === 'tenant_refresh'
|
||||
}
|
||||
|
||||
export function jobKindRu(kind: string): string {
|
||||
@@ -61,18 +80,46 @@ const JOB_STATUS_RU: Record<string, string> = {
|
||||
paused: 'Приостановлен',
|
||||
disabled: 'Выключен',
|
||||
archived: 'В архиве',
|
||||
block: 'block',
|
||||
accept: 'accept',
|
||||
block: 'Блокировать',
|
||||
accept: 'Принимать',
|
||||
}
|
||||
|
||||
export function jobStatusRu(status: string): string {
|
||||
return JOB_STATUS_RU[status.toLowerCase()] ?? status
|
||||
}
|
||||
|
||||
const BGP_SESSION_STATE_RU: Record<string, string> = {
|
||||
Idle: 'Простой',
|
||||
Connect: 'Соединение',
|
||||
Active: 'Поиск',
|
||||
OpenSent: 'Open отправлен',
|
||||
OpenConfirm: 'Open подтверждён',
|
||||
Established: 'Установлена',
|
||||
}
|
||||
|
||||
export function bgpSessionStateRu(state: string | null | undefined): string {
|
||||
if (!state) return '—'
|
||||
if (state === 'Established') return 'Установлена'
|
||||
return state
|
||||
return BGP_SESSION_STATE_RU[state] ?? state
|
||||
}
|
||||
|
||||
export function speakerRoleRu(role: string | null | undefined): string {
|
||||
switch (role) {
|
||||
case 'master':
|
||||
case 'primary':
|
||||
return 'Основной'
|
||||
case 'replica':
|
||||
return 'Реплика'
|
||||
case 'secondary':
|
||||
return 'Резервный'
|
||||
case 'speaker':
|
||||
return 'Спикер'
|
||||
default:
|
||||
return role?.trim() || '—'
|
||||
}
|
||||
}
|
||||
|
||||
export function cdnSourceKindRu(kind: string): string {
|
||||
return kind === 'json' ? 'JSON' : 'Текст'
|
||||
}
|
||||
|
||||
export function speakerOnlineLabel(agentOk: boolean | undefined): string {
|
||||
|
||||
@@ -30,7 +30,13 @@ export const kpiGridClassName =
|
||||
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
|
||||
|
||||
/** Two-column chart panel row (monitoring / network overview). */
|
||||
export const chartPanelGridClassName = 'grid grid-cols-1 gap-4 @5xl:grid-cols-2'
|
||||
export const chartPanelGridClassName =
|
||||
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
|
||||
|
||||
/** Main + sidebar layout (8+4) used on dashboard. */
|
||||
export const dashboardMainSidebarClassName = 'grid gap-4 xl:grid-cols-12 xl:items-start'
|
||||
/**
|
||||
* Main + sidebar: stack until the dashboard container is wide enough.
|
||||
* Do not nest this inside another 2-col grid — that squeezes modules/timeline.
|
||||
* @see https://reui.io/preview/base/dashboard-1
|
||||
*/
|
||||
export const dashboardMainSidebarClassName =
|
||||
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
|
||||
|
||||
@@ -41,11 +41,11 @@ export function useCreateCommunityMutation() {
|
||||
mutationFn: (body: BgpCommunityCreate) =>
|
||||
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Сообщество создано')
|
||||
toast.success('Community создано')
|
||||
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось создать сообщество'),
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось создать community'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,11 +55,11 @@ export function useUpdateCommunityMutation() {
|
||||
mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) =>
|
||||
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Сообщество обновлено')
|
||||
toast.success('Community обновлено')
|
||||
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось обновить сообщество'),
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось обновить community'),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
||||
import { apiJSON, apiMutate } from '@/lib/api-client'
|
||||
import { overviewKeys } from '@/queries/overview'
|
||||
import type {
|
||||
ModuleCreate,
|
||||
ModulePatch,
|
||||
ModuleRow,
|
||||
ModulesResponse,
|
||||
@@ -64,6 +65,18 @@ export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateModuleMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: ModuleCreate) => apiMutate<ModuleRow>('/v1/modules', 'POST', body),
|
||||
onSuccess: (data) => {
|
||||
toast.success('Модуль создан')
|
||||
invalidateModules(qc, data.id)
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать модуль'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateModuleMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -62,7 +62,7 @@ export const Route = createFileRoute('/_auth')({
|
||||
if (!raw || !normalizeApiToken(raw)) {
|
||||
throw redirect({
|
||||
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 { z } from 'zod'
|
||||
|
||||
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
||||
import { type SettingsTab } from '@/components/settings/settings-tabs-data'
|
||||
|
||||
const settingsSearchSchema = z.object({
|
||||
tab: z
|
||||
.enum(['connection', 'session', 'appearance', 'sections'])
|
||||
.catch('connection'),
|
||||
reason: z.enum(['token-required']).optional(),
|
||||
})
|
||||
import {
|
||||
parseSettingsSection,
|
||||
type SettingsSection,
|
||||
} from '@/components/settings/settings-tabs-data'
|
||||
import { can } from '@/lib/auth'
|
||||
|
||||
export const Route = createFileRoute('/_auth/_settings/settings')({
|
||||
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() {
|
||||
const navigate = Route.useNavigate()
|
||||
const { tab, reason } = Route.useSearch()
|
||||
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({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
tab: nextTab,
|
||||
reason: nextTab === 'ui' ? prev.reason : undefined,
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
@@ -33,9 +41,10 @@ function SettingsComponent() {
|
||||
|
||||
return (
|
||||
<SettingsPageShell
|
||||
activeTab={tab}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
tokenRequired={tokenRequired}
|
||||
showBird={showBird}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,358 +1,8 @@
|
||||
import { createFileRoute, useSearch } 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 { PanelCard } from '@/components/panel-card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
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'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
|
||||
component: TenantSettingsComponent,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
|
||||
? search.tab
|
||||
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
|
||||
}),
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/settings', search: { tab: 'bird' } })
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
|
||||
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 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">
|
||||
<PanelCard
|
||||
title="BIRD control plane"
|
||||
description={
|
||||
<>
|
||||
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
|
||||
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
|
||||
</>
|
||||
}
|
||||
contentClassName="space-y-4 py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-64" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<div className="flex flex-col">
|
||||
{BIRD_SETTING_KEYS.map((key, index) => (
|
||||
<SettingsSettingField
|
||||
key={key}
|
||||
title={BIRD_LABELS[key]}
|
||||
description={BIRD_HINTS[key] ?? key}
|
||||
labelFor={key}
|
||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||
>
|
||||
{key === 'peer_discovery_enabled' ||
|
||||
key === 'peer_discovery_require_external' ? (
|
||||
<SelectField
|
||||
id={key}
|
||||
items={[...BIRD_BOOL_ITEMS]}
|
||||
value={birdForm[key] || 'false'}
|
||||
onValueChange={(v) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||
}
|
||||
placeholder="Выкл"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={key}
|
||||
value={birdForm[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||
}
|
||||
placeholder={BIRD_LABELS[key]}
|
||||
/>
|
||||
)}
|
||||
</SettingsSettingField>
|
||||
))}
|
||||
<div className="px-4 py-4">
|
||||
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</PanelCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="revision" className="mt-0">
|
||||
<PanelCard
|
||||
title="Ревизии"
|
||||
description="Время хранения ревизий в БД"
|
||||
contentClassName="space-y-4 py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<div className="flex max-w-sm flex-col gap-1.5">
|
||||
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label>
|
||||
<Input
|
||||
id="revision_retention_minutes"
|
||||
type="number"
|
||||
value={revisionForm.revision_retention_minutes ?? ''}
|
||||
onChange={(e) =>
|
||||
setRevisionForm((s) => ({
|
||||
...s,
|
||||
revision_retention_minutes: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
revision_retention_minutes
|
||||
</p>
|
||||
<LoadingButton
|
||||
className="mt-2 w-fit"
|
||||
onClick={saveRevision}
|
||||
loading={patchMutation.isPending}
|
||||
>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</PanelCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runtime-logs" className="mt-0">
|
||||
<PanelCard
|
||||
title="Файловые логи"
|
||||
description="Автоматическая очистка логов"
|
||||
contentClassName="space-y-4 py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-48" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SelectField
|
||||
label="Авто-очистка включена"
|
||||
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||
placeholder="Выберите"
|
||||
description="runtime_logs_auto_enabled"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_enabled: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
|
||||
<Input
|
||||
id="runtime_logs_max_file_mb"
|
||||
type="number"
|
||||
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_max_file_mb: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
runtime_logs_max_file_mb
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label>
|
||||
<Input
|
||||
id="runtime_logs_auto_schedule"
|
||||
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_schedule: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
runtime_logs_auto_schedule
|
||||
</p>
|
||||
</div>
|
||||
<SelectField
|
||||
label="Режим очистки"
|
||||
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||
placeholder="Выберите"
|
||||
description="runtime_logs_auto_mode"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_mode: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</PanelCard>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user