Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d695f2a4 | ||
|
|
dfc33dcb99 | ||
|
|
44f70443c3 | ||
|
|
357ed4ce7b | ||
|
|
35e262b5ae | ||
|
|
8267141136 | ||
|
|
34a29ad337 | ||
|
|
b04b27c215 | ||
|
|
6a25a3d137 | ||
|
|
32d9dc6acb | ||
|
|
fc4f234cc5 | ||
|
|
821f342476 | ||
|
|
4634aac2d1 | ||
|
|
7953ae6e78 | ||
|
|
d14550cda1 | ||
|
|
632adaa63f | ||
|
|
cc80858952 | ||
|
|
b8dbf15254 | ||
|
|
f45b2bed90 |
@@ -1,17 +1,17 @@
|
||||
---
|
||||
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 `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 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
|
||||
@@ -58,7 +58,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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
# ReUI components
|
||||
|
||||
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
## data-grid (the flagship - read its API every time)
|
||||
|
||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
|
||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
||||
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||
|
||||
```tsx
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// add sorting/pagination/selection models per the API
|
||||
})
|
||||
|
||||
<DataGrid table={table} recordCount={data.length}>
|
||||
@@ -28,9 +27,9 @@ const table = useReactTable({
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||
|
||||
## event-calendar
|
||||
|
||||
@@ -312,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||
|
||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||
|
||||
## icon-tile
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconTile variant="elevated" size="lg">
|
||||
<PackageIcon />
|
||||
</IconTile>
|
||||
```
|
||||
|
||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
||||
|
||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
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 `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 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
|
||||
@@ -58,7 +58,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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
# ReUI components
|
||||
|
||||
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
## data-grid (the flagship - read its API every time)
|
||||
|
||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
|
||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
||||
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||
|
||||
```tsx
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// add sorting/pagination/selection models per the API
|
||||
})
|
||||
|
||||
<DataGrid table={table} recordCount={data.length}>
|
||||
@@ -28,9 +27,9 @@ const table = useReactTable({
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||
|
||||
## event-calendar
|
||||
|
||||
@@ -312,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||
|
||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||
|
||||
## icon-tile
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconTile variant="elevated" size="lg">
|
||||
<PackageIcon />
|
||||
</IconTile>
|
||||
```
|
||||
|
||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
||||
|
||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
|
||||
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# KPI hybrid — только kit (stats-12 DNA)
|
||||
# KPI hybrid — только kit (stats-12 + IconTile)
|
||||
|
||||
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
||||
Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
||||
|
||||
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc).
|
||||
|
||||
@@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma
|
||||
| Зона | Компонент / DNA |
|
||||
|------|-----------------|
|
||||
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
||||
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
|
||||
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
|
||||
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
|
||||
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
|
||||
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
|
||||
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
|
||||
|
||||
Импорты UI: `@evobgp/ui/components/*`.
|
||||
Импорты UI: `@evobgp/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
|
||||
|
||||
## NEVER
|
||||
|
||||
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||
- Другой size / radius / solid brand fill вместо `bg-muted`
|
||||
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
|
||||
- Другой size / radius / solid brand fill вместо `elevated`
|
||||
- `card-35` как замена stats-12 hybrid KPI
|
||||
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
||||
- Голый lucide `size-4` в name-cell без hybrid tile
|
||||
- Голый lucide `size-4` в name-cell без IconTile
|
||||
|
||||
@@ -11,10 +11,14 @@ alwaysApply: true
|
||||
|
||||
| Документ | URL |
|
||||
|----------|-----|
|
||||
| **Introduction** | https://reui.io/docs |
|
||||
| **llms.txt** | https://reui.io/llms.txt |
|
||||
| **Get Started** | https://reui.io/docs/get-started |
|
||||
| **Styling** | https://reui.io/docs/styling |
|
||||
| **Registry** | https://reui.io/docs/registry |
|
||||
| **MCP** | https://reui.io/docs/mcp |
|
||||
| **Agent Skills** | https://reui.io/docs/agent-skills |
|
||||
| **Cursor MCP** | https://reui.io/docs/cursor |
|
||||
| **Blocks** | https://reui.io/blocks |
|
||||
| **Settings blocks** | https://reui.io/blocks/application/settings |
|
||||
| **License** | https://reui.io/docs/license-setup |
|
||||
@@ -27,6 +31,10 @@ alwaysApply: true
|
||||
|
||||
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
|
||||
|
||||
**Registry (актуально):** 20 free components — `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` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.claude/skills/reui` (v `668fb463eb`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||
|
||||
**Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции.
|
||||
|
||||
## Когда ReUI vs shadcn
|
||||
|
||||
| Задача | Registry | Импорт |
|
||||
@@ -39,7 +47,8 @@ alwaysApply: true
|
||||
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
|
||||
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
|
||||
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
|
||||
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
|
||||
| Number / date / autocomplete / color / kanban / timeline / stepper / tree | `@reui` | `@/components/reui/*` |
|
||||
| Event Calendar / Gantt / Icon Tile (registry, 20 free) | `@reui` | CLI `@reui/<name>` → `@/components/reui/*` при установке |
|
||||
|
||||
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
|
||||
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
|
||||
@@ -66,9 +75,9 @@ alwaysApply: true
|
||||
|
||||
## Установленные ReUI (apps/web)
|
||||
|
||||
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
|
||||
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
|
||||
|
||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
|
||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
|
||||
|
||||
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
|
||||
|
||||
@@ -101,6 +110,7 @@ REUI_LICENSE_KEY=
|
||||
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
|
||||
- Смешивать Card и Frame на ops-экране
|
||||
- Ставить shadcn/ui blocks выше ReUI PRO
|
||||
- **settings-7** как target (`surface: card`) — только [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16)
|
||||
|
||||
## Чеклист
|
||||
|
||||
|
||||
@@ -101,6 +101,9 @@ pnpm --filter @evobgp/web run build
|
||||
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
|
||||
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
|
||||
|
||||
**WEB-23** | NEVER | **settings-7** как target ([preview](https://reui.io/preview/base/settings-7) — `surface: card`). Новые settings-экраны — [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16) (`surface: frame`). Существующий `apps/web/src/components/settings/*` на DNA settings-7 **не** копировать как эталон; миграция — отдельный CHANGE.
|
||||
*Проверка:* review MCP `get_block` / cite; нет новых экранов с settings-7.
|
||||
|
||||
---
|
||||
|
||||
## Documentation Sync (Web)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
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 `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 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
|
||||
@@ -58,7 +58,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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
# ReUI components
|
||||
|
||||
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
## data-grid (the flagship - read its API every time)
|
||||
|
||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
|
||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
||||
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||
|
||||
```tsx
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// add sorting/pagination/selection models per the API
|
||||
})
|
||||
|
||||
<DataGrid table={table} recordCount={data.length}>
|
||||
@@ -28,9 +27,9 @@ const table = useReactTable({
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||
|
||||
## event-calendar
|
||||
|
||||
@@ -312,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||
|
||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||
|
||||
## icon-tile
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconTile variant="elevated" size="lg">
|
||||
<PackageIcon />
|
||||
</IconTile>
|
||||
```
|
||||
|
||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
||||
|
||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||
|
||||
+8
-6
@@ -28,6 +28,8 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
|
||||
|
||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||
|
||||
Повтор упавшего **release** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-release).
|
||||
|
||||
### Секреты
|
||||
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
|
||||
@@ -35,11 +37,11 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
|
||||
### Теги образов
|
||||
|
||||
```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`.
|
||||
@@ -49,7 +51,7 @@ git.shts.su/<owner>/<имя>:sha-<full-sha>
|
||||
Пример:
|
||||
|
||||
```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).
|
||||
|
||||
@@ -303,6 +303,7 @@ jobs:
|
||||
- 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
|
||||
@@ -317,14 +318,28 @@ jobs:
|
||||
- name: Semantic release
|
||||
run: npx semantic-release
|
||||
env:
|
||||
GITEA_URL: https://git.shts.su
|
||||
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
|
||||
echo "version=$(tr -d '[:space:]' < .release-version)" >> "$GITHUB_OUTPUT"
|
||||
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"
|
||||
@@ -348,20 +363,20 @@ jobs:
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shts.su
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
|
||||
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.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
|
||||
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
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 `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 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
|
||||
@@ -58,7 +58,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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
# ReUI components
|
||||
|
||||
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
## data-grid (the flagship - read its API every time)
|
||||
|
||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
|
||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
||||
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||
|
||||
```tsx
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// add sorting/pagination/selection models per the API
|
||||
})
|
||||
|
||||
<DataGrid table={table} recordCount={data.length}>
|
||||
@@ -28,9 +27,9 @@ const table = useReactTable({
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||
|
||||
## event-calendar
|
||||
|
||||
@@ -312,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||
|
||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||
|
||||
## icon-tile
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconTile variant="elevated" size="lg">
|
||||
<PackageIcon />
|
||||
</IconTile>
|
||||
```
|
||||
|
||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
||||
|
||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@
|
||||
[
|
||||
"@markwylde/semantic-release-gitea",
|
||||
{
|
||||
"giteaUrl": "https://git.shts.su",
|
||||
"giteaUrl": "https://git.shx.one",
|
||||
"assets": [
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
|
||||
@@ -73,4 +73,6 @@ pnpm --filter @evobgp/web run build
|
||||
|
||||
**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`.
|
||||
|
||||
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills). Skill: `.claude/skills/reui` (`668fb463eb`).
|
||||
|
||||
UI-задачи начинаются с MCP `user-reui` (`surface: "frame"`) + `plugin-shadcn-shadcn`, затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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'
|
||||
@@ -40,9 +42,10 @@ export function ChartDonutMetric({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-6', className)}>
|
||||
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
|
||||
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="count"
|
||||
@@ -75,7 +78,7 @@ export function ChartDonutMetric({
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<ul className="min-w-0 flex-1 space-y-3">
|
||||
<ul className="flex w-full min-w-0 max-w-xs flex-col gap-3 sm:w-auto sm:min-w-[10rem]">
|
||||
{slices.map((slice) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
|
||||
@@ -51,7 +51,7 @@ export function NetworkOverviewAnalyticsCard({
|
||||
label: 'Offline',
|
||||
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 ? (
|
||||
|
||||
@@ -1,62 +1,2 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from "@evobgp/ui/components/field"
|
||||
|
||||
interface SettingRowProps {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
titleAddon?: ReactNode
|
||||
}
|
||||
|
||||
export function SettingRow({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
last,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
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">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
{titleAddon}
|
||||
</div>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||
>
|
||||
<div className="flex w-full justify-start @md/field-group:justify-end">
|
||||
{children}
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */
|
||||
export { SettingRow } from '@/components/settings/setting-row'
|
||||
|
||||
@@ -1,53 +1,2 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
interface SettingsCardProps {
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
footerClassName?: string
|
||||
}
|
||||
|
||||
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
|
||||
export function SettingsCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
contentClassName,
|
||||
footerClassName,
|
||||
}: SettingsCardProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full 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>
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</FrameHeader>
|
||||
|
||||
<div className={cn('min-w-0', contentClassName)}>{children}</div>
|
||||
|
||||
{footer ? (
|
||||
<FrameFooter
|
||||
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
|
||||
>
|
||||
{footer}
|
||||
</FrameFooter>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
/** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */
|
||||
export { SettingsCard } from '@/components/settings/settings-card'
|
||||
|
||||
@@ -1,37 +1,2 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
|
||||
interface SettingsFieldGroupProps {
|
||||
legend: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
fieldGroupClassName?: string
|
||||
}
|
||||
|
||||
export function SettingsFieldGroup({
|
||||
legend,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
fieldGroupClassName,
|
||||
}: SettingsFieldGroupProps) {
|
||||
return (
|
||||
<FieldSet className={cn("gap-0", className)}>
|
||||
<FieldLegend className="sr-only">{legend}</FieldLegend>
|
||||
{/* Description */}
|
||||
<FieldDescription className="sr-only">{description}</FieldDescription>
|
||||
{/* List */}
|
||||
<FieldGroup className={cn("gap-0", fieldGroupClassName)}>
|
||||
{children}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
/** @deprecated Import from `@/components/settings/settings-field-group` — settings-7 is not a target. */
|
||||
export { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@evobgp/ui/components/drawer'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { useState, type ReactElement, type ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
confirmDrawerContentClassName,
|
||||
DrawerActionsFooter,
|
||||
} from '@/components/drawer-layout'
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@evobgp/ui/components/alert-dialog'
|
||||
|
||||
type ConfirmDialogBaseProps = {
|
||||
title: string
|
||||
@@ -41,90 +38,52 @@ type ConfirmDialogControlled = ConfirmDialogBaseProps & {
|
||||
|
||||
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
|
||||
|
||||
function ConfirmDrawerBody({
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Подтвердить',
|
||||
cancelLabel = 'Отмена',
|
||||
destructive,
|
||||
onConfirm,
|
||||
confirmDisabled,
|
||||
confirmLoading,
|
||||
confirmLoadingLabel,
|
||||
controlled,
|
||||
}: ConfirmDialogBaseProps & { controlled?: boolean }) {
|
||||
const confirmText =
|
||||
confirmLoading && confirmLoadingLabel
|
||||
? confirmLoadingLabel
|
||||
: confirmLoading
|
||||
? `${confirmLabel}…`
|
||||
: confirmLabel
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerHeader className="shrink-0 border-b border-border pb-4">
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||
</DrawerHeader>
|
||||
<DrawerActionsFooter>
|
||||
<DrawerClose render={<Button variant="outline" disabled={confirmLoading} />}>
|
||||
{cancelLabel}
|
||||
</DrawerClose>
|
||||
{controlled ? (
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
disabled={confirmDisabled || confirmLoading}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
) : (
|
||||
<DrawerClose
|
||||
render={
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
disabled={confirmDisabled || confirmLoading}
|
||||
/>
|
||||
}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</DrawerClose>
|
||||
)}
|
||||
</DrawerActionsFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Confirm overlay — Alert Dialog. Preview: https://reui.io/preview/base/components/c-alert-dialog-11 */
|
||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const bodyProps: ConfirmDialogBaseProps = {
|
||||
title: props.title,
|
||||
description: props.description,
|
||||
confirmLabel: props.confirmLabel,
|
||||
cancelLabel: props.cancelLabel,
|
||||
destructive: props.destructive,
|
||||
onConfirm: props.onConfirm,
|
||||
confirmDisabled: props.confirmDisabled,
|
||||
confirmLoading: props.confirmLoading,
|
||||
confirmLoadingLabel: props.confirmLoadingLabel,
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
|
||||
const isControlled = props.open !== undefined
|
||||
const open = isControlled ? props.open : uncontrolledOpen
|
||||
|
||||
function setOpen(next: boolean) {
|
||||
if (!isControlled) setUncontrolledOpen(next)
|
||||
if (isControlled) props.onOpenChange?.(next)
|
||||
}
|
||||
|
||||
if (props.trigger) {
|
||||
return (
|
||||
<Drawer swipeDirection="right">
|
||||
<DrawerTrigger render={props.trigger} />
|
||||
<DrawerContent className={confirmDrawerContentClassName}>
|
||||
<ConfirmDrawerBody {...bodyProps} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
const confirmText =
|
||||
props.confirmLoading && props.confirmLoadingLabel
|
||||
? props.confirmLoadingLabel
|
||||
: props.confirmLoading
|
||||
? `${props.confirmLabel ?? 'Подтвердить'}…`
|
||||
: (props.confirmLabel ?? 'Подтвердить')
|
||||
|
||||
function handleConfirm() {
|
||||
props.onConfirm()
|
||||
if (!props.confirmLoading && !isControlled) setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
|
||||
<DrawerContent className={confirmDrawerContentClassName}>
|
||||
<ConfirmDrawerBody {...bodyProps} controlled />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
{props.trigger ? <AlertDialogTrigger render={props.trigger} /> : null}
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{props.title}</AlertDialogTitle>
|
||||
{props.description ? (
|
||||
<AlertDialogDescription>{props.description}</AlertDialogDescription>
|
||||
) : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={props.confirmLoading}>
|
||||
{props.cancelLabel ?? 'Отмена'}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant={props.destructive ? 'destructive' : 'default'}
|
||||
disabled={props.confirmDisabled || props.confirmLoading}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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,6 +165,7 @@ export function DashboardModulesGrid({
|
||||
<DashboardFramePanel
|
||||
title="Модули"
|
||||
description="Поиск, сортировка и быстрый переход к настройке"
|
||||
className="min-w-0"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
||||
<PlusIcon />
|
||||
@@ -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-сообществах.',
|
||||
to: '/lookup',
|
||||
icon: <Search aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
@@ -23,7 +23,7 @@ const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'communities',
|
||||
title: 'BGP-сообщества',
|
||||
description: 'Справочник communities для политик экспорта.',
|
||||
description: 'Справочник BGP-сообществ для политик экспорта.',
|
||||
to: '/directories',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
@@ -31,7 +31,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'
|
||||
@@ -49,7 +49,7 @@ export function DashboardRecentJobsGrid({
|
||||
[nameById],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
const { table, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => {
|
||||
@@ -63,16 +63,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}>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from '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 { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
useCreateCommunityMutation,
|
||||
useUpdateCommunityMutation,
|
||||
} from '@/queries/directories'
|
||||
import type { BgpCommunity, BgpCommunityCreate, BgpCommunityPatch } from '@/types/api'
|
||||
|
||||
interface CommunityFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editTarget?: BgpCommunity | null
|
||||
}
|
||||
|
||||
/** @see https://reui.io/preview/base/form-7 */
|
||||
export function CommunityFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editTarget = null,
|
||||
}: CommunityFormDialogProps) {
|
||||
const createMutation = useCreateCommunityMutation()
|
||||
const updateMutation = useUpdateCommunityMutation()
|
||||
const saving = createMutation.isPending || updateMutation.isPending
|
||||
const [community, setCommunity] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (editTarget) {
|
||||
setCommunity(editTarget.community ?? '')
|
||||
setTitle(editTarget.title ?? '')
|
||||
} else {
|
||||
setCommunity('')
|
||||
setTitle('')
|
||||
}
|
||||
}, [editTarget, open])
|
||||
|
||||
async function save() {
|
||||
const value = community.trim()
|
||||
if (!value) {
|
||||
toast.error('Укажите community')
|
||||
return
|
||||
}
|
||||
const titleTrimmed = title.trim()
|
||||
try {
|
||||
if (editTarget) {
|
||||
const body: BgpCommunityPatch = {
|
||||
community: value,
|
||||
title: titleTrimmed || '',
|
||||
}
|
||||
await updateMutation.mutateAsync({ id: editTarget.id, body })
|
||||
} else {
|
||||
const body: BgpCommunityCreate = { community: value }
|
||||
if (titleTrimmed) body.title = titleTrimmed
|
||||
await createMutation.mutateAsync(body)
|
||||
}
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={editTarget ? 'Редактировать сообщество' : 'Новое сообщество BGP'}
|
||||
description="Тег для префиксов в фильтрах BIRD"
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={saving} onClick={() => void save()}>
|
||||
{editTarget ? 'Сохранить' : 'Создать'}
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="comm-value">Community</Label>
|
||||
<Input
|
||||
id="comm-value"
|
||||
placeholder="65000:100"
|
||||
value={community}
|
||||
onChange={(e) => setCommunity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="comm-title">Название (опционально)</Label>
|
||||
<Input
|
||||
id="comm-title"
|
||||
placeholder="Отображаемое имя"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Use CommunityFormDialog */
|
||||
export const CommunityCreateDialog = CommunityFormDialog
|
||||
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { BgpCommunity } from '@/types/api'
|
||||
@@ -11,12 +12,16 @@ import type { BgpCommunity } from '@/types/api'
|
||||
export function DirectoriesCommunitiesGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
canWrite = false,
|
||||
onEdit,
|
||||
}: {
|
||||
items: BgpCommunity[]
|
||||
isLoading?: boolean
|
||||
canWrite?: boolean
|
||||
onEdit?: (row: BgpCommunity) => void
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<BgpCommunity>[]>(
|
||||
() => [
|
||||
const columns = useMemo<ColumnDef<BgpCommunity>[]>(() => {
|
||||
const cols: ColumnDef<BgpCommunity>[] = [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
@@ -38,9 +43,23 @@ export function DirectoriesCommunitiesGrid({
|
||||
cell: () => <CategoryBadge>community</CategoryBadge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
]
|
||||
|
||||
if (canWrite && onEdit) {
|
||||
cols.push({
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
|
||||
),
|
||||
meta: { headerTitle: 'Действия' },
|
||||
})
|
||||
}
|
||||
|
||||
return cols
|
||||
}, [canWrite, onEdit])
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { DohProfile } from '@/types/api'
|
||||
@@ -11,12 +12,16 @@ import type { DohProfile } from '@/types/api'
|
||||
export function DirectoriesDohGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
canWrite = false,
|
||||
onEdit,
|
||||
}: {
|
||||
items: DohProfile[]
|
||||
isLoading?: boolean
|
||||
canWrite?: boolean
|
||||
onEdit?: (row: DohProfile) => void
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DohProfile>[]>(
|
||||
() => [
|
||||
const columns = useMemo<ColumnDef<DohProfile>[]>(() => {
|
||||
const cols: ColumnDef<DohProfile>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.url,
|
||||
@@ -43,9 +48,23 @@ export function DirectoriesDohGrid({
|
||||
cell: () => <CategoryBadge>—</CategoryBadge>,
|
||||
meta: { headerTitle: 'По умолчанию' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
]
|
||||
|
||||
if (canWrite && onEdit) {
|
||||
cols.push({
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
|
||||
),
|
||||
meta: { headerTitle: 'Действия' },
|
||||
})
|
||||
}
|
||||
|
||||
return cols
|
||||
}, [canWrite, onEdit])
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MoreHorizontalIcon, PencilIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
|
||||
/**
|
||||
* Data-grid row actions — ⋯ menu (ReUI / shadcn DropdownMenu).
|
||||
* @see https://reui.io/preview/base/components/c-dropdown-menu-12
|
||||
* @see https://reui.io/docs/components/base/dropdown-menu
|
||||
*/
|
||||
export function DirectoriesRowActions({ onEdit }: { onEdit: () => void }) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="Действия">
|
||||
<MoreHorizontalIcon className="size-4" aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<PencilIcon aria-hidden />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from '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 { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
useCreateDohProfileMutation,
|
||||
useUpdateDohProfileMutation,
|
||||
} from '@/queries/directories'
|
||||
import type { DohProfile, DohProfileCreate, DohProfilePatch } from '@/types/api'
|
||||
|
||||
interface DohProfileFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editTarget?: DohProfile | null
|
||||
}
|
||||
|
||||
/** @see https://reui.io/preview/base/form-7 */
|
||||
export function DohProfileFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editTarget = null,
|
||||
}: DohProfileFormDialogProps) {
|
||||
const createMutation = useCreateDohProfileMutation()
|
||||
const updateMutation = useUpdateDohProfileMutation()
|
||||
const saving = createMutation.isPending || updateMutation.isPending
|
||||
const [name, setName] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [timeoutMs, setTimeoutMs] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (editTarget) {
|
||||
setName(editTarget.name ?? '')
|
||||
setUrl(editTarget.url ?? '')
|
||||
setTimeoutMs(
|
||||
editTarget.timeout_ms === null || editTarget.timeout_ms === undefined
|
||||
? ''
|
||||
: String(editTarget.timeout_ms),
|
||||
)
|
||||
} else {
|
||||
setName('')
|
||||
setUrl('')
|
||||
setTimeoutMs('')
|
||||
}
|
||||
}, [editTarget, open])
|
||||
|
||||
async function save() {
|
||||
const trimmedUrl = url.trim()
|
||||
if (!trimmedUrl) {
|
||||
toast.error('Укажите URL DoH')
|
||||
return
|
||||
}
|
||||
let urlOk = true
|
||||
try {
|
||||
new URL(trimmedUrl)
|
||||
} catch {
|
||||
urlOk = false
|
||||
}
|
||||
if (!urlOk) {
|
||||
toast.error('Некорректный URL')
|
||||
return
|
||||
}
|
||||
|
||||
let timeout: number | null = null
|
||||
if (timeoutMs.trim() !== '') {
|
||||
const ms = Number(timeoutMs)
|
||||
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
|
||||
toast.error('Timeout должен быть целым числом > 0')
|
||||
return
|
||||
}
|
||||
timeout = ms
|
||||
}
|
||||
|
||||
const nameTrimmed = name.trim()
|
||||
|
||||
try {
|
||||
if (editTarget) {
|
||||
const body: DohProfilePatch = {
|
||||
url: trimmedUrl,
|
||||
name: nameTrimmed || undefined,
|
||||
timeout_ms: timeout,
|
||||
}
|
||||
await updateMutation.mutateAsync({ id: editTarget.id, body })
|
||||
} else {
|
||||
const body: DohProfileCreate = { url: trimmedUrl }
|
||||
if (nameTrimmed) body.name = nameTrimmed
|
||||
if (timeout !== null) body.timeout_ms = timeout
|
||||
await createMutation.mutateAsync(body)
|
||||
}
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={editTarget ? 'Редактировать DoH-профиль' : 'Новый DoH-профиль'}
|
||||
description="Резолвер DNS-over-HTTPS для доменных модулей"
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={saving} onClick={() => void save()}>
|
||||
{editTarget ? 'Сохранить' : 'Создать'}
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-name">Имя (опционально)</Label>
|
||||
<Input
|
||||
id="doh-name"
|
||||
placeholder="Control D / AdGuard"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-url">URL</Label>
|
||||
<Input
|
||||
id="doh-url"
|
||||
type="url"
|
||||
placeholder="https://dns.example/dns-query"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-timeout">Timeout, мс (опционально)</Label>
|
||||
<Input
|
||||
id="doh-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="5000"
|
||||
value={timeoutMs}
|
||||
onChange={(e) => setTimeoutMs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Use DohProfileFormDialog */
|
||||
export const DohProfileCreateDialog = DohProfileFormDialog
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { DrawerFooter } from '@evobgp/ui/components/drawer'
|
||||
|
||||
/** Shared footer layout for right-side form and confirm drawers. */
|
||||
export function DrawerActionsFooter({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<DrawerFooter
|
||||
className={cn(
|
||||
'mt-0 shrink-0 border-t border-border bg-muted/50 p-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
{children}
|
||||
</div>
|
||||
</DrawerFooter>
|
||||
)
|
||||
}
|
||||
|
||||
export const formDrawerContentClassName =
|
||||
'flex h-full max-h-dvh flex-col sm:max-w-lg'
|
||||
|
||||
export const confirmDrawerContentClassName = 'flex h-auto max-h-dvh flex-col sm:max-w-sm'
|
||||
@@ -1,28 +1,103 @@
|
||||
/**
|
||||
* ReUI Empty + IconStack — Frame-friendly (empty-state-14 / empty-state-12).
|
||||
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
|
||||
* Docs: https://reui.io/docs/components/base/icon-stack
|
||||
*/
|
||||
import { InboxIcon, type LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { IconStack } from '@/components/reui/icon-stack'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@evobgp/ui/components/empty'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon | ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
icon?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
stackedIcon?: boolean
|
||||
centered?: boolean
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
|
||||
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
|
||||
if (typeof icon === 'function') return true
|
||||
if (icon != null && typeof icon === 'object' && '$$typeof' in icon) {
|
||||
if ('props' in icon) return false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
stackedIcon = true,
|
||||
centered = true,
|
||||
}: EmptyStateProps) {
|
||||
const Icon = isLucideIcon(icon) ? icon : InboxIcon
|
||||
const customIcon = icon && !isLucideIcon(icon) ? icon : null
|
||||
|
||||
const body = (
|
||||
<Empty
|
||||
className={cn(
|
||||
'max-w-md flex-none border-0 bg-transparent p-0',
|
||||
!centered && className,
|
||||
)}
|
||||
>
|
||||
<EmptyHeader className="gap-5 text-center">
|
||||
<EmptyMedia className="mb-0">
|
||||
{customIcon ? (
|
||||
stackedIcon ? (
|
||||
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
|
||||
{customIcon}
|
||||
</IconStack>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{customIcon}</div>
|
||||
)
|
||||
) : stackedIcon ? (
|
||||
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
|
||||
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
|
||||
</IconStack>
|
||||
) : (
|
||||
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</EmptyMedia>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
|
||||
{description ? (
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
|
||||
) : null}
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
{action ? (
|
||||
<EmptyContent className="mt-1 items-center justify-center">{action}</EmptyContent>
|
||||
) : null}
|
||||
</Empty>
|
||||
)
|
||||
|
||||
if (!centered) return body
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
|
||||
'flex w-full flex-1 items-center justify-center self-stretch py-14 sm:py-16',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,18 +2,13 @@ import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@evobgp/ui/components/drawer'
|
||||
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
||||
|
||||
import {
|
||||
DrawerActionsFooter,
|
||||
formDrawerContentClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evobgp/ui/components/sheet'
|
||||
|
||||
interface FormDrawerProps {
|
||||
open: boolean
|
||||
@@ -25,6 +20,7 @@ interface FormDrawerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
|
||||
export function FormDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -35,17 +31,22 @@ export function FormDrawer({
|
||||
className,
|
||||
}: FormDrawerProps) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
|
||||
<DrawerContent className={cn(formDrawerContentClassName, className)}>
|
||||
<DrawerHeader className="shrink-0 border-b border-border pb-4">
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||
</DrawerHeader>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-4 px-4 py-4">{children}</div>
|
||||
</ScrollArea>
|
||||
<DrawerActionsFooter>{footer}</DrawerActionsFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b border-border/50">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
|
||||
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
{footer}
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Settings,
|
||||
BookText,
|
||||
KeyRound,
|
||||
ServerCog,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -47,6 +46,7 @@ import { CommandPalette, type CommandPaletteItem } from '@/components/layout/com
|
||||
import { NavUser } from '@/components/layout/nav-user'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
@@ -96,8 +96,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
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' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -161,6 +160,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<AppSwitcher />
|
||||
@@ -196,7 +198,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<SidebarInset id="main-content">
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
@@ -220,9 +222,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||
import type {
|
||||
BgpCommunity,
|
||||
LookupQueryKind,
|
||||
LookupResponse,
|
||||
ModuleRow,
|
||||
} from '@/types/api'
|
||||
|
||||
/**
|
||||
* Lookup wizard step 3 — module + community (bare content for single Frame).
|
||||
* @see https://reui.io/preview/base/wizard-2
|
||||
*/
|
||||
|
||||
function hostPrefixFromIp(ip: string): string {
|
||||
return ip.includes(':') ? `${ip}/128` : `${ip}/32`
|
||||
}
|
||||
|
||||
function moduleTypeForKind(kind: LookupQueryKind): ModuleRow['type'] {
|
||||
return kind === 'domain' ? 'DOMAINS' : 'IP_RANGES'
|
||||
}
|
||||
|
||||
export function LookupAddStep({
|
||||
data,
|
||||
modules,
|
||||
communities,
|
||||
onCancel,
|
||||
onAdded,
|
||||
}: {
|
||||
data: LookupResponse
|
||||
modules: ModuleRow[]
|
||||
communities: BgpCommunity[]
|
||||
onCancel: () => void
|
||||
onAdded: () => void | Promise<void>
|
||||
}) {
|
||||
const wantedType = moduleTypeForKind(data.query_kind)
|
||||
const eligible = useMemo(
|
||||
() => modules.filter((m) => m.type === wantedType),
|
||||
[modules, wantedType],
|
||||
)
|
||||
|
||||
const [moduleId, setModuleId] = useState('')
|
||||
const [communityId, setCommunityId] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (eligible.length === 0) {
|
||||
setModuleId('')
|
||||
return
|
||||
}
|
||||
setModuleId((prev) =>
|
||||
prev && eligible.some((m) => m.id === prev) ? prev : eligible[0]!.id,
|
||||
)
|
||||
}, [eligible])
|
||||
|
||||
useEffect(() => {
|
||||
const mod = eligible.find((m) => m.id === moduleId)
|
||||
if (!mod) {
|
||||
setCommunityId(null)
|
||||
return
|
||||
}
|
||||
setCommunityId(mod.default_community_id ?? null)
|
||||
}, [moduleId, eligible])
|
||||
|
||||
const moduleItems = useMemo(
|
||||
() =>
|
||||
eligible.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.name,
|
||||
})),
|
||||
[eligible],
|
||||
)
|
||||
|
||||
async function handleAdd() {
|
||||
if (!moduleId) {
|
||||
toast.error('Выберите модуль')
|
||||
return
|
||||
}
|
||||
if (data.query_kind !== 'domain' && !communityId) {
|
||||
toast.error('Укажите community')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (data.query_kind === 'domain') {
|
||||
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', {
|
||||
fqdn: data.normalized,
|
||||
community_id: communityId,
|
||||
})
|
||||
toast.success('Домен добавлен')
|
||||
} else {
|
||||
const prefix =
|
||||
data.query_kind === 'cidr'
|
||||
? data.normalized
|
||||
: hostPrefixFromIp(data.normalized)
|
||||
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', {
|
||||
prefix,
|
||||
community_id: communityId,
|
||||
})
|
||||
toast.success('Префикс добавлен')
|
||||
}
|
||||
await onAdded()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (eligible.length === 0) {
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
||||
<AlertDescription>
|
||||
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
|
||||
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
|
||||
Перейти к модулям
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const valueLabel =
|
||||
data.query_kind === 'domain'
|
||||
? data.normalized
|
||||
: data.query_kind === 'cidr'
|
||||
? data.normalized
|
||||
: hostPrefixFromIp(data.normalized)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-semibold">Добавить в списки</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
«{valueLabel}» отсутствует в списках. Выберите модуль и community.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lookup-add-module">Модуль ({wantedType})</FieldLabel>
|
||||
<SelectMenu
|
||||
id="lookup-add-module"
|
||||
items={moduleItems}
|
||||
value={moduleId}
|
||||
placeholder="Выберите модуль"
|
||||
onValueChange={(v) => {
|
||||
if (v) setModuleId(v)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<CommunitySelect
|
||||
id="lookup-add-comm"
|
||||
label="Community"
|
||||
value={communityId}
|
||||
onValueChange={setCommunityId}
|
||||
communities={communities}
|
||||
nullable={data.query_kind === 'domain'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Назад
|
||||
</Button>
|
||||
<LoadingButton
|
||||
loading={saving}
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!moduleId || (data.query_kind !== 'domain' && !communityId)}
|
||||
>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
@@ -12,7 +11,8 @@ import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { LookupMatch } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Lookup matches grid — data-grid-filtering-2 pattern.
|
||||
* Lookup matches grid — bare section for wizard-2 single Frame.
|
||||
* @see https://reui.io/preview/base/wizard-2
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
@@ -113,10 +113,13 @@ export function LookupMatchesGrid({
|
||||
})
|
||||
|
||||
return (
|
||||
<FrameDataGrid
|
||||
title="Совпадения"
|
||||
description="Entries и snapshots · клик по строке открывает модуль"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h3 className="text-sm font-semibold">Совпадения</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Entries и snapshots · клик по строке открывает модуль
|
||||
</p>
|
||||
</div>
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
@@ -129,6 +132,6 @@ export function LookupMatchesGrid({
|
||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
||||
}
|
||||
/>
|
||||
</FrameDataGrid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,17 +8,10 @@ import {
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
/**
|
||||
* Lookup search form — Frame + InputGroup (form-7 pattern).
|
||||
* @see https://reui.io/preview/base/form-7
|
||||
* Lookup search fields — bare form for wizard-2 single-Frame surface.
|
||||
* @see https://reui.io/preview/base/wizard-2
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function LookupSearchForm({
|
||||
@@ -40,37 +33,27 @@ export function LookupSearchForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Проверка списка</FrameTitle>
|
||||
<FrameDescription>
|
||||
IP или FQDN — поиск в entries и материализованных snapshots с community.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<Field className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor="lookup-q">IP или домен</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Search aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="lookup-q"
|
||||
name="q"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="8.8.8.8 или example.com"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Button type="submit" disabled={isPending || !value.trim()}>
|
||||
Проверить
|
||||
</Button>
|
||||
</form>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<Field className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor="lookup-q">IP, CIDR или домен</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Search aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="lookup-q"
|
||||
name="q"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="8.8.8.8, 203.0.113.0/24 или example.com"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Button type="submit" disabled={isPending || !value.trim()}>
|
||||
Проверить
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CircleAlert, CircleCheck, ShieldAlert } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
|
||||
/**
|
||||
* Lookup result status plaque — ReUI Alert + Badge.
|
||||
* @see https://reui.io/docs/components/base/alert
|
||||
* @see https://reui.io/preview/base/components/c-alert-6
|
||||
* @see https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function LookupStatusBanner({
|
||||
status,
|
||||
normalized,
|
||||
matchCount = 0,
|
||||
}: {
|
||||
status: 'found' | 'missing' | 'missing_denied'
|
||||
normalized: string
|
||||
matchCount?: number
|
||||
}) {
|
||||
if (status === 'found') {
|
||||
return (
|
||||
<Alert variant="success" className="items-start py-3">
|
||||
<CircleCheck aria-hidden />
|
||||
<AlertTitle className="line-clamp-none flex flex-wrap items-center gap-2">
|
||||
Найдено
|
||||
<Badge variant="success-light" size="sm" radius="full">
|
||||
в списках
|
||||
</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
«{normalized}» — {matchCount} совп. в entries / snapshots.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'missing_denied') {
|
||||
return (
|
||||
<Alert variant="warning" className="items-start py-3">
|
||||
<ShieldAlert aria-hidden />
|
||||
<AlertTitle className="line-clamp-none flex flex-wrap items-center gap-2">
|
||||
Не найдено
|
||||
<Badge variant="warning-light" size="sm" radius="full">
|
||||
нет прав
|
||||
</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
«{normalized}» отсутствует в списках. Для добавления нужно право{' '}
|
||||
<span className="font-medium text-foreground">bgp:modules:write</span>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="warning" className="items-start py-3">
|
||||
<CircleAlert aria-hidden />
|
||||
<AlertTitle className="line-clamp-none flex flex-wrap items-center gap-2">
|
||||
Не найдено
|
||||
<Badge variant="warning-light" size="sm" radius="full">
|
||||
можно добавить
|
||||
</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
«{normalized}» отсутствует в entries и snapshots. Добавить запись в модуль?
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Globe, Layers, ListChecks, Radar } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import type { LookupResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Lookup summary KPI — stats-12 via KpiStatGrid.
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function LookupSummaryKpi({ data }: { data: LookupResponse }) {
|
||||
const entryCount = data.matches.filter((m) => m.layer === 'entry').length
|
||||
const snapshotCount = data.matches.filter((m) => m.layer === 'snapshot').length
|
||||
const resolvedCount = data.resolved_ips?.length ?? 0
|
||||
|
||||
const items: KpiStatItem[] = [
|
||||
{
|
||||
id: 'matched',
|
||||
label: 'Результат',
|
||||
value: data.matched ? 'Найдено' : 'Не найдено',
|
||||
hint: data.normalized,
|
||||
icon: <Radar aria-hidden />,
|
||||
iconClassName: data.matched
|
||||
? 'bg-success text-success-foreground [&_svg]:text-success-foreground'
|
||||
: 'bg-muted text-muted-foreground [&_svg]:text-muted-foreground',
|
||||
variant: data.matched ? 'default' : 'warning',
|
||||
},
|
||||
{
|
||||
id: 'entry',
|
||||
label: 'Слой entry',
|
||||
value: entryCount,
|
||||
hint: 'сырые списки',
|
||||
icon: <ListChecks aria-hidden />,
|
||||
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
|
||||
},
|
||||
{
|
||||
id: 'snapshot',
|
||||
label: 'Слой snapshot',
|
||||
value: snapshotCount,
|
||||
hint: 'материализация',
|
||||
icon: <Layers aria-hidden />,
|
||||
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
|
||||
},
|
||||
]
|
||||
|
||||
if (data.query_kind === 'domain') {
|
||||
items.push({
|
||||
id: 'resolved',
|
||||
label: 'DNS IP',
|
||||
value: resolvedCount,
|
||||
hint: resolvedCount > 0 ? data.resolved_ips?.slice(0, 3).join(', ') : 'нет A/AAAA',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<KpiStatGrid
|
||||
items={items}
|
||||
aria-label={`Запрос: ${data.query_kind} · ${data.query}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, Search } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { LookupAddStep } from '@/components/lookup/lookup-add-step'
|
||||
import { LookupMatchesGrid } from '@/components/lookup/lookup-matches-grid'
|
||||
import { LookupSearchForm } from '@/components/lookup/lookup-search-form'
|
||||
import { LookupStatusBanner } from '@/components/lookup/lookup-status-banner'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import {
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperDescription,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperNav,
|
||||
StepperPanel,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from '@/components/reui/stepper'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { sessionCanWriteModules } from '@/lib/auth'
|
||||
import { authSessionQueryOptions } from '@/queries/auth'
|
||||
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
|
||||
import { lookupKeys, lookupQueryOptions } from '@/queries/lookup'
|
||||
import { modulesListQueryOptions } from '@/queries/modules'
|
||||
|
||||
/**
|
||||
* Lookup membership wizard — one Frame (wizard-2), all content inside.
|
||||
* @see https://reui.io/preview/base/wizard-2
|
||||
* @see https://reui.io/docs/components/base/stepper
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
|
||||
const STEP_QUERY = 1
|
||||
const STEP_RESULT = 2
|
||||
const STEP_ADD = 3
|
||||
const STEP_DONE = 4
|
||||
|
||||
function WizardActions({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LookupWizard({
|
||||
q,
|
||||
onSubmitQuery,
|
||||
}: {
|
||||
q: string
|
||||
onSubmitQuery: (next: string) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const trimmed = q.trim()
|
||||
const lookupQ = useQuery(lookupQueryOptions(trimmed))
|
||||
const sessionQ = useQuery(authSessionQueryOptions())
|
||||
|
||||
const canWrite = sessionCanWriteModules(sessionQ.data)
|
||||
const [step, setStep] = useState(trimmed ? STEP_RESULT : STEP_QUERY)
|
||||
const [offerAdd, setOfferAdd] = useState(false)
|
||||
const loadCatalog = step === STEP_ADD || step === STEP_DONE
|
||||
|
||||
const modulesQ = useQuery({
|
||||
...modulesListQueryOptions(),
|
||||
enabled: loadCatalog,
|
||||
})
|
||||
const communitiesQ = useQuery({
|
||||
...directoriesCommunitiesQueryOptions(),
|
||||
enabled: loadCatalog,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setOfferAdd(false)
|
||||
setStep(trimmed ? STEP_RESULT : STEP_QUERY)
|
||||
}, [trimmed])
|
||||
|
||||
function resetToQuery() {
|
||||
setOfferAdd(false)
|
||||
setStep(STEP_QUERY)
|
||||
onSubmitQuery('')
|
||||
}
|
||||
|
||||
function goToAdd() {
|
||||
setOfferAdd(true)
|
||||
setStep(STEP_ADD)
|
||||
}
|
||||
|
||||
async function handleAdded() {
|
||||
setStep(STEP_DONE)
|
||||
await queryClient.invalidateQueries({ queryKey: lookupKeys.query(trimmed) })
|
||||
await lookupQ.refetch()
|
||||
}
|
||||
|
||||
const data = lookupQ.data
|
||||
const notFound = data != null && !data.matched
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FramePanel className="flex flex-col gap-6">
|
||||
<FrameHeader className="px-0 py-0">
|
||||
<FrameTitle>Мастер проверки</FrameTitle>
|
||||
<FrameDescription>
|
||||
Проверка IP / CIDR / домена в списках, затем при необходимости — добавление.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<Stepper
|
||||
value={step}
|
||||
onValueChange={setStep}
|
||||
className="flex flex-col gap-6"
|
||||
indicators={{
|
||||
completed: <Check className="size-4" />,
|
||||
}}
|
||||
>
|
||||
<StepperNav>
|
||||
<StepperItem step={STEP_QUERY} completed={step > STEP_QUERY}>
|
||||
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||
<StepperIndicator>{STEP_QUERY}</StepperIndicator>
|
||||
<div className="flex flex-col items-start">
|
||||
<StepperTitle>Запрос</StepperTitle>
|
||||
<StepperDescription className="hidden sm:block">
|
||||
IP, CIDR или FQDN
|
||||
</StepperDescription>
|
||||
</div>
|
||||
</StepperTrigger>
|
||||
<StepperSeparator className="max-sm:hidden" />
|
||||
</StepperItem>
|
||||
<StepperItem
|
||||
step={STEP_RESULT}
|
||||
completed={step > STEP_RESULT}
|
||||
disabled={!trimmed}
|
||||
>
|
||||
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||
<StepperIndicator>{STEP_RESULT}</StepperIndicator>
|
||||
<div className="flex flex-col items-start">
|
||||
<StepperTitle>Результат</StepperTitle>
|
||||
<StepperDescription className="hidden sm:block">
|
||||
Есть в списках?
|
||||
</StepperDescription>
|
||||
</div>
|
||||
</StepperTrigger>
|
||||
<StepperSeparator className="max-sm:hidden" />
|
||||
</StepperItem>
|
||||
<StepperItem
|
||||
step={STEP_ADD}
|
||||
completed={step > STEP_ADD}
|
||||
disabled={!offerAdd && step !== STEP_ADD && step !== STEP_DONE}
|
||||
>
|
||||
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||
<StepperIndicator>{STEP_ADD}</StepperIndicator>
|
||||
<div className="flex flex-col items-start">
|
||||
<StepperTitle>Добавление</StepperTitle>
|
||||
<StepperDescription className="hidden sm:block">
|
||||
Модуль и community
|
||||
</StepperDescription>
|
||||
</div>
|
||||
</StepperTrigger>
|
||||
<StepperSeparator className="max-sm:hidden" />
|
||||
</StepperItem>
|
||||
<StepperItem step={STEP_DONE} completed={step === STEP_DONE}>
|
||||
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||
<StepperIndicator>{STEP_DONE}</StepperIndicator>
|
||||
<div className="flex flex-col items-start">
|
||||
<StepperTitle>Готово</StepperTitle>
|
||||
<StepperDescription className="hidden sm:block">
|
||||
Подтверждение
|
||||
</StepperDescription>
|
||||
</div>
|
||||
</StepperTrigger>
|
||||
</StepperItem>
|
||||
</StepperNav>
|
||||
|
||||
<StepperPanel>
|
||||
<StepperContent value={STEP_QUERY}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<LookupSearchForm
|
||||
key={`form-${trimmed}`}
|
||||
initialQuery={trimmed}
|
||||
isPending={lookupQ.isFetching}
|
||||
onSubmit={onSubmitQuery}
|
||||
/>
|
||||
{!trimmed ? (
|
||||
<EmptyState
|
||||
icon={<Search className="size-8" />}
|
||||
title="Введите IP, CIDR или домен"
|
||||
description="Например 8.8.8.8, 203.0.113.0/24 или example.com — проверка по entries и snapshots."
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</StepperContent>
|
||||
|
||||
<StepperContent value={STEP_RESULT}>
|
||||
{!trimmed ? (
|
||||
<EmptyState
|
||||
icon={<Search className="size-8" />}
|
||||
title="Сначала выполните проверку"
|
||||
description="Вернитесь к шагу «Запрос» и укажите значение."
|
||||
/>
|
||||
) : (
|
||||
<QueryState
|
||||
data={lookupQ.data}
|
||||
isLoading={lookupQ.isLoading}
|
||||
isError={lookupQ.isError}
|
||||
error={lookupQ.error}
|
||||
onRetry={() => void lookupQ.refetch()}
|
||||
skeleton={<TableSkeleton rows={5} />}
|
||||
>
|
||||
{(result) => (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<LookupSearchForm
|
||||
key={`result-form-${trimmed}`}
|
||||
initialQuery={trimmed}
|
||||
isPending={lookupQ.isFetching}
|
||||
onSubmit={onSubmitQuery}
|
||||
/>
|
||||
|
||||
{result.matched ? (
|
||||
<>
|
||||
<LookupStatusBanner
|
||||
status="found"
|
||||
normalized={result.normalized}
|
||||
matchCount={result.match_count}
|
||||
/>
|
||||
<LookupMatchesGrid
|
||||
items={result.matches}
|
||||
isLoading={lookupQ.isFetching}
|
||||
/>
|
||||
<WizardActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetToQuery}
|
||||
>
|
||||
Новая проверка
|
||||
</Button>
|
||||
</WizardActions>
|
||||
</>
|
||||
) : canWrite ? (
|
||||
<>
|
||||
<LookupStatusBanner
|
||||
status="missing"
|
||||
normalized={result.normalized}
|
||||
/>
|
||||
<WizardActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetToQuery}
|
||||
>
|
||||
Новая проверка
|
||||
</Button>
|
||||
<Button type="button" onClick={() => goToAdd()}>
|
||||
Добавить
|
||||
</Button>
|
||||
</WizardActions>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LookupStatusBanner
|
||||
status="missing_denied"
|
||||
normalized={result.normalized}
|
||||
/>
|
||||
<WizardActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetToQuery}
|
||||
>
|
||||
Новая проверка
|
||||
</Button>
|
||||
</WizardActions>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
)}
|
||||
</StepperContent>
|
||||
|
||||
<StepperContent value={STEP_ADD}>
|
||||
{data && notFound ? (
|
||||
<LookupAddStep
|
||||
data={data}
|
||||
modules={modulesQ.data?.items ?? []}
|
||||
communities={communitiesQ.data?.items ?? []}
|
||||
onCancel={() => {
|
||||
setOfferAdd(false)
|
||||
setStep(STEP_RESULT)
|
||||
}}
|
||||
onAdded={handleAdded}
|
||||
/>
|
||||
) : (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Добавление недоступно</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сначала выполните проверку для значения, которого ещё нет в списках.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</StepperContent>
|
||||
|
||||
<StepperContent value={STEP_DONE}>
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<Alert variant="success" className="items-start py-3">
|
||||
<Check aria-hidden />
|
||||
<AlertTitle className="line-clamp-none">Запись добавлена</AlertTitle>
|
||||
<AlertDescription>
|
||||
Повторная проверка обновлена. Можно посмотреть совпадения или
|
||||
начать новый запрос.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{lookupQ.data?.matched ? (
|
||||
<LookupMatchesGrid
|
||||
items={lookupQ.data.matches}
|
||||
isLoading={lookupQ.isFetching}
|
||||
/>
|
||||
) : null}
|
||||
<WizardActions>
|
||||
<Button type="button" variant="outline" onClick={resetToQuery}>
|
||||
Новая проверка
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setStep(STEP_RESULT)}>
|
||||
К результату
|
||||
</Button>
|
||||
</WizardActions>
|
||||
</div>
|
||||
</StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
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,
|
||||
moduleDohProfileIds,
|
||||
} from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
|
||||
import { useUpdateModuleMutation } from '@/queries/modules'
|
||||
import type {
|
||||
BgpCommunity,
|
||||
DohProfile,
|
||||
DohResolverPolicy,
|
||||
ModulePatch,
|
||||
ModuleRow,
|
||||
} from '@/types/api'
|
||||
|
||||
interface ModuleEditDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
mod: ModuleRow
|
||||
communities: BgpCommunity[]
|
||||
dohProfiles: DohProfile[]
|
||||
}
|
||||
|
||||
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 ModuleEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mod,
|
||||
communities,
|
||||
dohProfiles,
|
||||
}: ModuleEditDialogProps) {
|
||||
const updateMutation = useUpdateModuleMutation()
|
||||
const isDomains = mod.type === 'DOMAINS'
|
||||
|
||||
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[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setName(mod.name ?? '')
|
||||
setEnabled(mod.enabled !== false)
|
||||
setPriority(String(mod.priority ?? 0))
|
||||
setRefreshIntervalSec(
|
||||
mod.refresh_interval_sec === null || mod.refresh_interval_sec === undefined
|
||||
? ''
|
||||
: String(mod.refresh_interval_sec),
|
||||
)
|
||||
setCronExpr(mod.cron_expr ?? '')
|
||||
setDefaultCommunityId(mod.default_community_id ?? null)
|
||||
setDohResolverPolicy(mod.doh_resolver_policy ?? 'primary_only')
|
||||
setDohProfileIds(moduleDohProfileIds(mod))
|
||||
}, [mod, 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 | null = null
|
||||
if (refreshIntervalSec.trim() !== '') {
|
||||
const n = Number(refreshIntervalSec)
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
toast.error('Интервал обновления должен быть целым числом ≥ 0')
|
||||
return
|
||||
}
|
||||
refresh = n
|
||||
}
|
||||
|
||||
const body: ModulePatch = {
|
||||
name: trimmedName,
|
||||
enabled,
|
||||
priority: priorityNum,
|
||||
refresh_interval_sec: refresh,
|
||||
cron_expr: cronExpr.trim() || null,
|
||||
default_community_id: defaultCommunityId,
|
||||
}
|
||||
|
||||
if (isDomains) {
|
||||
body.doh_resolver_policy = dohResolverPolicy
|
||||
body.doh_profile_ids = dohProfileIds
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: mod.id, body })
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Редактировать модуль"
|
||||
description={`${moduleTypeRu(mod.type)} · ${mod.type}`}
|
||||
className="sm:max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={updateMutation.isPending} onClick={() => void save()}>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-name">Название</Label>
|
||||
<Input
|
||||
id="mod-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-enabled">Включён</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выключенный модуль не участвует в refresh и apply.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="mod-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-priority">Приоритет</Label>
|
||||
<Input
|
||||
id="mod-priority"
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-interval">Интервал обновления (сек)</Label>
|
||||
<Input
|
||||
id="mod-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-cron">Cron (опционально)</Label>
|
||||
<Input
|
||||
id="mod-cron"
|
||||
placeholder="0 * * * *"
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CommunitySelect
|
||||
id="mod-community"
|
||||
label="Community по умолчанию"
|
||||
value={defaultCommunityId}
|
||||
onValueChange={setDefaultCommunityId}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
|
||||
{isDomains ? (
|
||||
<>
|
||||
<SelectField
|
||||
id="mod-doh-policy"
|
||||
label="Политика DoH"
|
||||
items={DOH_POLICY_ITEMS}
|
||||
value={dohResolverPolicy}
|
||||
onValueChange={(v) => setDohResolverPolicy(v as DohResolverPolicy)}
|
||||
/>
|
||||
|
||||
<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-doh-${p.id}`}
|
||||
className="flex cursor-pointer items-start gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={`mod-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>
|
||||
)
|
||||
}
|
||||
@@ -2,29 +2,25 @@ import { Clock, Gauge, Globe, Tags } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||
import {
|
||||
communityLabel,
|
||||
dohProfileLabel,
|
||||
moduleDohProfileIds,
|
||||
} from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
||||
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||
import { communityLabel, moduleDohProfileIds } from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||
import type { AsEntry, BgpCommunity, ModuleRow } from '@/types/api'
|
||||
|
||||
interface ModuleKpiCardsProps {
|
||||
mod: ModuleRow | null
|
||||
communities: BgpCommunity[]
|
||||
dohProfiles: DohProfile[]
|
||||
asEntries: AsEntry[]
|
||||
entriesCount?: number
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ModuleKpiCards({
|
||||
mod,
|
||||
communities,
|
||||
dohProfiles,
|
||||
asEntries,
|
||||
entriesCount = 0,
|
||||
loading = false,
|
||||
}: ModuleKpiCardsProps) {
|
||||
if (loading || !mod) {
|
||||
@@ -33,6 +29,49 @@ export function ModuleKpiCards({
|
||||
|
||||
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||
const dohIds = moduleDohProfileIds(mod)
|
||||
const isDomains = mod.type === 'DOMAINS'
|
||||
const isAsPrefixes = mod.type === 'AS_PREFIXES'
|
||||
const community = communityLabel(mod.default_community_id, communities)
|
||||
|
||||
const entriesFooter = isAsPrefixes ? (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{asEntries.length} AS · в модуле
|
||||
</Badge>
|
||||
) : isDomains ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
{mod.default_community_id ? community : 'без community'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{entriesCount} · в модуле
|
||||
</Badge>
|
||||
)
|
||||
|
||||
const policyItem: KpiStatItem = isDomains
|
||||
? {
|
||||
id: 'doh',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
||||
label: 'DoH',
|
||||
footer: (
|
||||
<Badge variant={dohIds.length > 0 ? 'info-light' : 'outline'} size="sm">
|
||||
{dohIds.length > 0 ? dohPolicyRu(mod.doh_resolver_policy) : 'без DoH'}
|
||||
</Badge>
|
||||
),
|
||||
}
|
||||
: {
|
||||
id: 'community',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: community,
|
||||
label: 'Community',
|
||||
footer: (
|
||||
<Badge variant="outline" size="sm">
|
||||
по умолчанию
|
||||
</Badge>
|
||||
),
|
||||
}
|
||||
|
||||
const items: KpiStatItem[] = [
|
||||
{
|
||||
@@ -63,29 +102,11 @@ export function ModuleKpiCards({
|
||||
id: 'prefixes',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
|
||||
label: mod.type === 'AS_PREFIXES' ? 'Префиксы AS' : 'Записи модуля',
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{asEntries.length} AS · в модуле
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'policy',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
||||
label: communityLabel(mod.default_community_id, communities),
|
||||
footer: (
|
||||
<Badge variant="info-light" size="sm">
|
||||
{dohPolicyRu(mod.doh_resolver_policy)}
|
||||
{dohIds.length > 0
|
||||
? ` · ${dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')}`
|
||||
: ' · без DoH'}
|
||||
</Badge>
|
||||
),
|
||||
value: isAsPrefixes ? String(asPrefixTotal) : String(entriesCount),
|
||||
label: isAsPrefixes ? 'Префиксы AS' : 'Записи модуля',
|
||||
footer: entriesFooter,
|
||||
},
|
||||
policyItem,
|
||||
]
|
||||
|
||||
return <KpiStatGrid items={items} aria-label="KPI модуля" />
|
||||
|
||||
@@ -7,12 +7,20 @@ import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { jobStatusRu, readyCheckRu } from '@/lib/ui-labels'
|
||||
import {
|
||||
isReadyCheckOk,
|
||||
isSystemReady,
|
||||
readyCheckStatusLabel,
|
||||
} from '@/lib/metrics'
|
||||
import { readyCheckRu } from '@/lib/ui-labels'
|
||||
import type { ReadyStatus } from '@/queries/monitoring'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
const READY_CHECK_ICONS: Record<string, typeof Database> = {
|
||||
postgres: Database,
|
||||
store: HardDrive,
|
||||
store_backend: HardDrive,
|
||||
jobs: ListTodo,
|
||||
}
|
||||
|
||||
@@ -21,6 +29,7 @@ interface ReadyCheckRow {
|
||||
label: string
|
||||
subtitle?: string
|
||||
icon: typeof Database
|
||||
iconClassName?: string
|
||||
status: string
|
||||
statusLabel: string
|
||||
}
|
||||
@@ -34,12 +43,14 @@ export function MonitoringReadyGrid({
|
||||
}) {
|
||||
const data = useMemo<ReadyCheckRow[]>(() => {
|
||||
const checks = ready.checks ?? {}
|
||||
const systemReady = isSystemReady(ready.status)
|
||||
const rows: ReadyCheckRow[] = [
|
||||
{
|
||||
id: 'liveness',
|
||||
label: 'Живучесть',
|
||||
subtitle: '/v1/health',
|
||||
icon: HeartPulse,
|
||||
iconClassName: health?.ok ? 'text-success' : 'text-destructive',
|
||||
status: health?.ok ? 'ok' : 'error',
|
||||
statusLabel: health?.ok ? 'В норме' : 'Ошибка',
|
||||
},
|
||||
@@ -48,19 +59,21 @@ export function MonitoringReadyGrid({
|
||||
label: 'Готовность',
|
||||
subtitle: '/v1/ready',
|
||||
icon: ShieldCheck,
|
||||
status: ready.status === 'ok' ? 'ok' : 'warning',
|
||||
statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'),
|
||||
iconClassName: systemReady ? 'text-success' : 'text-warning',
|
||||
status: systemReady ? 'ok' : 'warning',
|
||||
statusLabel: systemReady ? 'Готов' : 'Не готов',
|
||||
},
|
||||
]
|
||||
for (const key of Object.keys(checks)) {
|
||||
const value = checks[key]
|
||||
const ok = typeof value === 'boolean' ? value : value?.ok !== false
|
||||
const ok = isReadyCheckOk(value)
|
||||
rows.push({
|
||||
id: key,
|
||||
label: readyCheckRu(key),
|
||||
icon: READY_CHECK_ICONS[key] ?? ListTodo,
|
||||
iconClassName: ok ? 'text-success' : 'text-destructive',
|
||||
status: ok ? 'ok' : 'error',
|
||||
statusLabel: ok ? 'В норме' : 'Ошибка',
|
||||
statusLabel: readyCheckStatusLabel(value, ok),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
@@ -74,8 +87,17 @@ export function MonitoringReadyGrid({
|
||||
cell: ({ row }) => {
|
||||
const Icon = row.original.icon
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 items-center gap-2.5 py-0.5">
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-3.5',
|
||||
row.original.iconClassName,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<Icon />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.label}
|
||||
subtitle={row.original.subtitle}
|
||||
@@ -90,7 +112,9 @@ export function MonitoringReadyGrid({
|
||||
enableSorting: false,
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
||||
<div className="flex justify-start">
|
||||
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Plus, SearchIcon, ActivityIcon } from 'lucide-react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Plus, SearchIcon, ActivityIcon, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
|
||||
import {
|
||||
getPeerFilterFieldValue,
|
||||
@@ -15,22 +17,28 @@ import {
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
||||
import { useDeletePeerMutation } from '@/queries/network'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* BGP peers list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
|
||||
function peerDeleteLabel(p: PeerRow): string {
|
||||
return p.name?.trim() || p.neighbor || p.id
|
||||
}
|
||||
|
||||
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: 'Established', label: 'Установлена' },
|
||||
{ value: 'Idle', label: 'Idle' },
|
||||
{ value: 'Active', label: 'Active' },
|
||||
{ value: 'Connect', label: 'Connect' },
|
||||
@@ -89,7 +97,9 @@ export function NetworkPeersCard({
|
||||
error,
|
||||
onRetry,
|
||||
}: NetworkPeersCardProps) {
|
||||
const deleteMutation = useDeletePeerMutation()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<PeerRow | null>(null)
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
|
||||
|
||||
const addButton = useMemo(
|
||||
@@ -102,6 +112,33 @@ export function NetworkPeersCard({
|
||||
[],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<PeerRow, unknown>[]>(
|
||||
() => [
|
||||
...peerColumns,
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
aria-label="Удалить пира"
|
||||
title="Удалить"
|
||||
onClick={() => setDeleteTarget(row.original)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResourcePage
|
||||
@@ -114,7 +151,7 @@ export function NetworkPeersCard({
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultPeerFilters())}
|
||||
getFilterFieldValue={getPeerFilterFieldValue}
|
||||
columns={peerColumns}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
@@ -129,7 +166,34 @@ export function NetworkPeersCard({
|
||||
}}
|
||||
/>
|
||||
|
||||
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
|
||||
<PeerFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
speakers={speakers}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null)
|
||||
}}
|
||||
title="Удалить пира?"
|
||||
description={
|
||||
deleteTarget
|
||||
? `${peerDeleteLabel(deleteTarget)} будет удалён. Запустится reconcile пиров и apply на спикеры.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
confirmLoadingLabel="Удаление…"
|
||||
destructive
|
||||
confirmLoading={deleteMutation.isPending}
|
||||
onConfirm={() => {
|
||||
if (!deleteTarget) return
|
||||
void deleteMutation
|
||||
.mutateAsync(deleteTarget.id)
|
||||
.then(() => setDeleteTarget(null))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@evobgp/ui/components/chart'
|
||||
import type { BreakdownSlice } from '@/lib/metrics'
|
||||
|
||||
/** chart-27 / chart-13 inspired donut in PanelCard. */
|
||||
/** chart-27 / chart-13 inspired donut in PanelCard (Frame surface). */
|
||||
export function DonutBreakdownCard({
|
||||
title,
|
||||
description,
|
||||
@@ -30,13 +30,24 @@ export function DonutBreakdownCard({
|
||||
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
|
||||
|
||||
return (
|
||||
<PanelCard title={title} description={description} className="h-full">
|
||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center">
|
||||
<PanelCard
|
||||
title={title}
|
||||
description={description}
|
||||
className="h-full"
|
||||
actions={
|
||||
badge ? (
|
||||
<Badge variant="success-light" className="hidden sm:inline-flex">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-start gap-4 p-4 sm:flex-row sm:items-center sm:justify-start sm:gap-6">
|
||||
{total === 0 ? (
|
||||
<p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative mx-auto size-36 shrink-0">
|
||||
<div className="relative mx-auto size-36 shrink-0 sm:mx-0">
|
||||
<ChartContainer config={chartConfig} className="aspect-square size-36">
|
||||
<PieChart>
|
||||
<Pie
|
||||
@@ -59,11 +70,11 @@ export function DonutBreakdownCard({
|
||||
<span className="text-lg font-semibold tabular-nums">{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-2">
|
||||
<ul className="flex w-full min-w-0 max-w-xs flex-col gap-2 sm:w-auto sm:min-w-[10rem]">
|
||||
{slices.map((slice) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<li key={slice.key} className="flex items-center justify-between gap-2 text-sm">
|
||||
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
@@ -81,11 +92,6 @@ export function DonutBreakdownCard({
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{badge ? (
|
||||
<Badge variant="success-light" className="absolute top-4 right-4 hidden sm:flex">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</PanelCard>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
import { IconStack } from '@/components/reui/icon-stack'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Frame,
|
||||
FramePanel,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
@@ -11,7 +14,7 @@ import {
|
||||
EmptyTitle,
|
||||
} from '@evobgp/ui/components/empty'
|
||||
|
||||
/** empty-state-11 pattern adapted to Card surface. */
|
||||
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
|
||||
export function IllustratedEmptyState({
|
||||
title,
|
||||
description,
|
||||
@@ -24,8 +27,8 @@ export function IllustratedEmptyState({
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-muted min-h-[240px] p-0 shadow-none">
|
||||
<CardContent className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
||||
{action ? <div className="w-full self-end">{action}</div> : null}
|
||||
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
||||
<EmptyHeader className="items-center gap-5 text-center">
|
||||
@@ -40,7 +43,7 @@ export function IllustratedEmptyState({
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Badge } from '@/components/reui/badge'
|
||||
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
||||
import { PanelCorners } from '@/components/patterns/panel-corners'
|
||||
import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
|
||||
export type KpiSparklineMetric = {
|
||||
id: string
|
||||
@@ -44,21 +44,21 @@ function Sparkline({ values, color }: { values: readonly number[]; color: string
|
||||
)
|
||||
}
|
||||
|
||||
/** KPI card with sparkline (dashboard-5 / chart-15 pattern, Card surface). */
|
||||
/** KPI sparkline tile — chart-15 Frame DNA. Preview: https://reui.io/preview/base/chart-15 */
|
||||
export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
|
||||
const tone = toneStyles[metric.tone]
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
||||
<PanelCorners />
|
||||
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Frame dense spacing="sm" className="relative overflow-hidden">
|
||||
<FramePanel className="relative isolate flex min-h-[7.25rem] flex-col justify-between gap-5 overflow-hidden p-4">
|
||||
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
||||
<PanelCorners />
|
||||
<div className="relative z-10 flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm leading-4 font-semibold">{metric.title}</span>
|
||||
<span className="text-muted-foreground truncate text-xs leading-4">{metric.label}</span>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="min-w-0 space-y-2.5">
|
||||
<div className="relative z-10 flex items-end justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight tabular-nums">
|
||||
{metric.value}
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@ export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
|
||||
</div>
|
||||
<Sparkline values={metric.sparkline} color={tone.stroke} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,23 +34,29 @@ export function QueryState<T>({
|
||||
children,
|
||||
}: QueryStateProps<T>) {
|
||||
if (isLoading) {
|
||||
return <>{skeleton ?? <DefaultSkeleton />}</>
|
||||
return (
|
||||
<div role="status" aria-live="polite" aria-busy="true">
|
||||
{skeleton ?? <DefaultSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<AlertCircle className="size-8" />}
|
||||
title="Ошибка загрузки"
|
||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Повторить
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div role="alert">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="Ошибка загрузки"
|
||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Повторить
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (empty || data == null) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{item.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{item.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
@@ -308,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>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
|
||||
@@ -54,16 +54,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{action.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{action.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
{action.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
|
||||
@@ -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,104 +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: string
|
||||
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)
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to={tab.to}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
/**
|
||||
* CSS variable architecture:
|
||||
*
|
||||
* The root owns four variables so every part of the tile stays in proportion
|
||||
* and stays overridable from a single `className`:
|
||||
*
|
||||
* --icon-tile-size tile width/height
|
||||
* --icon-tile-icon-size glyph size applied to child svgs
|
||||
* --icon-tile-radius corner radius (also drives the nested inner card)
|
||||
* --icon-tile-inset gap between the outer ring and the inner card
|
||||
*
|
||||
* The `frame` and `soft` variants paint their inner card with an `::after`
|
||||
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
|
||||
* context, so the negative z-index pseudo paints above the root background but
|
||||
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
|
||||
*
|
||||
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
|
||||
* a single text color class (e.g. `text-success`) retints the whole tile. They
|
||||
* default to `text-primary`; override it to recolor without touching internals.
|
||||
*/
|
||||
const iconTileVariants = cva(
|
||||
[
|
||||
"relative inline-flex shrink-0 items-center justify-center align-middle",
|
||||
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
/** Plain bordered surface. The quiet default for list rows and toolbars. */
|
||||
outline: "border border-border bg-background dark:bg-input/32",
|
||||
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
|
||||
elevated:
|
||||
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
|
||||
/**
|
||||
* Tinted double container: an opacity-filled outer ring with no border
|
||||
* around a bordered inner card, all derived from `currentColor`. The
|
||||
* quiet, colorful sibling of `frame`. Retint with a text color class.
|
||||
*/
|
||||
soft: [
|
||||
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
|
||||
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||
"after:border after:border-current/20 after:bg-current/5",
|
||||
],
|
||||
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
|
||||
solid: "bg-primary text-primary-foreground",
|
||||
/** Double container - a muted ring around an inset card, matching Frame. */
|
||||
frame: [
|
||||
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
|
||||
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||
"after:border after:border-border after:bg-card after:shadow-xs",
|
||||
],
|
||||
},
|
||||
size: {
|
||||
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
|
||||
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
|
||||
default:
|
||||
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
|
||||
},
|
||||
/**
|
||||
* `default`: active style radius. `full`: circular.
|
||||
* The plain value is the fallback outside a style scope; the `style-*`
|
||||
* tokens win by specificity inside one, and survive the registry
|
||||
* transform as the single resolved value per generated style.
|
||||
*
|
||||
* Each style token is clamped to a fraction of the tile. A flat radius
|
||||
* is a circle once it reaches half the box, so the soft styles used to
|
||||
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
|
||||
* `full` variant's meaning. Clamping keeps one corner ratio at every
|
||||
* size instead, so a tile still reads as the same shape when it scales.
|
||||
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
|
||||
*/
|
||||
radius: {
|
||||
default:
|
||||
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
|
||||
full: "[--icon-tile-radius:calc(infinity*1px)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "outline",
|
||||
size: "default",
|
||||
radius: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface IconTileProps extends useRender.ComponentProps<"span"> {
|
||||
variant?: VariantProps<typeof iconTileVariants>["variant"]
|
||||
size?: VariantProps<typeof iconTileVariants>["size"]
|
||||
radius?: VariantProps<typeof iconTileVariants>["radius"]
|
||||
}
|
||||
|
||||
function IconTile({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
radius = "default",
|
||||
render,
|
||||
...props
|
||||
}: IconTileProps) {
|
||||
const defaultProps = {
|
||||
"data-slot": "icon-tile",
|
||||
"data-variant": variant,
|
||||
"data-size": size,
|
||||
className: cn(iconTileVariants({ variant, size, radius, className })),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
render,
|
||||
props: mergeProps<"span">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export { IconTile, iconTileVariants, type IconTileProps }
|
||||
@@ -0,0 +1,470 @@
|
||||
import type { HTMLAttributes, ReactElement } from "react"
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
// Types
|
||||
type StepperOrientation = "horizontal" | "vertical"
|
||||
type StepState = "active" | "completed" | "inactive" | "loading"
|
||||
type StepIndicators = {
|
||||
active?: React.ReactNode
|
||||
completed?: React.ReactNode
|
||||
inactive?: React.ReactNode
|
||||
loading?: React.ReactNode
|
||||
}
|
||||
|
||||
interface StepperContextValue {
|
||||
activeStep: number
|
||||
setActiveStep: (step: number) => void
|
||||
stepsCount: number
|
||||
orientation: StepperOrientation
|
||||
registerTrigger: (node: HTMLButtonElement | null) => void
|
||||
triggerNodes: HTMLButtonElement[]
|
||||
focusNext: (currentIdx: number) => void
|
||||
focusPrev: (currentIdx: number) => void
|
||||
focusFirst: () => void
|
||||
focusLast: () => void
|
||||
indicators: StepIndicators
|
||||
}
|
||||
|
||||
interface StepItemContextValue {
|
||||
step: number
|
||||
state: StepState
|
||||
isDisabled: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const StepperContext = createContext<StepperContextValue | undefined>(undefined)
|
||||
const StepItemContext = createContext<StepItemContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
function useStepper() {
|
||||
const ctx = useContext(StepperContext)
|
||||
if (!ctx) throw new Error("useStepper must be used within a Stepper")
|
||||
return ctx
|
||||
}
|
||||
|
||||
function useStepItem() {
|
||||
const ctx = useContext(StepItemContext)
|
||||
if (!ctx) throw new Error("useStepItem must be used within a StepperItem")
|
||||
return ctx
|
||||
}
|
||||
|
||||
interface StepperProps extends HTMLAttributes<HTMLDivElement> {
|
||||
defaultValue?: number
|
||||
value?: number
|
||||
onValueChange?: (value: number) => void
|
||||
orientation?: StepperOrientation
|
||||
indicators?: StepIndicators
|
||||
}
|
||||
|
||||
function Stepper({
|
||||
defaultValue = 1,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "horizontal",
|
||||
className,
|
||||
children,
|
||||
indicators = {},
|
||||
...props
|
||||
}: StepperProps) {
|
||||
const [activeStep, setActiveStep] = useState(defaultValue)
|
||||
const [triggerNodes, setTriggerNodes] = useState<HTMLButtonElement[]>([])
|
||||
|
||||
// Register/unregister triggers
|
||||
const registerTrigger = useCallback((node: HTMLButtonElement | null) => {
|
||||
setTriggerNodes((prev) => {
|
||||
if (node && !prev.includes(node)) {
|
||||
return [...prev, node]
|
||||
} else if (!node && prev.includes(node!)) {
|
||||
return prev.filter((n) => n !== node)
|
||||
} else {
|
||||
return prev
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSetActiveStep = useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setActiveStep(step)
|
||||
}
|
||||
onValueChange?.(step)
|
||||
},
|
||||
[value, onValueChange]
|
||||
)
|
||||
|
||||
const currentStep = value ?? activeStep
|
||||
|
||||
// Keyboard navigation logic
|
||||
const focusTrigger = (idx: number) => {
|
||||
if (triggerNodes[idx]) triggerNodes[idx].focus()
|
||||
}
|
||||
const focusNext = (currentIdx: number) =>
|
||||
focusTrigger((currentIdx + 1) % triggerNodes.length)
|
||||
const focusPrev = (currentIdx: number) =>
|
||||
focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length)
|
||||
const focusFirst = () => focusTrigger(0)
|
||||
const focusLast = () => focusTrigger(triggerNodes.length - 1)
|
||||
|
||||
// Context value
|
||||
const contextValue = useMemo<StepperContextValue>(
|
||||
() => ({
|
||||
activeStep: currentStep,
|
||||
setActiveStep: handleSetActiveStep,
|
||||
stepsCount: Children.toArray(children).filter(
|
||||
(child): child is ReactElement =>
|
||||
isValidElement(child) &&
|
||||
(child.type as { displayName?: string }).displayName === "StepperItem"
|
||||
).length,
|
||||
orientation,
|
||||
registerTrigger,
|
||||
focusNext,
|
||||
focusPrev,
|
||||
focusFirst,
|
||||
focusLast,
|
||||
triggerNodes,
|
||||
indicators,
|
||||
}),
|
||||
[
|
||||
currentStep,
|
||||
handleSetActiveStep,
|
||||
children,
|
||||
orientation,
|
||||
registerTrigger,
|
||||
triggerNodes,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<StepperContext.Provider value={contextValue}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-orientation={orientation}
|
||||
data-slot="stepper"
|
||||
className={cn("w-full", className)}
|
||||
data-orientation={orientation}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StepperContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
step: number
|
||||
completed?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
function StepperItem({
|
||||
step,
|
||||
completed = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StepperItemProps) {
|
||||
const { activeStep } = useStepper()
|
||||
|
||||
const state: StepState =
|
||||
completed || step < activeStep
|
||||
? "completed"
|
||||
: activeStep === step
|
||||
? "active"
|
||||
: "inactive"
|
||||
|
||||
const isLoading = loading && step === activeStep
|
||||
|
||||
return (
|
||||
<StepItemContext.Provider
|
||||
value={{ step, state, isDisabled: disabled, isLoading }}
|
||||
>
|
||||
<div
|
||||
data-slot="stepper-item"
|
||||
className={cn(
|
||||
"group/step flex items-center justify-center not-last:flex-1 group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col",
|
||||
className
|
||||
)}
|
||||
data-state={state}
|
||||
{...(isLoading ? { "data-loading": true } : {})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StepItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type StepperTriggerProps = useRender.ComponentProps<"button">
|
||||
|
||||
function StepperTrigger({
|
||||
className,
|
||||
children,
|
||||
tabIndex,
|
||||
render,
|
||||
...props
|
||||
}: StepperTriggerProps) {
|
||||
const { state, isLoading } = useStepItem()
|
||||
const stepperCtx = useStepper()
|
||||
const {
|
||||
setActiveStep,
|
||||
activeStep,
|
||||
registerTrigger,
|
||||
triggerNodes,
|
||||
focusNext,
|
||||
focusPrev,
|
||||
focusFirst,
|
||||
focusLast,
|
||||
} = stepperCtx
|
||||
const { step, isDisabled } = useStepItem()
|
||||
const isSelected = activeStep === step
|
||||
const id = `stepper-tab-${step}`
|
||||
const panelId = `stepper-panel-${step}`
|
||||
|
||||
// Register this trigger for keyboard navigation
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => {
|
||||
if (btnRef.current) {
|
||||
registerTrigger(btnRef.current)
|
||||
}
|
||||
}, [btnRef.current])
|
||||
|
||||
// Find our index among triggers for navigation
|
||||
const myIdx = useMemo(
|
||||
() =>
|
||||
triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
|
||||
[triggerNodes, btnRef.current]
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
e.preventDefault()
|
||||
if (myIdx !== -1 && focusNext) focusNext(myIdx)
|
||||
break
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
e.preventDefault()
|
||||
if (myIdx !== -1 && focusPrev) focusPrev(myIdx)
|
||||
break
|
||||
case "Home":
|
||||
e.preventDefault()
|
||||
if (focusFirst) focusFirst()
|
||||
break
|
||||
case "End":
|
||||
e.preventDefault()
|
||||
if (focusLast) focusLast()
|
||||
break
|
||||
case "Enter":
|
||||
case " ":
|
||||
e.preventDefault()
|
||||
setActiveStep(step)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
role: "tab",
|
||||
id,
|
||||
"aria-selected": isSelected,
|
||||
"aria-controls": panelId,
|
||||
tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1,
|
||||
"data-slot": "stepper-trigger",
|
||||
"data-state": state,
|
||||
"data-loading": isLoading,
|
||||
className: cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60",
|
||||
"gap-2.5 rounded-full",
|
||||
className
|
||||
),
|
||||
onClick: () => setActiveStep(step),
|
||||
onKeyDown: handleKeyDown,
|
||||
disabled: isDisabled,
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
render,
|
||||
ref: btnRef,
|
||||
props: mergeProps<"button">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
function StepperIndicator({
|
||||
children,
|
||||
className,
|
||||
}: React.ComponentProps<"div">) {
|
||||
const { state, isLoading } = useStepItem()
|
||||
const { indicators } = useStepper()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-indicator"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"border-background bg-accent text-accent-foreground data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground data-[state=active]:bg-primary data-[state=active]:text-primary-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden",
|
||||
"rounded-full text-xs",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="absolute">
|
||||
{indicators &&
|
||||
((isLoading && indicators.loading) ||
|
||||
(state === "completed" && indicators.completed) ||
|
||||
(state === "active" && indicators.active) ||
|
||||
(state === "inactive" && indicators.inactive))
|
||||
? (isLoading && indicators.loading) ||
|
||||
(state === "completed" && indicators.completed) ||
|
||||
(state === "active" && indicators.active) ||
|
||||
(state === "inactive" && indicators.inactive)
|
||||
: children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperSeparator({ className }: React.ComponentProps<"div">) {
|
||||
const { state } = useStepItem()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-separator"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"bg-muted rounded-sm m-0.5 group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1 group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperTitle({ children, className }: React.ComponentProps<"h3">) {
|
||||
const { state } = useStepItem()
|
||||
|
||||
return (
|
||||
<h3
|
||||
data-slot="stepper-title"
|
||||
data-state={state}
|
||||
className={cn("text-sm leading-none font-medium", className)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperDescription({
|
||||
children,
|
||||
className,
|
||||
}: React.ComponentProps<"div">) {
|
||||
const { state } = useStepItem()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-description"
|
||||
data-state={state}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperNav({ children, className }: React.ComponentProps<"nav">) {
|
||||
const { activeStep, orientation } = useStepper()
|
||||
|
||||
return (
|
||||
<nav
|
||||
data-slot="stepper-nav"
|
||||
data-state={activeStep}
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperPanel({ children, className }: React.ComponentProps<"div">) {
|
||||
const { activeStep } = useStepper()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-panel"
|
||||
data-state={activeStep}
|
||||
className={cn("w-full", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepperContentProps extends React.ComponentProps<"div"> {
|
||||
value: number
|
||||
forceMount?: boolean
|
||||
}
|
||||
|
||||
function StepperContent({
|
||||
value,
|
||||
forceMount,
|
||||
children,
|
||||
className,
|
||||
}: StepperContentProps) {
|
||||
const { activeStep } = useStepper()
|
||||
const isActive = value === activeStep
|
||||
|
||||
if (!forceMount && !isActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-content"
|
||||
data-state={activeStep}
|
||||
className={cn("w-full", className, !isActive && forceMount && "hidden")}
|
||||
hidden={!isActive && forceMount}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useStepper,
|
||||
useStepItem,
|
||||
Stepper,
|
||||
StepperItem,
|
||||
StepperTrigger,
|
||||
StepperIndicator,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperDescription,
|
||||
StepperPanel,
|
||||
StepperContent,
|
||||
StepperNav,
|
||||
type StepperProps,
|
||||
type StepperItemProps,
|
||||
type StepperTriggerProps,
|
||||
type StepperContentProps,
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useTheme } from 'next-themes'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||
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 {
|
||||
ToggleGroup,
|
||||
@@ -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
|
||||
|
||||
@@ -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: 'обнулить (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)',
|
||||
}
|
||||
|
||||
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 control plane"
|
||||
description="Глобальные параметры BIRD для pipeline refresh/apply. Сохранение — роль operator."
|
||||
footer={
|
||||
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-64" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="BIRD"
|
||||
description="Параметры демона и автообнаружения пиров."
|
||||
>
|
||||
{BIRD_SETTING_KEYS.map((key, index) => (
|
||||
<SettingsSettingField
|
||||
key={key}
|
||||
title={BIRD_LABELS[key]}
|
||||
description={BIRD_HINTS[key]}
|
||||
labelFor={key}
|
||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||
stacked
|
||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||
>
|
||||
{key === 'peer_discovery_enabled' ||
|
||||
key === 'peer_discovery_require_external' ? (
|
||||
<SelectMenu
|
||||
id={key}
|
||||
items={[...BIRD_BOOL_ITEMS]}
|
||||
value={birdForm[key] || 'false'}
|
||||
onValueChange={(v) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||
}
|
||||
placeholder="Выкл"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={key}
|
||||
className="w-full min-w-0"
|
||||
value={birdForm[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||
}
|
||||
placeholder={BIRD_LABELS[key]}
|
||||
/>
|
||||
)}
|
||||
</SettingsSettingField>
|
||||
))}
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Ревизии"
|
||||
description="Время хранения ревизий в БД"
|
||||
footer={
|
||||
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="Retention"
|
||||
description="Срок хранения ревизий в минутах."
|
||||
>
|
||||
<SettingsSettingField
|
||||
title="Retention"
|
||||
description="Сколько минут хранить ревизии конфигурации."
|
||||
labelFor="revision_retention_minutes"
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<Input
|
||||
id="revision_retention_minutes"
|
||||
type="number"
|
||||
className="w-full min-w-0"
|
||||
value={revisionForm.revision_retention_minutes ?? ''}
|
||||
onChange={(e) =>
|
||||
setRevisionForm((s) => ({
|
||||
...s,
|
||||
revision_retention_minutes: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
|
||||
<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,13 +1,14 @@
|
||||
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'
|
||||
|
||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||
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,12 +50,13 @@ 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}
|
||||
@@ -64,7 +65,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
||||
title="Подключение к API"
|
||||
description="Токен хранится только в этом браузере (localStorage)"
|
||||
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. Управление ключами tenant — в разделе{' '}
|
||||
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||
Права доступа
|
||||
</Link>
|
||||
@@ -97,12 +97,14 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
||||
</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-ключ"
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
import { SettingsCard } from '@/components/settings/settings-card'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
@@ -23,7 +23,8 @@ 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" />,
|
||||
@@ -32,6 +33,7 @@ const ADMIN_SECTIONS = [
|
||||
{
|
||||
id: 'access',
|
||||
to: '/access' as const,
|
||||
search: undefined,
|
||||
title: 'Права доступа',
|
||||
description: 'API-ключи tenant, роли и управление доступом.',
|
||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||
@@ -40,6 +42,7 @@ const ADMIN_SECTIONS = [
|
||||
{
|
||||
id: 'monitoring',
|
||||
to: '/monitoring' as const,
|
||||
search: undefined,
|
||||
title: 'Мониторинг',
|
||||
description: 'Метрики, состояние jobs и observability control plane.',
|
||||
icon: <ActivityIcon aria-hidden="true" />,
|
||||
@@ -49,7 +52,7 @@ const ADMIN_SECTIONS = [
|
||||
|
||||
export function SectionsSettingsTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard
|
||||
title="Разделы control plane"
|
||||
description="Параметры tenant и операции — отдельно от настроек браузера"
|
||||
@@ -58,7 +61,7 @@ export function SectionsSettingsTab() {
|
||||
{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>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { MonitorIcon } from 'lucide-react'
|
||||
|
||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||
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 { QueryState } from '@/components/query-state'
|
||||
import { TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
@@ -35,7 +35,7 @@ export function SessionSettingsTab() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
|
||||
<QueryState
|
||||
data={sessionQ.data}
|
||||
@@ -58,7 +58,7 @@ 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">
|
||||
Активна
|
||||
@@ -66,7 +66,9 @@ export function SessionSettingsTab() {
|
||||
</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>
|
||||
@@ -94,12 +96,13 @@ export function SessionSettingsTab() {
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
stacked
|
||||
last
|
||||
>
|
||||
{sessionQ.data ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-muted-foreground text-sm break-words">
|
||||
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
|
||||
<code className="font-mono text-xs">{sessionQ.data.tenant_id}</code>.
|
||||
<code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from '@evobgp/ui/components/field'
|
||||
|
||||
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 / 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={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>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
{titleAddon}
|
||||
</div>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription className="text-sm break-words">{description}</FieldDescription>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
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={cn(
|
||||
'flex w-full min-w-0 justify-start',
|
||||
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{separated && !last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
interface SettingsCardProps {
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
footerClassName?: string
|
||||
}
|
||||
|
||||
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 */
|
||||
export function SettingsCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
contentClassName,
|
||||
footerClassName,
|
||||
}: SettingsCardProps) {
|
||||
return (
|
||||
<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>
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</FrameHeader>
|
||||
|
||||
<div className={cn('min-w-0', contentClassName)}>{children}</div>
|
||||
|
||||
{footer ? (
|
||||
<FrameFooter
|
||||
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
|
||||
>
|
||||
{footer}
|
||||
</FrameFooter>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from '@evobgp/ui/components/field'
|
||||
|
||||
interface SettingsFieldGroupProps {
|
||||
legend: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
fieldGroupClassName?: string
|
||||
}
|
||||
|
||||
/** Settings field group — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */
|
||||
export function SettingsFieldGroup({
|
||||
legend,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
fieldGroupClassName,
|
||||
}: SettingsFieldGroupProps) {
|
||||
return (
|
||||
<FieldSet className={cn('gap-0', className)}>
|
||||
<FieldLegend className="sr-only">{legend}</FieldLegend>
|
||||
<FieldDescription className="sr-only">{description}</FieldDescription>
|
||||
<FieldGroup className={cn('gap-0', fieldGroupClassName)}>
|
||||
{children}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -283,6 +283,56 @@ export function sessionCanManageApiKeys(session: {
|
||||
return session.role === 'operator'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether session may create/update module entries (`bgp:modules:write`).
|
||||
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+.
|
||||
*/
|
||||
export function sessionCanWriteModules(session: {
|
||||
role?: string
|
||||
kind?: string
|
||||
is_admin?: boolean
|
||||
permissions?: readonly string[]
|
||||
} | null | undefined): boolean {
|
||||
if (!session) return false
|
||||
const jwtPath =
|
||||
session.kind === 'jwt' ||
|
||||
session.is_admin === true ||
|
||||
(session.permissions?.length ?? 0) > 0
|
||||
if (jwtPath) {
|
||||
return (
|
||||
session.is_admin === true ||
|
||||
hasPermission(session.permissions ?? [], 'bgp:modules:write')
|
||||
)
|
||||
}
|
||||
const role = (session.role ?? '').toLowerCase()
|
||||
return role === 'editor' || role === 'operator'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether session may create/update directories (`bgp:directories:write`).
|
||||
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+.
|
||||
*/
|
||||
export function sessionCanWriteDirectories(session: {
|
||||
role?: string
|
||||
kind?: string
|
||||
is_admin?: boolean
|
||||
permissions?: readonly string[]
|
||||
} | null | undefined): boolean {
|
||||
if (!session) return false
|
||||
const jwtPath =
|
||||
session.kind === 'jwt' ||
|
||||
session.is_admin === true ||
|
||||
(session.permissions?.length ?? 0) > 0
|
||||
if (jwtPath) {
|
||||
return (
|
||||
session.is_admin === true ||
|
||||
hasPermission(session.permissions ?? [], 'bgp:directories:write')
|
||||
)
|
||||
}
|
||||
const role = (session.role ?? '').toLowerCase()
|
||||
return role === 'editor' || role === 'operator'
|
||||
}
|
||||
|
||||
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
|
||||
export function permissionForPath(pathname: string): string | null {
|
||||
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
||||
@@ -296,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. */
|
||||
@@ -315,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>(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user