Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc803bcb34 | ||
|
|
e469c421ca | ||
|
|
094f75913b | ||
|
|
764a4e5b4e | ||
|
|
2d29a892f9 | ||
|
|
d9bec85c02 | ||
|
|
d56405af7b | ||
|
|
927e27640a | ||
|
|
5255cd2d30 | ||
|
|
3723ba7ed1 | ||
|
|
c5148ac4a0 | ||
|
|
e6e319a275 | ||
|
|
869b13cb57 | ||
|
|
e190785d4f | ||
|
|
3fd05ff833 | ||
|
|
e7f24f0be4 | ||
|
|
1bfe460e4b | ||
|
|
e55d2c5aba | ||
|
|
f63e9b5fd0 | ||
|
|
0148d4ca37 | ||
|
|
e0ecabb22f | ||
|
|
da301b1a94 | ||
|
|
653bc6cc91 | ||
|
|
e0d695f2a4 | ||
|
|
dfc33dcb99 | ||
|
|
44f70443c3 | ||
|
|
357ed4ce7b | ||
|
|
35e262b5ae | ||
|
|
8267141136 | ||
|
|
34a29ad337 | ||
|
|
b04b27c215 | ||
|
|
6a25a3d137 | ||
|
|
32d9dc6acb | ||
|
|
fc4f234cc5 | ||
|
|
821f342476 | ||
|
|
4634aac2d1 | ||
|
|
7953ae6e78 | ||
|
|
d14550cda1 | ||
|
|
632adaa63f | ||
|
|
cc80858952 | ||
|
|
b8dbf15254 | ||
|
|
f45b2bed90 | ||
|
|
b13c233679 | ||
|
|
37f28dfcfd | ||
|
|
6c6e76fca3 | ||
|
|
26f5172f88 | ||
|
|
8fe74c1d3b | ||
|
|
13e3d21ce2 | ||
|
|
a0cfbcdab1 | ||
|
|
c3369059af | ||
|
|
53ce80c9ff | ||
|
|
1e04e91dd8 | ||
|
|
5fbe9c9b56 | ||
|
|
fd3a217cbe | ||
|
|
ff6efec4c5 | ||
|
|
738d2e2256 | ||
|
|
2e3e1493f5 | ||
|
|
fd2fd8298d | ||
|
|
df7cd99060 | ||
|
|
c273cea067 | ||
|
|
1f969e6cac | ||
|
|
c1132cbe19 | ||
|
|
b28ad88b22 | ||
|
|
b871d62de6 | ||
|
|
4d83b8d673 | ||
|
|
2820cff988 | ||
|
|
54b7ea3bd5 | ||
|
|
57bcfcd9e1 | ||
|
|
1317a73e9a | ||
|
|
039d2f3dd9 | ||
|
|
54a0b5b966 | ||
|
|
1639ba40f3 | ||
|
|
26a96bc824 | ||
|
|
4fc5c96e63 | ||
|
|
3859983ae5 | ||
|
|
aa1779170a | ||
|
|
bbcf9d76d9 | ||
|
|
55eb2a6c89 | ||
|
|
a3f3ffd672 | ||
|
|
1a142e68a9 | ||
|
|
46e7b2aa6b | ||
|
|
1152bb40f7 | ||
|
|
538daea0f1 | ||
|
|
39f1295438 |
@@ -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 `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -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" } }
|
||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
@@ -52,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 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
## 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,37 @@ 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
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
@@ -79,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
@@ -284,6 +349,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)).
|
||||
|
||||
@@ -46,28 +46,31 @@ pnpm dlx shadcn@latest add @reui/data-grid # ReUI enterprise → apps/web/src/co
|
||||
|--------|----------|--------|
|
||||
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
|
||||
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
|
||||
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` |
|
||||
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → `reui-kit/ResourcePage` |
|
||||
| Мультифильтры | `@reui` | `@/components/reui/filters` |
|
||||
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
||||
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
||||
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
||||
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
||||
|
||||
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid (через `DataGridCard`), не shadcn Data Table.
|
||||
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid через `ResourcePage`, не shadcn Data Table.
|
||||
|
||||
## Уже установленные shared-обёртки
|
||||
|
||||
В `apps/web/src/components/`:
|
||||
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
|
||||
- `QueryState` — обёртка loading/error/empty для TanStack Query
|
||||
- `EmptyState` — пустые списки
|
||||
- `EmptyState` — пустые списки (empty-state-14 DNA)
|
||||
- `ConfirmDialog` — подтверждения (не `window.confirm`)
|
||||
- `LoadingButton` — кнопка с loading-состоянием
|
||||
- `StatusBadge` — статусные бейджи
|
||||
- `SectionCards` — сетка KPI-карточек
|
||||
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
|
||||
- `LoadingButton` — SoT кнопка с loading (`Button` + `Spinner`)
|
||||
- `StatusBadge` / `CategoryBadge` / `ModeBadge` — semantic ReUI Badge
|
||||
- `SegmentedTabs` — секции страницы (c-tabs-9)
|
||||
- `StatusToggleGroup` — статус грида (c-toggle-group-5)
|
||||
- `FormDrawer` — Sheet overlay (sheet-8 / form-7)
|
||||
- `Skeletons` (`TableSkeleton`, `KpiStatGridSkeleton`) — скелетоны
|
||||
- `TruncatedText` — текст с тултипом
|
||||
- `ModeToggle` — переключатель темы
|
||||
|
||||
Kit: `reui-kit/ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`, `FrameSection`.
|
||||
|
||||
Перед созданием новой обёртки — проверить существующие через Codegraph.
|
||||
|
||||
|
||||
@@ -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 `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -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" } }
|
||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
@@ -52,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 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
## 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,37 @@ 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
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
@@ -79,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
@@ -284,6 +349,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,6 +0,0 @@
|
||||
{
|
||||
"pid": 43636,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1783570299043
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101206}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101204}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138640}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138642}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605143758}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605144457}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605145907}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152669}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152671}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222064}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222067}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605819320}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605827145}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605864391}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606471768}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606472923}
|
||||
@@ -0,0 +1,8 @@
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613834659}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613834740}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613854903}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613854967}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613897698}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613897769}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614164222}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614181455}
|
||||
@@ -11,9 +11,9 @@
|
||||
]
|
||||
},
|
||||
"reui": {
|
||||
"url": "https://mcp.reui.io/api/mcp?style=base-nova",
|
||||
"url": "https://mcp.reui.io",
|
||||
"headers": {
|
||||
"X-Reui-Style": "base-nova"
|
||||
"Authorization": "Bearer <REUI_LICENSE_KEY>"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ alwaysApply: true
|
||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||
|------------|-------------|------------------|-------|
|
||||
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `npx @redocly/cli lint` |
|
||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `pnpm exec redocly lint` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ alwaysApply: true
|
||||
## Dependency Management
|
||||
|
||||
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
|
||||
*Проверка:* `go.mod`, `.gitea/workflows/ci.yaml`.
|
||||
*Проверка:* `go.mod`, `.gitea/workflows/quality.yaml`.
|
||||
|
||||
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
|
||||
*Проверка:* review.
|
||||
@@ -123,10 +123,10 @@ alwaysApply: true
|
||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||
*Проверка:* CI job `bird2`.
|
||||
|
||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`.
|
||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/quality.yaml`.
|
||||
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
||||
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `pnpm exec redocly lint docs/openapi.yaml`.
|
||||
*Проверка:* CI job `openapi`.
|
||||
|
||||
---
|
||||
@@ -229,7 +229,7 @@ alwaysApply: true
|
||||
```powershell
|
||||
go vet ./...
|
||||
go test ./... -race -count=1
|
||||
npx @redocly/cli lint docs/openapi.yaml
|
||||
pnpm exec redocly lint docs/openapi.yaml
|
||||
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
||||
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# KPI hybrid — только kit (stats-12 + IconTile)
|
||||
|
||||
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).
|
||||
|
||||
## MUST
|
||||
|
||||
| Зона | Компонент / DNA |
|
||||
|------|-----------------|
|
||||
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
||||
| 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/*`. IconTile: `@/components/reui/icon-tile`.
|
||||
|
||||
## NEVER
|
||||
|
||||
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||
- `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 без IconTile
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
description: ReUI PRO (@reui) — MCP user-reui, Frame surface, kit, license, матрица выбора
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# ReUI MCP — обязательно (PRO + free)
|
||||
|
||||
Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
|
||||
|
||||
Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
|
||||
|
||||
| Документ | 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 |
|
||||
| **Base UI components** | https://reui.io/docs/components/base/<name> |
|
||||
|
||||
## Primary MCP
|
||||
|
||||
1. **`user-reui`** — `search` / `compose_page` / `get_block` / `get_component` / `get_install_command` / `validate_usage` / `get_audit_checklist`
|
||||
2. **`plugin-shadcn-shadcn`** — primitives `@shadcn`; для `@reui` — вторично
|
||||
|
||||
**Обязательно** цитировать `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 | Импорт |
|
||||
|--------|----------|--------|
|
||||
| Button, Sheet, Field, Sidebar, Tabs | `@shadcn` | `@evobgp/ui/components/*` |
|
||||
| PRO pages/sections (settings, stats, auth, dashboard) | `@reui` blocks | adapt → `apps/web/src/components/` / `reui-kit/` |
|
||||
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
|
||||
| Filters | `@reui` | `@/components/reui/filters` |
|
||||
| Frame surface | `@reui` | `@/components/reui/frame` |
|
||||
| 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 / 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).
|
||||
|
||||
## MCP workflow
|
||||
|
||||
0. Codegraph / поиск существующих `reui-kit/*`, `@/components/reui/*`
|
||||
1. `user-reui` `search` (`surface: "frame"`, `category` при известном)
|
||||
2. Страница целиком → `compose_page`; секция → `get_block`
|
||||
3. `get_component` для API primitives из `componentsUsed`
|
||||
4. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --yes`
|
||||
5. Post-add: shadcn imports → `@evobgp/ui/components/*`
|
||||
6. Adapt by reuse → kit / route
|
||||
7. `validate_usage` + `get_audit_checklist`
|
||||
|
||||
## Размещение
|
||||
|
||||
| Слой | Путь | Импорт |
|
||||
|------|------|--------|
|
||||
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
||||
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
||||
| PRO blocks (reference) | — | CLI → adapt into kit; не держать demo-деревья в `src/components/blocks/` |
|
||||
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
|
||||
|
||||
## Установленные ReUI (apps/web)
|
||||
|
||||
**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`, `DetailPanel`, `SettingsShell`, `FrameSection`
|
||||
|
||||
**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`, …
|
||||
|
||||
## License
|
||||
|
||||
```env
|
||||
# .env.local (gitignored)
|
||||
REUI_LICENSE_KEY=
|
||||
```
|
||||
|
||||
`apps/web/components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
|
||||
|
||||
## Эталоны preview
|
||||
|
||||
| Зона | Preview |
|
||||
|------|---------|
|
||||
| KPI / Quick Actions | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
|
||||
| List | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||
| Settings | https://reui.io/preview/base/settings-16 |
|
||||
| Auth | https://reui.io/preview/base/auth-13 |
|
||||
| Shell | https://reui.io/preview/base/app-shell-12 |
|
||||
| Empty | https://reui.io/preview/base/empty-state-12 |
|
||||
|
||||
## Запрещено
|
||||
|
||||
- Копипаст с reui.io без CLI
|
||||
- ReUI в `packages/ui` / импорт как `@evobgp/ui`
|
||||
- Radix-варианты docs — только Base UI
|
||||
- Raw `bg-emerald-*` вместо ReUI `variant`
|
||||
- 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)
|
||||
|
||||
## Чеклист
|
||||
|
||||
- [ ] `user-reui` search/get_block + previewUrl
|
||||
- [ ] `surface: frame`
|
||||
- [ ] CLI add из `apps/web` при новом item
|
||||
- [ ] Kit / `@/components/reui` / `@evobgp/ui` — правильный слой
|
||||
- [ ] `pnpm --filter @evobgp/web run build`
|
||||
@@ -6,18 +6,18 @@ alwaysApply: false
|
||||
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -9,12 +9,12 @@ alwaysApply: false
|
||||
# Web UI — React + shadcn/ui + ReUI
|
||||
|
||||
**Источники правды:**
|
||||
- **ReUI PRO first:** MCP `user-reui` ([`reui-mcp.mdc`](reui-mcp.mdc)) — pages / KPI / lists / settings / shell
|
||||
- shadcn primitives: MCP `plugin-shadcn-shadcn` (secondary)
|
||||
- ReUI Base UI: https://reui.io/docs/components/base/<name> · [llms.txt](https://reui.io/llms.txt)
|
||||
- shadcn/ui React: https://ui.shadcn.com/docs/components
|
||||
- ReUI Base UI: https://reui.io/docs/components/base/<name>
|
||||
- ReUI llms.txt: https://reui.io/llms.txt
|
||||
- MCP `plugin-shadcn-shadcn` (registries: `@shadcn`, `@reui`) — перед любой UI-задачей
|
||||
|
||||
Общие правила Go/API: `.cursor/rules/engineering.mdc`. Стек ID: `.cursor/rules/context7-stack.mdc`.
|
||||
Иерархия: **ReUI PRO > shadcn**. Общие: `.cursor/rules/engineering.mdc`, `context7-stack.mdc`.
|
||||
|
||||
## Слои UI
|
||||
|
||||
@@ -22,16 +22,19 @@ alwaysApply: false
|
||||
|------|------|------------|
|
||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
|
||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
|
||||
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell, DetailPanel, FrameSection |
|
||||
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
||||
|
||||
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Page tabs: [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9). Grid status: [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5). Quick Actions: `QuickActionGrid`.
|
||||
|
||||
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
||||
|
||||
## Правила
|
||||
|
||||
**WEB-01** | MUST | Перед новым UI — MCP `plugin-shadcn-shadcn`: `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`. Только после — JSX.
|
||||
*Rationale:* единый источник правды и API.
|
||||
*Проверка:* review; нет самописных примитивов, если есть registry item.
|
||||
**WEB-01** | MUST | Перед новым UI — сначала MCP **`user-reui`** (`search` → `get_block` / `compose_page`, `surface: "frame"`) + cite `previewUrl`/`docsUrl`. Primitives — MCP `plugin-shadcn-shadcn`. Только после — JSX.
|
||||
*Rationale:* ReUI PRO выше shadcn; единый Frame surface.
|
||||
*Проверка:* review; [`reui-mcp.mdc`](reui-mcp.mdc).
|
||||
|
||||
**WEB-02** | MUST | Отсутствующий shadcn-примитив — `pnpm dlx shadcn@latest add <component>` (из `apps/web`). ReUI — `pnpm dlx shadcn@latest add @reui/<name>`.
|
||||
*Проверка:* файлы в `packages/ui/src/components/` (для shadcn) или `apps/web/src/components/reui/` (для ReUI).
|
||||
@@ -45,8 +48,15 @@ alwaysApply: false
|
||||
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||
|
||||
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
|
||||
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
|
||||
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
|
||||
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
|
||||
|
||||
**WEB-06a** | MUST | Переключатели по роли UX:
|
||||
- секции страницы (Сеть / Операции / Справочники) — native `Tabs` default list (`SegmentedTabs`, [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9));
|
||||
- статус грида (Все/Вкл) — `ToggleGroup` в toolbar (`StatusToggleGroup`, [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5));
|
||||
- настройки — вертикальный Tabs rail ([settings-3](https://reui.io/preview/base/settings-3)).
|
||||
Не `variant="line"` для page/in-grid tabs.
|
||||
*Проверка:* `@/components/segmented-tabs`, `@/components/status-toggle-group`.
|
||||
|
||||
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||
@@ -70,7 +80,7 @@ alwaysApply: false
|
||||
|
||||
**WEB-14** | SHOULD | Нетривиальный UI — прочитать страницу компонента shadcn/ReUI (props, a11y).
|
||||
|
||||
**WEB-15** | MUST | Сомнения — MCP `plugin-shadcn-shadcn` + shadcn CLI docs + `pnpm --filter @evobgp/web run typecheck`.
|
||||
**WEB-15** | MUST | Сомнения — MCP `user-reui` + `plugin-shadcn-shadcn` + docs + `pnpm --filter @evobgp/web run typecheck`.
|
||||
|
||||
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
|
||||
|
||||
@@ -95,7 +105,11 @@ pnpm --filter @evobgp/web run build
|
||||
**WEB-21** | MUST | Data fetching — TanStack Query (`useQuery`, `useMutation`, `queryOptions`); query-key factories в `apps/web/src/queries/`. Mutations invalidate keys, не refetch вручную.
|
||||
*Проверка:* review `queries/*.ts`.
|
||||
|
||||
**WEB-22** | MUST | Legacy Svelte — в `web-legacy-svelte/` (archive). Не использовать импорты оттуда в новом коде; только как референс при миграции роутов.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -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" } }
|
||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
@@ -52,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 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
## 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,37 @@ 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
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
@@ -79,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
@@ -284,6 +349,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)).
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Build context = repository root (deploy/docker/docker-bake.hcl).
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.cursor
|
||||
.claude
|
||||
.codegraph
|
||||
.agents
|
||||
memory-bank
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
!**/.env.*.example
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
apps/web/src/routeTree.gen.ts
|
||||
apps/web/playwright-report
|
||||
apps/web/test-results
|
||||
|
||||
*.md
|
||||
AGENTS.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
docs
|
||||
|
||||
.pre-commit-config.yaml
|
||||
.golangci.yml
|
||||
.releaserc.json
|
||||
.commitlintrc.*
|
||||
redocly.yaml
|
||||
package-lock.json
|
||||
|
||||
data
|
||||
*.exe
|
||||
*.test
|
||||
coverage
|
||||
.coverage
|
||||
*.out
|
||||
.release-version
|
||||
CHANGELOG.md
|
||||
deploy/docker/docker-bake.override.hcl
|
||||
deploy/compose/runtime-logs
|
||||
+50
-21
@@ -1,55 +1,84 @@
|
||||
# Gitea Actions
|
||||
|
||||
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
||||
|
||||
| Job | PR | push в main |
|
||||
|-----|-----|-------------|
|
||||
| changes, openapi, web, go, bird2 | quality gates | quality gates |
|
||||
| commitlint | да | — |
|
||||
| **release** | — | semantic-release + docker push (один run) |
|
||||
| Workflow | Когда | Что |
|
||||
|----------|--------|-----|
|
||||
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
|
||||
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, go, bird2, commitlint, docker-check |
|
||||
|
||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||
|
||||
## CI (quality gates)
|
||||
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` → `go` / `openapi` и т.д. (см. `ci.yaml`).
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/api.md` / `docs/access.md` / `docs/openapi.yaml`) quality jobs не запускают.
|
||||
|
||||
На **pull request** — **commitlint** (Conventional Commits).
|
||||
На **pull request** — **commitlint**. При изменении `deploy/docker/**` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||
|
||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
|
||||
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`, `go env GOMODCACHE` / `GOCACHE`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||
|
||||
## Release (job в ci.yaml)
|
||||
Кэшируется целиком: pnpm store + `node_modules` + corepack; Go modules + GOCACHE + `golangci-lint` в `GOBIN`. При hit: `pnpm install --offline`, `go mod download` без сети. `setup-go cache:` и `golangci-lint-action` не используем — они завязаны на `hashFiles`.
|
||||
|
||||
После успешных quality gates на **push в main** job **release**:
|
||||
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||
|
||||
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR) и **publish** (CD).
|
||||
|
||||
## CD (job publish)
|
||||
|
||||
После успешных quality gates на **push в main** job **publish**:
|
||||
|
||||
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
|
||||
3. Зеркало base-образов в `evobgp-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evobgp` (`cleanup: false`).
|
||||
|
||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||
|
||||
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||
|
||||
### Секреты
|
||||
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||
|
||||
**`docker_hub_token`**: PAT Docker Hub (Settings → Actions → Secrets, можно на уровне организации). Логин перед зеркалом `docker.io` и `bake`, чтобы не ловить anonymous 429. Логин: `docker_hub_username` (если задан) или `gitea.actor`.
|
||||
|
||||
### Теги образов
|
||||
|
||||
```text
|
||||
git.shts.su/<owner>/<имя>:latest
|
||||
git.shts.su/<owner>/<имя>:v1.2.3
|
||||
git.shts.su/<owner>/<имя>:1.2.3
|
||||
git.shts.su/<owner>/<имя>:<short-sha>
|
||||
git.shts.su/<owner>/<имя>:sha-<full-sha>
|
||||
git.shx.one/<owner>/<имя>:latest
|
||||
git.shx.one/<owner>/<имя>:v1.2.3
|
||||
git.shx.one/<owner>/<имя>:1.2.3
|
||||
git.shx.one/<owner>/<имя>:<short-sha>
|
||||
git.shx.one/<owner>/<имя>:sha-<full-sha>
|
||||
```
|
||||
|
||||
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
||||
|
||||
Кэш сборки: `evobgp-buildcache:{go,web,birdc}-buildcache` и `evobgp-buildcache:base-*`.
|
||||
|
||||
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
||||
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
docker pull git.shts.su/myuser/evobgp-api:1.2.3
|
||||
docker pull git.shx.one/myuser/evobgp-api:1.2.3
|
||||
```
|
||||
|
||||
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
||||
|
||||
## act_runner: cache server
|
||||
|
||||
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||
|
||||
В `config.yaml` runner:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
enabled: true
|
||||
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||
port: 8088
|
||||
```
|
||||
|
||||
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||
|
||||
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
## Summary
|
||||
|
||||
<!-- 1–3 bullets: what and why -->
|
||||
|
||||
## Checklist
|
||||
|
||||
### General
|
||||
|
||||
- [ ] Docs updated if behavior/API changed (DOC-02)
|
||||
- [ ] Conventional Commits (EN title / RU body)
|
||||
|
||||
### Backend (if Go / OpenAPI / migrations)
|
||||
|
||||
- [ ] `go test ./... -race -count=1` (or scoped packages) when touching jobs/pipeline/httpapi
|
||||
- [ ] OpenAPI lint + regenerate `apps/web/src/types/api.gen.ts` if `docs/openapi.yaml` changed
|
||||
- [ ] Migration pairs postgres+sqlite (DEP-03)
|
||||
|
||||
### Frontend (if `apps/web` / `packages/ui`) — WEB-19
|
||||
|
||||
- [ ] `pnpm --filter @evobgp/web run typecheck`
|
||||
- [ ] `pnpm --filter @evobgp/web run lint`
|
||||
- [ ] `pnpm --filter @evobgp/web run build`
|
||||
- [ ] `pnpm --filter @evobgp/web run test`
|
||||
- [ ] UI follows ReUI PRO + `docs/ui-design-contract.md` (surface `frame`, kit; cite previewUrl)
|
||||
|
||||
### KPI / Quick Actions
|
||||
|
||||
- [ ] If changing `kpi-stat-grid.tsx` / `quick-action-grid.tsx`, note sibling-app sync (vps / CFDM / fw / auth-portal)
|
||||
@@ -0,0 +1,156 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: false
|
||||
before_sha: ${{ github.event.before }}
|
||||
head_sha: ${{ github.sha }}
|
||||
allow_registry_login: false
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
docker_hub_token: ${{ secrets.docker_hub_token }}
|
||||
docker_hub_username: ${{ secrets.docker_hub_username }}
|
||||
|
||||
publish:
|
||||
needs: [quality]
|
||||
if: >-
|
||||
always() &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||
needs.quality.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
releases: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
persist-credentials: true
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: Install release tooling
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: sh scripts/ci/pnpm-ci.sh
|
||||
- name: Verify releasable commit messages
|
||||
run: pnpm exec node scripts/commit/verify-release-commits.mjs
|
||||
- name: Semantic release
|
||||
run: pnpm exec semantic-release
|
||||
env:
|
||||
GITEA_URL: https://git.shx.one
|
||||
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Detect new release
|
||||
id: rel
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=""
|
||||
if [ -f .release-version ]; then
|
||||
version="$(tr -d '[:space:]' < .release-version)"
|
||||
echo "New release from semantic-release: $version"
|
||||
else
|
||||
git fetch --tags --force origin || true
|
||||
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
||||
if [ -n "${tag:-}" ]; then
|
||||
version="${tag#v}"
|
||||
echo "Reuse existing tag $tag on HEAD (release retry)"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${version:-}" ]; then
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No releasable commits — skipping image publish"
|
||||
fi
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: evobgp
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Prepare image metadata
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username || gitea.actor }}
|
||||
password: ${{ secrets.docker_hub_token }}
|
||||
- name: Log in to Gitea Registry
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: Mirror base images into buildcache
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
run: sh deploy/docker/mirror-base-images.sh
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
IMAGE_TAG: latest
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||
SHA_FULL: ${{ github.sha }}
|
||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||
CACHE_REF_BIRDC: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:birdc-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
working-directory: deploy/docker
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${MIRROR_ENV_FILE}"
|
||||
set +a
|
||||
fi
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||
-f docker-bake.hcl default --push
|
||||
+18
-356
@@ -1,363 +1,25 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
||||
# ---------------------------------------------------------------------------
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
openapi: ${{ steps.detect.outputs.openapi }}
|
||||
go: ${{ steps.detect.outputs.go }}
|
||||
web: ${{ steps.detect.outputs.web }}
|
||||
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
||||
docker_go: ${{ steps.detect.outputs.docker_go }}
|
||||
docker_web: ${{ steps.detect.outputs.docker_web }}
|
||||
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: detect
|
||||
name: Detect changed paths per module
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
openapi=false
|
||||
go=false
|
||||
web=false
|
||||
bird_conf=false
|
||||
docker_go=false
|
||||
docker_web=false
|
||||
docker_bird=false
|
||||
|
||||
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
|
||||
set_all_flags_true() {
|
||||
openapi=true
|
||||
go=true
|
||||
web=true
|
||||
bird_conf=true
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
FILES="$(git diff --name-only "$base" "$head")"
|
||||
else
|
||||
before="${{ github.event.before }}"
|
||||
after="${{ github.sha }}"
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
FILES="$(git diff --name-only "$before" "$after")"
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
# CI/CD инфраструктура — все узлы quality gates
|
||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
docs/openapi.yaml|redocly.yaml)
|
||||
openapi=true
|
||||
;;
|
||||
docs/api.md|docs/access.md)
|
||||
openapi=true
|
||||
go=true
|
||||
;;
|
||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||
;;
|
||||
apps/web/*|packages/ui/*|packages/shared/*)
|
||||
web=true
|
||||
;;
|
||||
deploy/bird/*)
|
||||
bird_conf=true
|
||||
go=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*)
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
go=true
|
||||
;;
|
||||
go.mod|go.sum|go.work)
|
||||
go=true
|
||||
;;
|
||||
migrations/*)
|
||||
go=true
|
||||
;;
|
||||
cmd/*|internal/*|*.go)
|
||||
go=true
|
||||
bird_conf=true
|
||||
;;
|
||||
docs/*)
|
||||
go=true
|
||||
;;
|
||||
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
go=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
openapi:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.openapi == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Lint OpenAPI (Redocly)
|
||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
web:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: pnpm install, typecheck, lint, build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
go:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||
run: sh scripts/lint-httpapi.sh
|
||||
- name: Check migration pairs (DEP-03)
|
||||
run: sh scripts/check-migrations-pair.sh
|
||||
- name: Validate remote speaker compose
|
||||
run: sh scripts/validate-remote-speaker-compose.sh
|
||||
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.64.8
|
||||
install-mode: goinstall
|
||||
- name: Test
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
out="${RUNNER_TEMP}/evobgp-bin"
|
||||
mkdir -p "$out"
|
||||
for d in cmd/*/; do
|
||||
name="$(basename "$d")"
|
||||
go build -o "$out/$name" "./$d"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
bird2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, go]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.changes.result == 'success' &&
|
||||
needs.go.result != 'failure' &&
|
||||
(needs.changes.outputs.go == 'true' ||
|
||||
needs.changes.outputs.bird_conf == 'true' ||
|
||||
needs.changes.outputs.docker_bird == 'true' ||
|
||||
needs.changes.outputs.docker_go == 'true')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
||||
$SUDO apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
||||
bird --version
|
||||
- name: bird -p on all scenario bird.conf files
|
||||
env:
|
||||
WORKSPACE: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
WS="${WORKSPACE:-$PWD}"
|
||||
cd "$WS"
|
||||
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
||||
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
||||
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
||||
exit 1
|
||||
fi
|
||||
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
||||
echo "==> $conf"
|
||||
bird -c "$WS/$conf" -p
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
commitlint:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- name: Lint commit messages
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
npm ci
|
||||
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
|
||||
# ---------------------------------------------------------------------------
|
||||
release:
|
||||
needs: [changes, openapi, web, go, bird2]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||
needs.changes.result == 'success' &&
|
||||
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
|
||||
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
|
||||
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
|
||||
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
persist-credentials: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- name: Install release tooling
|
||||
run: npm ci
|
||||
- name: Verify releasable commit messages
|
||||
run: node scripts/commit/verify-release-commits.mjs
|
||||
- name: Semantic release
|
||||
run: npx semantic-release
|
||||
env:
|
||||
GITEA_URL: https://git.shts.su
|
||||
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Detect new release
|
||||
id: rel
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f .release-version ]; then
|
||||
echo "version=$(tr -d '[:space:]' < .release-version)" >> "$GITHUB_OUTPUT"
|
||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No releasable commits — skipping image publish"
|
||||
fi
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Prepare image metadata
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Log in to Gitea Registry
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shts.su
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
|
||||
IMAGE_TAG: latest
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||
SHA_FULL: ${{ github.sha }}
|
||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
cd "${{ github.workspace }}/deploy/docker"
|
||||
sh write-bake-override.sh
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||
-f docker-bake.hcl -f docker-bake.override.hcl default --push
|
||||
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: true
|
||||
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
docker_hub_token: ${{ secrets.docker_hub_token }}
|
||||
docker_hub_username: ${{ secrets.docker_hub_username }}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
|
||||
name: quality
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
is_pull_request:
|
||||
type: boolean
|
||||
required: true
|
||||
base_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
head_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
before_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
allow_registry_login:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
ACTIONS_PAT:
|
||||
required: false
|
||||
docker_hub_token:
|
||||
required: false
|
||||
docker_hub_username:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
openapi: ${{ steps.detect.outputs.openapi }}
|
||||
go: ${{ steps.detect.outputs.go }}
|
||||
web: ${{ steps.detect.outputs.web }}
|
||||
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
||||
docker_go: ${{ steps.detect.outputs.docker_go }}
|
||||
docker_web: ${{ steps.detect.outputs.docker_web }}
|
||||
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
||||
steps:
|
||||
- if: ${{ inputs.is_pull_request }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- if: ${{ inputs.is_pull_request == false }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- id: detect
|
||||
name: Detect changed paths per module
|
||||
env:
|
||||
IS_PR: ${{ inputs.is_pull_request }}
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
BEFORE_SHA: ${{ inputs.before_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
openapi=false
|
||||
go=false
|
||||
web=false
|
||||
bird_conf=false
|
||||
docker_go=false
|
||||
docker_web=false
|
||||
docker_bird=false
|
||||
|
||||
set_all_flags_true() {
|
||||
openapi=true
|
||||
go=true
|
||||
web=true
|
||||
bird_conf=true
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "$IS_PR" = "true" ]; then
|
||||
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
||||
else
|
||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||
before="$BEFORE_SHA"
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
FILES="$(git diff --name-only "$before" "$after")"
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
docs/openapi.yaml|redocly.yaml)
|
||||
openapi=true
|
||||
;;
|
||||
docs/api.md|docs/access.md)
|
||||
openapi=true
|
||||
go=true
|
||||
;;
|
||||
.cursor/*|.claude/*|.codegraph/*|memory-bank/*)
|
||||
;;
|
||||
*.md|AGENTS.md)
|
||||
;;
|
||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||
;;
|
||||
apps/web/*|packages/ui/*|packages/shared/*)
|
||||
web=true
|
||||
;;
|
||||
deploy/bird/*)
|
||||
bird_conf=true
|
||||
go=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*|.dockerignore)
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
go=true
|
||||
;;
|
||||
go.mod|go.sum|go.work)
|
||||
go=true
|
||||
;;
|
||||
migrations/*)
|
||||
go=true
|
||||
;;
|
||||
cmd/*|internal/*|*.go)
|
||||
go=true
|
||||
bird_conf=true
|
||||
;;
|
||||
docs/*)
|
||||
;;
|
||||
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||
|
||||
openapi:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, Redocly, codegen check
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec redocly lint docs/openapi.yaml
|
||||
chmod +x scripts/check-openapi-gen.sh
|
||||
sh scripts/check-openapi-gen.sh
|
||||
|
||||
web:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, typecheck, lint, test, build
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run test
|
||||
pnpm --filter @evobgp/web run build
|
||||
|
||||
go:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: false
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: go-hash
|
||||
run: echo "key=$(sha256sum go.sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.GOMODCACHE }}
|
||||
${{ env.GOCACHE }}
|
||||
${{ env.GOBIN }}
|
||||
${{ env.GOLANGCI_LINT_CACHE }}
|
||||
key: go-${{ runner.os }}-1.24-gl1.64.8-${{ steps.go-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
go-${{ runner.os }}-1.24-gl1.64.8-
|
||||
go-${{ runner.os }}-1.24-
|
||||
- name: Download modules
|
||||
env:
|
||||
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||
GOCACHE: ${{ env.GOCACHE }}
|
||||
run: go mod download
|
||||
- name: Vet
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
run: go vet ./...
|
||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||
run: sh scripts/lint-httpapi.sh
|
||||
- name: Check migration pairs (DEP-03)
|
||||
run: sh scripts/check-migrations-pair.sh
|
||||
- name: Validate remote speaker compose
|
||||
run: sh scripts/validate-remote-speaker-compose.sh
|
||||
- name: golangci-lint
|
||||
env:
|
||||
GOLANGCI_LINT_VERSION: v1.64.8
|
||||
run: sh scripts/ci/golangci-lint.sh
|
||||
- name: Test
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||
GOCACHE: ${{ env.GOCACHE }}
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||
GOCACHE: ${{ env.GOCACHE }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
out="${RUNNER_TEMP}/evobgp-bin"
|
||||
mkdir -p "$out"
|
||||
for d in cmd/*/; do
|
||||
name="$(basename "$d")"
|
||||
go build -o "$out/$name" "./$d"
|
||||
done
|
||||
|
||||
bird2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, go]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.changes.result == 'success' &&
|
||||
needs.go.result != 'failure' &&
|
||||
(needs.changes.outputs.go == 'true' ||
|
||||
needs.changes.outputs.bird_conf == 'true' ||
|
||||
needs.changes.outputs.docker_bird == 'true' ||
|
||||
needs.changes.outputs.docker_go == 'true')
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
||||
$SUDO apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
||||
bird --version
|
||||
- name: bird -p on all scenario bird.conf files
|
||||
env:
|
||||
WORKSPACE: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
WS="${WORKSPACE:-$PWD}"
|
||||
cd "$WS"
|
||||
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
||||
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
||||
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
||||
exit 1
|
||||
fi
|
||||
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
||||
echo "==> $conf"
|
||||
bird -c "$WS/$conf" -p
|
||||
done
|
||||
|
||||
commitlint:
|
||||
if: inputs.is_pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: Lint commit messages
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
|
||||
docker-check:
|
||||
needs: [changes]
|
||||
if: >-
|
||||
inputs.is_pull_request &&
|
||||
(needs.changes.outputs.docker_go == 'true' ||
|
||||
needs.changes.outputs.docker_web == 'true' ||
|
||||
needs.changes.outputs.docker_bird == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: evobgp
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Log in to Docker Hub
|
||||
if: secrets.docker_hub_token != ''
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username || gitea.actor }}
|
||||
password: ${{ secrets.docker_hub_token }}
|
||||
- name: Log in to Gitea Registry
|
||||
if: inputs.allow_registry_login
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: bake --print
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||
- name: bake (no push)
|
||||
if: inputs.allow_registry_login
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
CACHE_REF_GO: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:web-buildcache
|
||||
CACHE_REF_BIRDC: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:birdc-buildcache
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
export CACHE_REF_GO="git.shx.one/${owner_lc}/evobgp-buildcache:go-buildcache"
|
||||
export CACHE_REF_WEB="git.shx.one/${owner_lc}/evobgp-buildcache:web-buildcache"
|
||||
export CACHE_REF_BIRDC="git.shx.one/${owner_lc}/evobgp-buildcache:birdc-buildcache"
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
|
||||
@@ -21,9 +21,14 @@ Thumbs.db
|
||||
|
||||
# Env / secrets (local only)
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
|
||||
deploy/compose/runtime-logs/
|
||||
# Local MCP configs (may contain REUI license Bearer)
|
||||
.cursor/mcp.json
|
||||
.mcp.json
|
||||
.codegraph/daemon.pid
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
},
|
||||
"reui": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.reui.io/api/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
engine-strict=true
|
||||
store-dir=C:\Users\shats\AppData\Local\pnpm-test-store\store\v10
|
||||
|
||||
@@ -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 `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -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" } }
|
||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
@@ -52,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 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
## 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,37 @@ 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
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
@@ -79,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
@@ -284,6 +349,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",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
|
||||
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
|
||||
| Точки входа процессов | `cmd/*/` |
|
||||
| Веб (SvelteKit) | `web/` |
|
||||
| Веб (React + ReUI) | `apps/web/` |
|
||||
| Compose, деплой | `deploy/compose/` |
|
||||
|
||||
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
|
||||
@@ -69,6 +69,10 @@ pnpm --filter @evobgp/web run build
|
||||
|
||||
Все три команды должны exit 0. CI job `web` не пропускает без этого.
|
||||
|
||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
|
||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte UI удалён (миграция завершена).
|
||||
|
||||
UI-задачи начинаются с MCP `plugin-shadcn-shadcn` (search → examples → add command), затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
|
||||
**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
|
||||
```
|
||||
@@ -50,6 +50,6 @@ docker compose --profile reference up -d
|
||||
|
||||
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
||||
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
|
||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CD run**, что и релиз (job `publish`)
|
||||
|
||||
Лицензия и условия использования — по политике владельца репозитория.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# EvoBGP web (Vite) — переменные окружения.
|
||||
# Скопируйте в apps/web/.env.local (файл в .gitignore) и заполните.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ReUI Pro/Ultimate — ключ с https://reui.io/account (для `pnpm dlx shadcn add @reui/*`)
|
||||
# ---------------------------------------------------------------------------
|
||||
# REUI_LICENSE_KEY=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App Switcher — JSON с описанием шапки «Приложения» (fallback, когда portal
|
||||
# недоступен либо VITE_AUTH_ENABLED=false).
|
||||
# Схема: см. apps/web/src/lib/app-switcher-config.ts.
|
||||
# ---------------------------------------------------------------------------
|
||||
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[...]}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-portal SSO
|
||||
# ---------------------------------------------------------------------------
|
||||
# Включает JWT-гейт через auth-portal вместо локального evobgp_api_token.
|
||||
# Пример:
|
||||
# VITE_AUTH_ENABLED=true
|
||||
# VITE_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
#
|
||||
# Backend опционально может отдавать GET /v1/auth/config
|
||||
# ({ "required": true, "portal_url": "https://auth.shnt.top" }) —
|
||||
# ответ имеет приоритет над VITE_* и позволяет менять режим без пересборки.
|
||||
# VITE_AUTH_ENABLED=false
|
||||
# VITE_AUTH_PORTAL_URL=http://localhost:5175
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Smoke against local API (+ optional UI). Profile: compose microvps-full.
|
||||
*
|
||||
* Env:
|
||||
* - EVOBGP_E2E_API_URL (default http://127.0.0.1:8080)
|
||||
* - EVOBGP_E2E_BASE_URL (default http://127.0.0.1:5173)
|
||||
* - EVOBGP_E2E_TOKEN (default dev)
|
||||
*/
|
||||
const apiBase = process.env.EVOBGP_E2E_API_URL ?? 'http://127.0.0.1:8080'
|
||||
const uiBase = process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173'
|
||||
const token = process.env.EVOBGP_E2E_TOKEN ?? 'dev'
|
||||
|
||||
test.describe('EvoBGP smoke', () => {
|
||||
test('API health and modules list', async ({ request }) => {
|
||||
const health = await request.get(`${apiBase}/v1/health`)
|
||||
expect(health.ok()).toBeTruthy()
|
||||
|
||||
const mods = await request.get(`${apiBase}/v1/modules?limit=10`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect([200, 401]).toContain(mods.status())
|
||||
})
|
||||
|
||||
test('UI loads when available', async ({ page }) => {
|
||||
test.skip(!process.env.EVOBGP_E2E_UI, 'set EVOBGP_E2E_UI=1 to enable UI smoke')
|
||||
await page.goto(uiBase)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,9 @@ export default tseslint.config(
|
||||
},
|
||||
{
|
||||
files: ['src/components/reui/**/*.{ts,tsx}'],
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'off',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
|
||||
+13
-6
@@ -9,10 +9,14 @@
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsr generate && tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"openapi:gen": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.ts",
|
||||
"openapi:check": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.check.ts && diff -q src/types/api.gen.ts src/types/api.gen.check.ts && rm -f src/types/api.gen.check.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.0.0",
|
||||
"@date-fns/tz": "^1.5.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -23,8 +27,8 @@
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-router": "^1.130.2",
|
||||
"@tanstack/react-router-devtools": "^1.130.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.4",
|
||||
"@tanstack/react-table": "^9.1.2",
|
||||
"@tanstack/react-virtual": "^3.14.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
@@ -36,26 +40,29 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"recharts": "3.8.0",
|
||||
"shadcn": "^4.19.0",
|
||||
"sonner": "^1.7.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@tanstack/router-plugin": "^1.130.0",
|
||||
"@tanstack/router-cli": "^1.130.0",
|
||||
"@tanstack/router-plugin": "^1.130.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.0.0",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.0",
|
||||
"globals": "^15.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"happy-dom": "^18.0.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"tw-animate-css": "^1.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 60_000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
use: {
|
||||
baseURL: process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
})
|
||||
@@ -2,9 +2,7 @@ import { useState } from 'react'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
||||
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
@@ -47,45 +45,38 @@ export function AccessApiKeysCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
title="API-ключи"
|
||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={false}
|
||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет ключей"
|
||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<AccessApiKeysGrid
|
||||
items={data}
|
||||
isLoading={isLoading}
|
||||
onRotate={handleRotated}
|
||||
onRevoke={(id) => revoke.mutate(id)}
|
||||
rotatePending={rotate.isPending}
|
||||
revokePending={revoke.isPending}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
{(data) => (
|
||||
<AccessApiKeysGrid
|
||||
items={data}
|
||||
isLoading={isLoading}
|
||||
onRotate={handleRotated}
|
||||
onRevoke={(id) => revoke.mutate(id)}
|
||||
rotatePending={rotate.isPending}
|
||||
revokePending={revoke.isPending}
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
<ApiKeyCreateDialog
|
||||
open={createOpen}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { KeyRound, RefreshCw, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createSearchFilterField,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { ApiKey } from '@/types/api'
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
createSearchFilterField('search', 'Поиск', 'Поиск API-ключей…'),
|
||||
]
|
||||
|
||||
export function AccessApiKeysGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
@@ -21,6 +28,7 @@ export function AccessApiKeysGrid({
|
||||
onRevoke,
|
||||
rotatePending = false,
|
||||
revokePending = false,
|
||||
actions,
|
||||
}: {
|
||||
items: ApiKey[]
|
||||
isLoading?: boolean
|
||||
@@ -28,13 +36,25 @@ export function AccessApiKeysGrid({
|
||||
onRevoke: (id: string) => void
|
||||
rotatePending?: boolean
|
||||
revokePending?: boolean
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<ApiKey>[]>(
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
|
||||
const columns = useMemo<DataGridColumnDef<ApiKey>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridNameCell
|
||||
icon={KeyRound}
|
||||
title={row.original.name}
|
||||
subtitle={`${row.original.prefix}…`}
|
||||
/>
|
||||
),
|
||||
minSize: 160,
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
@@ -45,14 +65,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,20 +89,21 @@ 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',
|
||||
enableSorting: false,
|
||||
size: 88,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const k = row.original
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
@@ -135,23 +148,24 @@ export function AccessApiKeysGrid({
|
||||
[onRevoke, onRotate, revokePending, rotatePending],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
<ResourcePage
|
||||
title="API-ключи"
|
||||
description="Полный токен показывается только при создании и ротации."
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={(row) =>
|
||||
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`
|
||||
}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ключей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск API-ключей…"
|
||||
primaryAction={actions}
|
||||
pinLastColumn
|
||||
emptyState={{ title: 'Нет ключей' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,12 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@evobgp/ui/components/tooltip'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { FrameSection } from '@/components/reui-kit'
|
||||
|
||||
/** Chart Frame shell — chart-1 spacing, not Card.
|
||||
* @see https://reui.io/preview/base/chart-1
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function AnalyticsCardShell({
|
||||
title,
|
||||
description,
|
||||
@@ -47,7 +51,7 @@ export function AnalyticsCardShell({
|
||||
)
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
<FrameSection
|
||||
title={titleNode}
|
||||
description={description}
|
||||
actions={actions}
|
||||
@@ -57,6 +61,6 @@ export function AnalyticsCardShell({
|
||||
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
|
||||
>
|
||||
{children}
|
||||
</PanelCard>
|
||||
</FrameSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export type AnalyticsKpiItem = {
|
||||
@@ -12,39 +12,42 @@ export type AnalyticsKpiItem = {
|
||||
}
|
||||
}
|
||||
|
||||
const TONE_CLASS = {
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
muted: 'text-muted-foreground',
|
||||
} as const
|
||||
|
||||
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
|
||||
if (direction === 'up') return <TrendingUp className="size-3" />
|
||||
if (direction === 'down') return <TrendingDown className="size-3" />
|
||||
return <Minus className="size-3" />
|
||||
function deltaBadgeVariant(
|
||||
tone?: 'success' | 'warning' | 'destructive' | 'muted',
|
||||
): 'success-light' | 'warning-light' | 'destructive-light' | 'outline' {
|
||||
if (tone === 'success') return 'success-light'
|
||||
if (tone === 'warning') return 'warning-light'
|
||||
if (tone === 'destructive') return 'destructive-light'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
|
||||
function toKpiItem(item: AnalyticsKpiItem): KpiStatItem {
|
||||
return {
|
||||
id: item.label,
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
footer: item.delta ? (
|
||||
<Badge variant={deltaBadgeVariant(item.delta.tone)} size="sm">
|
||||
{item.delta.label}
|
||||
</Badge>
|
||||
) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact KPI row inside analytics panels (stats-12 embedded tiles). */
|
||||
export function AnalyticsKpiRow({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: AnalyticsKpiItem[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="min-w-0 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
|
||||
{item.delta ? (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
TONE_CLASS[item.delta.tone ?? 'muted'],
|
||||
)}
|
||||
>
|
||||
<DeltaIcon direction={item.delta.direction} />
|
||||
{item.delta.label}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
items={items.map(toKpiItem)}
|
||||
embedded
|
||||
className={cn(className)}
|
||||
aria-label="Показатели"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -114,7 +114,7 @@ export function DashboardPlatformCard({
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
|
||||
onClick={() => navigate({ to: '/monitoring' })}
|
||||
>
|
||||
Мониторинг
|
||||
</Button>
|
||||
|
||||
@@ -10,4 +10,3 @@ export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
|
||||
export { DashboardPlatformCard } from './dashboard-platform-card'
|
||||
export { MonitoringHealthCard } from './monitoring-health-card'
|
||||
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
||||
export { OperationsAnalyticsCard } from './operations-analytics-card'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card'
|
||||
import { readinessBreakdown } from '@/lib/metrics'
|
||||
import type { ReadyStatus } from '@/queries/monitoring'
|
||||
|
||||
@@ -13,21 +12,25 @@ export function MonitoringHealthCard({
|
||||
loading?: boolean
|
||||
}) {
|
||||
const slices = readinessBreakdown(ready, healthOk)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DonutBreakdownCard
|
||||
title="Доступность системы"
|
||||
description="Проверки живучести и готовности"
|
||||
slices={[]}
|
||||
centerLabel="Проверки"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
<DonutBreakdownCard
|
||||
title="Доступность системы"
|
||||
description="Проверки живучести и готовности"
|
||||
info="Диаграмма отражает результат GET /v1/health и проверок из GET /v1/ready."
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Проверки" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
slices={slices}
|
||||
centerLabel="Проверки"
|
||||
badge={healthOk ? 'API в норме' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card'
|
||||
import { SegmentedProgressCard } from '@/components/patterns/segmented-progress-card'
|
||||
import { peerSessionBreakdown } from '@/lib/metrics'
|
||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
@@ -16,51 +15,44 @@ export function NetworkOverviewAnalyticsCard({
|
||||
}) {
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
const slices = peerSessionBreakdown(peers)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
const speakersPct =
|
||||
net.speakersTotal > 0 ? Math.round((net.speakersOnline / net.speakersTotal) * 100) : 0
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DonutBreakdownCard
|
||||
title="Сводка BGP"
|
||||
description="Установленные сессии и спикеры"
|
||||
slices={[]}
|
||||
centerLabel="Пиры"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Сводка BGP"
|
||||
description="Установленные сессии, доступность спикеров и расхождения по live-данным"
|
||||
info="Снимок текущего состояния пиров и спикеров."
|
||||
>
|
||||
<AnalyticsKpiRow
|
||||
items={[
|
||||
{
|
||||
label: 'Пиры с установленной сессией',
|
||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
delta: {
|
||||
direction: net.peersMismatch > 0 ? 'down' : 'up',
|
||||
label: net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'сессии в норме',
|
||||
tone: net.peersMismatch > 0 ? 'warning' : 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Спикеры в сети',
|
||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
delta: {
|
||||
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
|
||||
label:
|
||||
net.speakersOnline < net.speakersTotal
|
||||
? `${net.speakersTotal - net.speakersOnline} не в сети`
|
||||
: 'все в сети',
|
||||
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Пиры всего',
|
||||
value: loading ? '—' : String(net.peersTotal),
|
||||
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
|
||||
},
|
||||
]}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<DonutBreakdownCard
|
||||
title="Сводка BGP"
|
||||
description="Распределение состояний пиров"
|
||||
slices={slices}
|
||||
centerLabel="Пиры"
|
||||
badge={net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'в норме'}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
<SegmentedProgressCard
|
||||
title="Спикеры"
|
||||
description="Доступность агентов"
|
||||
primary={{
|
||||
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
label: 'В сети',
|
||||
percent: speakersPct,
|
||||
}}
|
||||
secondary={{
|
||||
value: net.speakersTotal - net.speakersOnline,
|
||||
label: 'Не в сети',
|
||||
percent: 100 - speakersPct,
|
||||
}}
|
||||
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { jobStatusBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, RevisionRow } from '@/types/api'
|
||||
|
||||
export function OperationsAnalyticsCard({
|
||||
jobs,
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
|
||||
const failed = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const slices = jobStatusBreakdown(jobs)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Операции и задачи"
|
||||
description="Статистика ревизий и фоновых jobs"
|
||||
info="Данные из GET /v1/jobs и /v1/revisions."
|
||||
>
|
||||
<AnalyticsKpiRow
|
||||
items={[
|
||||
{
|
||||
label: 'Ревизий',
|
||||
value: loading ? '—' : String(revisions.length),
|
||||
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: loading ? '—' : String(running),
|
||||
delta: {
|
||||
direction: running > 0 ? 'up' : 'neutral',
|
||||
label: running > 0 ? 'выполняются' : 'очередь пуста',
|
||||
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'С ошибкой',
|
||||
value: loading ? '—' : String(failed),
|
||||
delta: {
|
||||
direction: failed > 0 ? 'down' : 'up',
|
||||
label: failed > 0 ? 'требуют внимания' : 'в норме',
|
||||
tone: failed > 0 ? 'destructive' : 'success',
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
|
||||
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
getCurrentApp,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
|
||||
/** Sidebar app switcher — shared chrome etalon EvoBGP. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppSwitcher() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
const current = getCurrentApp(config)
|
||||
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<CurrentIcon className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{current.name}</span>
|
||||
{current.subtitle ? (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{current.subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||
</div>
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
||||
const isCurrent = app.id === CURRENT_APP_ID
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<DropdownMenuItem key={app.id} disabled>
|
||||
<Icon />
|
||||
{app.name}
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
nativeButton={false}
|
||||
render={<a href={app.url} />}
|
||||
>
|
||||
<Icon />
|
||||
{app.name}
|
||||
{app.shortcut ? (
|
||||
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +1 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||
|
||||
const TONE_VARIANT: Record<string, BadgeVariant> = {
|
||||
neutral: 'outline',
|
||||
info: 'info-light',
|
||||
warning: 'warning-light',
|
||||
success: 'success-light',
|
||||
}
|
||||
|
||||
export function ModeBadge({
|
||||
enabled,
|
||||
onLabel = 'включён',
|
||||
offLabel = 'выключен',
|
||||
className,
|
||||
}: {
|
||||
enabled: boolean
|
||||
onLabel?: string
|
||||
offLabel?: string
|
||||
className?: string
|
||||
}) {
|
||||
return enabled ? (
|
||||
<Badge variant="success-light" size="sm" radius="full" className={className}>
|
||||
{onLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" size="sm" radius="full" className={className}>
|
||||
{offLabel}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function CategoryBadge({
|
||||
children,
|
||||
tone = 'neutral',
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
tone?: keyof typeof TONE_VARIANT
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Badge variant={TONE_VARIANT[tone]} size="sm" radius="full" className={className}>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
export { CategoryBadge, ModeBadge } from '@/components/status-badge'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
function grain(i: number, j: number) {
|
||||
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
|
||||
return n - Math.floor(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Static dot field adapted from REUI card-17: theme-aware canvas dots,
|
||||
* repainted on resize and theme change.
|
||||
*/
|
||||
export function CardDotField({ className }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const GAP = 3
|
||||
const DOT = 1.5
|
||||
const BASE = 0.05
|
||||
const PEAK = 0.32
|
||||
|
||||
const draw = () => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
canvas.width = Math.round(rect.width * dpr)
|
||||
canvas.height = Math.round(rect.height * dpr)
|
||||
|
||||
ctx.fillStyle = getComputedStyle(canvas).color || 'rgb(115,115,115)'
|
||||
ctx.fillRect(0, 0, 1, 1)
|
||||
const px = ctx.getImageData(0, 0, 1, 1).data
|
||||
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
ctx.fillStyle = color
|
||||
|
||||
const cols = Math.ceil(rect.width / GAP) + 1
|
||||
const rows = Math.ceil(rect.height / GAP) + 1
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const x = i * GAP
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const q = grain(i, j)
|
||||
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
|
||||
let a = (BASE + (PEAK - BASE) * q * q) * amp
|
||||
if (a > 1) a = 1
|
||||
ctx.globalAlpha = a
|
||||
ctx.fillRect(x, j * GAP, DOT, DOT)
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => draw())
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
const themeObserver = new MutationObserver(() => draw())
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
themeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className={cn('pointer-events-none absolute inset-0 h-full w-full', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
ListChecks,
|
||||
Network,
|
||||
} from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type KpiCard = KpiStatItem & { icon: ReactNode }
|
||||
|
||||
function ratioPercent(part: number, total: number): number | undefined {
|
||||
if (total <= 0) return undefined
|
||||
return Math.round((part / total) * 100)
|
||||
}
|
||||
|
||||
function buildKpis({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}): KpiCard[] {
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const network = aggregateNetworkMetrics(peers, speakers)
|
||||
const peersEnabled = network.peersEnabled
|
||||
const bgpPct =
|
||||
peersEnabled > 0 ? Math.round((network.peersEstablished / peersEnabled) * 100) : null
|
||||
const running = runningJobCount(jobs)
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
||||
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
||||
const disabledModules = Math.max(0, modules.length - enabledModules)
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'modules',
|
||||
icon: <Boxes aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: loading ? '—' : enabledModules,
|
||||
label: 'Модули активны',
|
||||
progress: loading ? undefined : ratioPercent(enabledModules, modules.length),
|
||||
footer: (
|
||||
<Badge
|
||||
variant={
|
||||
loading || modules.length === 0
|
||||
? 'outline'
|
||||
: disabledModules === 0
|
||||
? 'success-light'
|
||||
: 'warning-light'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading
|
||||
? '…'
|
||||
: modules.length === 0
|
||||
? 'нет модулей'
|
||||
: disabledModules === 0
|
||||
? 'все активны'
|
||||
: `${disabledModules} выкл`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
icon: <Network aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||
label: 'BGP готовность',
|
||||
progress: loading || bgpPct === null ? undefined : bgpPct,
|
||||
footer: (
|
||||
<Badge
|
||||
variant={
|
||||
bgpPct !== null && bgpPct >= 90
|
||||
? 'success-light'
|
||||
: bgpPct !== null && bgpPct < 70
|
||||
? 'warning-light'
|
||||
: 'outline'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading || bgpPct === null
|
||||
? 'нет включённых пиров'
|
||||
: bgpPct >= 90
|
||||
? 'сессии в норме'
|
||||
: `${network.peersEstablished} установлено`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
icon: <ListChecks aria-hidden />,
|
||||
iconClassName: 'text-focus',
|
||||
value: loading ? '—' : String(running),
|
||||
label: 'Активные задачи',
|
||||
footer: (
|
||||
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||
{loading ? '…' : running > 0 ? 'выполняются' : 'очередь пуста'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'risks',
|
||||
icon: <AlertTriangle aria-hidden />,
|
||||
iconClassName: 'text-destructive',
|
||||
value: loading ? '—' : String(riskCount),
|
||||
label: 'Риски',
|
||||
variant: riskCount > 0 ? 'destructive' : 'default',
|
||||
footer: (
|
||||
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||
{loading
|
||||
? '…'
|
||||
: riskCount > 0
|
||||
? `${failedJobs} задач · ${network.peersMismatch} расхождений`
|
||||
: 'в норме'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function buildDashboardKpiCards({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
return buildKpis({ modules, peers, speakers, jobs, loading }).map((card) => ({
|
||||
...card,
|
||||
id: card.id ?? String(card.label),
|
||||
}))
|
||||
}
|
||||
|
||||
export function DashboardKpiGrid({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
return (
|
||||
<KpiStatGrid
|
||||
items={buildKpis({ modules, peers, speakers, jobs, loading })}
|
||||
aria-label="KPI обзора"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
|
||||
import { kpiGridClassName } from '@/lib/ui-surface'
|
||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
function syntheticSparkline(seed: number, points = 9): number[] {
|
||||
const base = Math.max(4, seed)
|
||||
return Array.from({ length: points }, (_, i) =>
|
||||
Math.round(base * (0.82 + (i / points) * 0.18 + Math.sin(i + seed) * 0.04)),
|
||||
)
|
||||
}
|
||||
|
||||
function buildMetrics({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}): KpiSparklineMetric[] {
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const network = aggregateNetworkMetrics(peers, speakers)
|
||||
const bgpPct =
|
||||
network.peersEnabled > 0
|
||||
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
|
||||
: 0
|
||||
const running = runningJobCount(jobs)
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'bgp',
|
||||
title: 'BGP готовность',
|
||||
label: 'Установлено / включено',
|
||||
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
|
||||
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
|
||||
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
|
||||
detail: loading ? '' : `${network.peersEstablished} сессий`,
|
||||
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'danger',
|
||||
sparkline: syntheticSparkline(bgpPct || 40),
|
||||
},
|
||||
{
|
||||
id: 'modules',
|
||||
title: 'Модули',
|
||||
label: 'Активные списки',
|
||||
value: loading ? '—' : `${enabledModules}`,
|
||||
delta: loading ? '…' : `${modules.length} всего`,
|
||||
deltaVariant: 'primary-light',
|
||||
detail: loading ? '' : 'маршрутизация',
|
||||
tone: 'info',
|
||||
sparkline: syntheticSparkline(enabledModules || 3),
|
||||
},
|
||||
{
|
||||
id: 'speakers',
|
||||
title: 'Спикеры',
|
||||
label: 'В сети / всего',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
delta:
|
||||
loading || network.speakersTotal === 0
|
||||
? '…'
|
||||
: network.speakersOnline === network.speakersTotal
|
||||
? 'все в сети'
|
||||
: 'частично',
|
||||
deltaVariant:
|
||||
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
||||
detail: loading ? '' : 'live-снимок',
|
||||
tone: network.speakersOnline === network.speakersTotal ? 'success' : 'warning',
|
||||
sparkline: syntheticSparkline(network.speakersOnline || 2),
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
title: 'Задачи',
|
||||
label: 'Активные / ошибки',
|
||||
value: loading ? '—' : String(running),
|
||||
delta: loading ? '…' : failedJobs > 0 ? `${failedJobs} ошибок` : 'без сбоев',
|
||||
deltaVariant: failedJobs > 0 ? 'destructive-light' : 'success-light',
|
||||
detail: loading ? '' : `${jobs.length} в выборке`,
|
||||
tone: failedJobs > 0 ? 'danger' : running > 0 ? 'info' : 'success',
|
||||
sparkline: syntheticSparkline(running + failedJobs || 1),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function DashboardKpiSparklineRow({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const metrics = useMemo(
|
||||
() => buildMetrics({ modules, peers, speakers, jobs, loading }),
|
||||
[modules, peers, speakers, jobs, loading],
|
||||
)
|
||||
|
||||
return (
|
||||
<section aria-label="KPI обзора" className={kpiGridClassName}>
|
||||
{metrics.map((metric) => (
|
||||
<KpiSparklineCard key={metric.id} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { SegmentedProgressCard } from '@/components/patterns/segmented-progress-card'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
capacityUtilization,
|
||||
peerCapacityBars,
|
||||
speakerCapacityBars,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type CapacityMode = 'peers' | 'speakers'
|
||||
|
||||
export function DashboardNetworkHealth({
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<CapacityMode>('peers')
|
||||
|
||||
const bars = useMemo(
|
||||
() => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)),
|
||||
[mode, peers, speakers],
|
||||
)
|
||||
const utilization = capacityUtilization(bars)
|
||||
const queued = runningJobCount(jobs)
|
||||
|
||||
const established =
|
||||
mode === 'peers'
|
||||
? peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length
|
||||
: speakers.filter((s) => s.live?.agent_ok).length
|
||||
const total =
|
||||
mode === 'peers' ? peers.filter((p) => p.enabled !== false).length : speakers.length
|
||||
const offline = Math.max(0, total - established)
|
||||
|
||||
return (
|
||||
<SegmentedProgressCard
|
||||
title="Загрузка BGP"
|
||||
description="Утилизация сессий по пирам и спикерам"
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'peers', label: 'Пиры' },
|
||||
{ value: 'speakers', label: 'Спикеры' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
primary={{
|
||||
value: loading ? '—' : `${utilization}%`,
|
||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
|
||||
percent: loading ? 0 : utilization,
|
||||
badge: (
|
||||
<Badge
|
||||
variant={
|
||||
loading || total === 0
|
||||
? 'outline'
|
||||
: offline === 0
|
||||
? 'success-light'
|
||||
: 'warning-light'
|
||||
}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{loading
|
||||
? '…'
|
||||
: total === 0
|
||||
? 'нет данных'
|
||||
: offline === 0
|
||||
? mode === 'peers'
|
||||
? 'все установлены'
|
||||
: 'все в сети'
|
||||
: mode === 'peers'
|
||||
? `${offline} не установлены`
|
||||
: `${offline} офлайн`}
|
||||
</Badge>
|
||||
),
|
||||
}}
|
||||
secondary={{
|
||||
value: loading ? '—' : offline,
|
||||
label: mode === 'peers' ? 'Не установлены' : 'Не в сети',
|
||||
percent: total > 0 ? Math.round((offline / total) * 100) : 0,
|
||||
}}
|
||||
footer={
|
||||
loading
|
||||
? 'Загрузка…'
|
||||
: `Активных задач: ${queued} · сегментов в графике: ${bars.length}`
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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 { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, ModuleRow } from '@/types/api'
|
||||
|
||||
type FlowMode = 'jobs' | 'modules'
|
||||
|
||||
/**
|
||||
* 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,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
modules: ModuleRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<FlowMode>('jobs')
|
||||
|
||||
const slices = useMemo(
|
||||
() => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)),
|
||||
[mode, jobs, modules],
|
||||
)
|
||||
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Поток операций"
|
||||
description="Доли статусов задач или типов модулей"
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'jobs', label: 'Задачи' },
|
||||
{ value: 'modules', label: 'Модули' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
className="min-w-0"
|
||||
contentClassName="px-5 py-4"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
||||
)}
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
export function DashboardQuickActions() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
|
||||
<Plus className="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
|
||||
<Tags className="size-4" />
|
||||
Добавить BGP-сообщество
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'overview' }} />}>
|
||||
<Network className="size-4" />
|
||||
Сеть
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'peers' }} />}>
|
||||
<Share2 className="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
render={<Link to="/operations" search={{ tab: 'revisions' }} />}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Деплой
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}>
|
||||
<Gauge className="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Gauge, Network, Play, Plus, Search, Share2, Tags } from 'lucide-react'
|
||||
|
||||
import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit'
|
||||
|
||||
/** iconClassName: semantic text only (shared chrome with CFDM). @see https://reui.io/preview/base/stats-12 */
|
||||
const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'lookup',
|
||||
title: 'Проверка IP/домена',
|
||||
description: 'Проверка IP в списках и BGP community.',
|
||||
to: '/lookup',
|
||||
icon: <Search aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
{
|
||||
id: 'new-module',
|
||||
title: 'Создать модуль',
|
||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||
to: '/modules',
|
||||
search: { create: true },
|
||||
icon: <Plus aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
{
|
||||
id: 'communities',
|
||||
title: 'BGP community',
|
||||
description: 'Справочник BGP community для политик экспорта.',
|
||||
to: '/directories',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
title: 'Сеть',
|
||||
description: 'Обзор пиров, спикеров и живых BGP-сессий.',
|
||||
to: '/network',
|
||||
search: { tab: 'overview' },
|
||||
icon: <Network aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
},
|
||||
{
|
||||
id: 'add-peer',
|
||||
title: 'Добавить пира',
|
||||
description: 'Настройка BGP-соседа и шаблонов сессии.',
|
||||
to: '/network',
|
||||
search: { tab: 'peers' },
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
},
|
||||
{
|
||||
id: 'deploy',
|
||||
title: 'Деплой',
|
||||
description: 'Ревизии конфигурации и применение на нодах.',
|
||||
to: '/operations',
|
||||
search: { tab: 'revisions' },
|
||||
icon: <Play aria-hidden />,
|
||||
iconClassName: 'text-muted-foreground',
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
title: 'Мониторинг',
|
||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||
to: '/monitoring',
|
||||
icon: <Gauge aria-hidden />,
|
||||
iconClassName: 'text-destructive',
|
||||
},
|
||||
]
|
||||
|
||||
export function DashboardQuickLinks() {
|
||||
return (
|
||||
<QuickActionGrid
|
||||
actions={ACTIONS}
|
||||
description="Частые переходы к настройке и деплою"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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 { 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'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
export function DashboardRecentJobsGrid({
|
||||
jobs,
|
||||
nameById,
|
||||
isLoading = false,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
nameById: Map<string, string>
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const data = useMemo(() => jobs.slice(0, 8), [jobs])
|
||||
|
||||
const columns = useMemo<ColumnDef<JobRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'kind',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={jobKindRu(row.original.kind)}
|
||||
accent="mono"
|
||||
subtitle={
|
||||
row.original.meta?.module_id
|
||||
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Вид' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
],
|
||||
[nameById],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => {
|
||||
const moduleName = row.meta?.module_id
|
||||
? (nameById.get(String(row.meta.module_id)) ?? '')
|
||||
: ''
|
||||
return `${jobKindRu(row.kind)} ${row.status} ${moduleName}`
|
||||
},
|
||||
getRowId: (row) => row.job_id,
|
||||
pageSize: 8,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск задач…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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 { 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'
|
||||
import type { RevisionRow } from '@/types/api'
|
||||
|
||||
export function DashboardRecentRevisionsGrid({
|
||||
revisions,
|
||||
isLoading = false,
|
||||
}: {
|
||||
revisions: RevisionRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const data = useMemo(() => revisions.slice(0, 8), [revisions])
|
||||
|
||||
const columns = useMemo<ColumnDef<RevisionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'id',
|
||||
accessorFn: (row) => row.id,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={`${row.original.id.slice(0, 10)}…`} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'ID' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => row.id,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: 8,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ревизий"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск ревизий…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,38 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
const ACCENT_CLASS = {
|
||||
primary: 'font-medium text-primary',
|
||||
default: 'font-medium text-foreground',
|
||||
mono: 'font-mono text-sm text-primary',
|
||||
} as const
|
||||
|
||||
export function DataGridPrimaryCell({
|
||||
/**
|
||||
* Name cell DNA — IconTile elevated size-10.5 + truncate.
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function DataGridNameCell({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
accent = 'default',
|
||||
iconClassName = 'text-muted-foreground',
|
||||
className,
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
title: ReactNode
|
||||
subtitle?: ReactNode
|
||||
accent?: keyof typeof ACCENT_CLASS
|
||||
iconClassName?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex min-w-0 flex-col gap-0.5', className)}>
|
||||
<span className={cn('truncate', ACCENT_CLASS[accent])}>{title}</span>
|
||||
{subtitle ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
|
||||
) : null}
|
||||
<div className={cn('flex min-w-0 items-center gap-2.5', className)}>
|
||||
<IconTile variant="elevated" className="size-10.5">
|
||||
<Icon className={iconClassName} aria-hidden />
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate font-medium">{title}</span>
|
||||
{subtitle ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -42,3 +50,16 @@ export function DataGridMutedCell({
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Mono / secondary cell without semantic primary color. */
|
||||
export function DataGridMonoCell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<span className={cn('truncate font-mono text-sm', className)}>{children}</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { DataGridToolbar } from '@/components/data-grid-toolbar'
|
||||
import { PanelCard, panelCardContentFlushClassName, panelCardFooterClassName, panelCardInsetClassName } from '@/components/panel-card'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
DATA_GRID_MESSAGES_RU,
|
||||
DATA_GRID_PAGINATION_RU,
|
||||
DATA_GRID_TABLE_CLASS_NAMES,
|
||||
DATA_GRID_TABLE_LAYOUT,
|
||||
} from '@/lib/data-grid-defaults'
|
||||
|
||||
interface DataGridShellProps<TData extends object> {
|
||||
table: Table<TData>
|
||||
recordCount: number
|
||||
isLoading?: boolean
|
||||
emptyMessage?: ReactNode
|
||||
showPagination?: boolean
|
||||
tableLayout?: typeof DATA_GRID_TABLE_LAYOUT
|
||||
className?: string
|
||||
onRowClick?: (row: TData) => void
|
||||
}
|
||||
|
||||
export function DataGridShell<TData extends object>({
|
||||
table,
|
||||
recordCount,
|
||||
isLoading = false,
|
||||
emptyMessage,
|
||||
showPagination = true,
|
||||
tableLayout = DATA_GRID_TABLE_LAYOUT,
|
||||
className,
|
||||
onRowClick,
|
||||
}: DataGridShellProps<TData>) {
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={recordCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage ?? DATA_GRID_MESSAGES_RU.emptyMessage}
|
||||
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||
tableLayout={tableLayout}
|
||||
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
|
||||
className={className}
|
||||
onRowClick={onRowClick}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
{showPagination ? (
|
||||
<div className={cn(panelCardFooterClassName, panelCardInsetClassName)}>
|
||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
||||
</div>
|
||||
) : null}
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataGridCardProps {
|
||||
title?: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DataGridCard({ title, description, actions, children, className }: DataGridCardProps) {
|
||||
return (
|
||||
<PanelCard
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
className={className}
|
||||
contentClassName={panelCardContentFlushClassName}
|
||||
>
|
||||
{children}
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataGridSectionProps<TData extends object> extends DataGridShellProps<TData> {
|
||||
searchValue: string
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
toolbarFilters?: ReactNode
|
||||
toolbarActions?: ReactNode
|
||||
beforeGrid?: ReactNode
|
||||
}
|
||||
|
||||
export function DataGridSection<TData extends object>({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
toolbarFilters,
|
||||
toolbarActions,
|
||||
beforeGrid,
|
||||
...shellProps
|
||||
}: DataGridSectionProps<TData>) {
|
||||
return (
|
||||
<>
|
||||
<DataGridToolbar
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
filters={toolbarFilters}
|
||||
actions={toolbarActions}
|
||||
/>
|
||||
{beforeGrid}
|
||||
<DataGridShell {...shellProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Field } from '@evobgp/ui/components/field'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import { ListFilterIcon, SearchIcon, XIcon } from 'lucide-react'
|
||||
|
||||
import { panelCardInsetClassName } from '@/components/panel-card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
interface DataGridToolbarProps {
|
||||
searchValue: string
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
filters?: ReactNode
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Search row for data grids (ReUI c-input-group-37 pattern). */
|
||||
export function DataGridToolbar({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Поиск…',
|
||||
filters,
|
||||
actions,
|
||||
className,
|
||||
}: DataGridToolbarProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center gap-3 border-b', panelCardInsetClassName, className)}>
|
||||
<Field className="min-w-[200px] flex-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end" className="gap-1">
|
||||
{searchValue.length > 0 ? (
|
||||
<InputGroupButton
|
||||
aria-label="Очистить поиск"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => onSearchChange('')}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
) : null}
|
||||
{filters ? (
|
||||
filters
|
||||
) : (
|
||||
<InputGroupButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="pointer-events-none gap-1.5 opacity-0"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ListFilterIcon className="size-3.5" />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 ? 'Редактировать community' : 'Новое BGP community'}
|
||||
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
|
||||
@@ -1,34 +1,52 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Tags } from 'lucide-react'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
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 { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createSearchFilterField,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import type { BgpCommunity } from '@/types/api'
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
createSearchFilterField('search', 'Поиск', 'Поиск community…'),
|
||||
]
|
||||
|
||||
export function DirectoriesCommunitiesGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
canWrite = false,
|
||||
onEdit,
|
||||
actions,
|
||||
}: {
|
||||
items: BgpCommunity[]
|
||||
isLoading?: boolean
|
||||
canWrite?: boolean
|
||||
onEdit?: (row: BgpCommunity) => void
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<BgpCommunity>[]>(
|
||||
() => [
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
|
||||
const columns = useMemo<DataGridColumnDef<BgpCommunity>[]>(() => {
|
||||
const cols: DataGridColumnDef<BgpCommunity>[] = [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.title} accent="primary" />,
|
||||
cell: ({ row }) => <DataGridNameCell icon={Tags} title={row.original.title} />,
|
||||
meta: { headerTitle: 'Название' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'community',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.community} accent="mono" />
|
||||
),
|
||||
cell: ({ row }) => <DataGridMonoCell>{row.original.community}</DataGridMonoCell>,
|
||||
meta: { headerTitle: 'Значение' },
|
||||
},
|
||||
{
|
||||
@@ -38,26 +56,38 @@ export function DirectoriesCommunitiesGrid({
|
||||
cell: () => <CategoryBadge>community</CategoryBadge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
]
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.title} ${row.community}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
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])
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
<ResourcePage
|
||||
title="BGP community"
|
||||
description="Теги для префиксов в фильтрах BIRD"
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={(row) => `${row.title} ${row.community}`}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет сообществ"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск сообществ…"
|
||||
primaryAction={actions}
|
||||
pinLastColumn={Boolean(canWrite && onEdit)}
|
||||
emptyState={{ title: 'Нет community', action: actions }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,51 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Globe } from 'lucide-react'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
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 { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createSearchFilterField,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import type { DohProfile } from '@/types/api'
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
createSearchFilterField('search', 'Поиск', 'Поиск DoH профилей…'),
|
||||
]
|
||||
|
||||
export function DirectoriesDohGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
canWrite = false,
|
||||
onEdit,
|
||||
actions,
|
||||
}: {
|
||||
items: DohProfile[]
|
||||
isLoading?: boolean
|
||||
canWrite?: boolean
|
||||
onEdit?: (row: DohProfile) => void
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DohProfile>[]>(
|
||||
() => [
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
|
||||
const columns = useMemo<DataGridColumnDef<DohProfile>[]>(() => {
|
||||
const cols: DataGridColumnDef<DohProfile>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.url,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
<DataGridNameCell
|
||||
icon={Globe}
|
||||
title={row.original.name ?? row.original.url}
|
||||
subtitle={row.original.name ? row.original.url : undefined}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Название' },
|
||||
@@ -33,7 +53,7 @@ export function DirectoriesDohGrid({
|
||||
{
|
||||
accessorKey: 'url',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.url} accent="mono" />,
|
||||
cell: ({ row }) => <DataGridMonoCell>{row.original.url}</DataGridMonoCell>,
|
||||
meta: { headerTitle: 'URL' },
|
||||
},
|
||||
{
|
||||
@@ -43,26 +63,38 @@ export function DirectoriesDohGrid({
|
||||
cell: () => <CategoryBadge>—</CategoryBadge>,
|
||||
meta: { headerTitle: 'По умолчанию' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
]
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.name ?? ''} ${row.url}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
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])
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
<ResourcePage
|
||||
title="DoH профили"
|
||||
description="Резолверы DNS-over-HTTPS для доменных модулей"
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={(row) => `${row.name ?? ''} ${row.url}`}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет DoH профилей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск DoH профилей…"
|
||||
primaryAction={actions}
|
||||
pinLastColumn={Boolean(canWrite && onEdit)}
|
||||
emptyState={{ title: 'Нет DoH профилей', action: actions }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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('Таймаут должен быть целым числом больше 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">Таймаут, мс (необязательно)</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,120 @@
|
||||
/**
|
||||
* 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 {
|
||||
Frame,
|
||||
FramePanel,
|
||||
} from '@/components/reui/frame'
|
||||
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
|
||||
/** Wrap in Frame (empty-state-14). Nested grids should leave this false. */
|
||||
framed?: 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,
|
||||
framed = false,
|
||||
}: EmptyStateProps) {
|
||||
const Icon = isLucideIcon(icon) ? icon : InboxIcon
|
||||
const customIcon = icon && !isLucideIcon(icon) ? icon : null
|
||||
|
||||
const empty = (
|
||||
<Empty
|
||||
className={cn(
|
||||
'max-w-md flex-none border-0 bg-transparent p-0',
|
||||
!centered && !framed && 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>
|
||||
)
|
||||
|
||||
const body = framed ? (
|
||||
<Frame dense spacing="sm" className={cn('w-full', !centered && className)}>
|
||||
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
||||
{empty}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
) : (
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||
import { Field } from "@evobgp/ui/components/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import { Label } from "@evobgp/ui/components/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
|
||||
|
||||
const statuses = ["Pending", "Shipped", "Cancelled"] as const
|
||||
|
||||
type Status = (typeof statuses)[number]
|
||||
|
||||
function toggleStatus(values: Status[], value: Status) {
|
||||
return values.includes(value)
|
||||
? values.filter((item) => item !== value)
|
||||
: [...values, value]
|
||||
}
|
||||
|
||||
export function Pattern() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
|
||||
|
||||
return (
|
||||
<Field className="max-w-sm">
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<InputGroupInput
|
||||
placeholder="Search orders..."
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
/>
|
||||
|
||||
<InputGroupAddon align="inline-end" className="gap-1">
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupButton
|
||||
aria-label="Clear search"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
) : null}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<InputGroupButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
aria-label="Filter order status"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListFilterIcon className="size-3.5" aria-hidden="true" />
|
||||
Status
|
||||
{selectedStatuses.length > 0 ? (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{selectedStatuses.length}
|
||||
</span>
|
||||
) : null}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-40 p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{statuses.map((status) => (
|
||||
<div key={status} className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
id={`order-status-${status}`}
|
||||
checked={selectedStatuses.includes(status)}
|
||||
onCheckedChange={() =>
|
||||
setSelectedStatuses((previous) =>
|
||||
toggleStatus(previous, status)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`order-status-${status}`}
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
{status}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Field } from '@evobgp/ui/components/field'
|
||||
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
|
||||
const items = [
|
||||
{ label: 'Select an item', value: 'placeholder' as const },
|
||||
...Array.from({ length: 100 }).map((_, i) => ({
|
||||
label: `Item ${i}`,
|
||||
value: `item-${i}` as const,
|
||||
})),
|
||||
]
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<Field className="max-w-xs">
|
||||
<SelectMenu items={items} placeholder="Select an item" />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">
|
||||
<LayoutDashboardIcon className="size-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics">
|
||||
<BarChart3Icon className="size-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings">
|
||||
<SettingsIcon className="size-4" />
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<Card>
|
||||
<CardContent>Overview dashboard content goes here.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="analytics">
|
||||
<Card>
|
||||
<CardContent>Analytics charts and metrics.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<Card>
|
||||
<CardContent>Application settings and preferences.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Tabs defaultValue="inbox">
|
||||
<TabsList variant="line" className="mb-3.5 w-full">
|
||||
<TabsTrigger value="inbox" className="gap-2">
|
||||
Inbox
|
||||
<Badge variant="primary-light" size="sm">
|
||||
12
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drafts" className="gap-2">
|
||||
Drafts
|
||||
<Badge variant="info-light" size="sm">
|
||||
3
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sent" className="gap-2">
|
||||
Sent
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="spam" className="gap-2">
|
||||
Spam
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
24
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="inbox">
|
||||
<Card>
|
||||
<CardContent>12 unread messages in your inbox.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="drafts">
|
||||
<Card>
|
||||
<CardContent>3 drafts waiting to be sent.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="sent">
|
||||
<Card>
|
||||
<CardContent>All sent messages appear here.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="spam">
|
||||
<Card>
|
||||
<CardContent>24 spam messages detected.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
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 type { FirewallClient } from '@/types/api'
|
||||
|
||||
function formatPacketCount(value?: number | null): string | null {
|
||||
if (value == null || value <= 0) return null
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export interface FirewallClientsGridProps {
|
||||
clients: FirewallClient[]
|
||||
isLoading?: boolean
|
||||
onApprove: (id: string) => void
|
||||
onReject: (id: string) => void
|
||||
approvePending?: boolean
|
||||
rejectPending?: boolean
|
||||
emptyTitle?: string
|
||||
}
|
||||
|
||||
export function FirewallClientsGrid({
|
||||
clients,
|
||||
isLoading = false,
|
||||
onApprove,
|
||||
onReject,
|
||||
approvePending = false,
|
||||
rejectPending = false,
|
||||
emptyTitle = 'Нет клиентов',
|
||||
}: FirewallClientsGridProps) {
|
||||
const columns = useMemo<ColumnDef<FirewallClient>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.name}
|
||||
subtitle={row.original.hostname || row.original.token_prefix}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'last_seen_at',
|
||||
accessorFn: (row) => row.last_seen_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последняя активность" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
|
||||
),
|
||||
sortingFn: (a, b) => {
|
||||
const av = a.original.last_seen_at ?? ''
|
||||
const bv = b.original.last_seen_at ?? ''
|
||||
return av.localeCompare(bv)
|
||||
},
|
||||
meta: { headerTitle: 'Последняя активность' },
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
enableSorting: false,
|
||||
header: 'Применение',
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{c.last_apply_status ?? '—'}
|
||||
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Применение' },
|
||||
},
|
||||
{
|
||||
id: 'packets',
|
||||
enableSorting: false,
|
||||
header: 'Пакеты',
|
||||
cell: ({ row }) => {
|
||||
const dropped = formatPacketCount(row.original.last_apply_packets_dropped)
|
||||
const accepted = formatPacketCount(row.original.last_apply_packets_accepted)
|
||||
if (!dropped && !accepted) {
|
||||
return <span className="text-muted-foreground text-xs">—</span>
|
||||
}
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{dropped ? <span className="text-destructive">↓{dropped}</span> : null}
|
||||
{dropped && accepted ? ' · ' : null}
|
||||
{accepted ? <span className="text-success">↑{accepted}</span> : null}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Пакеты' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
{c.status === 'pending' ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={approvePending}
|
||||
onClick={() => onApprove(c.id)}
|
||||
>
|
||||
Одобрить
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={rejectPending}
|
||||
>
|
||||
Отклонить
|
||||
</Button>
|
||||
}
|
||||
title="Отклонить запрос?"
|
||||
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
|
||||
confirmLabel="Отклонить"
|
||||
destructive
|
||||
onConfirm={() => onReject(c.id)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{c.status === 'approved' ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={rejectPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить клиент?"
|
||||
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={() => onReject(c.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[approvePending, onApprove, onReject, rejectPending],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: clients,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyTitle}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск клиентов…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
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 { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { useCreateFirewallRule } from '@/queries/firewall'
|
||||
import type { BgpCommunity } from '@/types/api'
|
||||
|
||||
const FIREWALL_ACTION_ITEMS = [
|
||||
{ value: 'block', label: 'block' },
|
||||
{ value: 'accept', label: 'accept' },
|
||||
] as const
|
||||
|
||||
interface FirewallRuleCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
communities: BgpCommunity[]
|
||||
}
|
||||
|
||||
export function FirewallRuleCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
communities,
|
||||
}: FirewallRuleCreateDialogProps) {
|
||||
const createMutation = useCreateFirewallRule()
|
||||
const [action, setAction] = useState<'block' | 'accept'>('block')
|
||||
const [communityId, setCommunityId] = useState<string | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setAction('block')
|
||||
setCommunityId(null)
|
||||
setComment('')
|
||||
}, [open])
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
scope: 'tenant',
|
||||
action,
|
||||
community_id: communityId,
|
||||
comment: comment.trim(),
|
||||
})
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast handled in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новое правило"
|
||||
className="sm:max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" onClick={save} loading={createMutation.isPending}>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
id="fw-rule-action"
|
||||
label="Действие"
|
||||
items={[...FIREWALL_ACTION_ITEMS]}
|
||||
value={action}
|
||||
placeholder="Выберите действие"
|
||||
onValueChange={(v) => v && setAction(v as 'block' | 'accept')}
|
||||
/>
|
||||
<CommunitySelect
|
||||
id="fw-rule-community"
|
||||
label="Community"
|
||||
value={communityId}
|
||||
onValueChange={setCommunityId}
|
||||
communities={communities}
|
||||
nullable
|
||||
placeholder="Все communities"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="fw-rule-comment">Комментарий</Label>
|
||||
<Input
|
||||
id="fw-rule-comment"
|
||||
placeholder="Комментарий"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
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 { communityLabel } from '@/lib/modules/helpers'
|
||||
import type { BgpCommunity, FirewallRule } from '@/types/api'
|
||||
|
||||
export interface FirewallRulesGridProps {
|
||||
rules: FirewallRule[]
|
||||
communities: BgpCommunity[]
|
||||
isLoading?: boolean
|
||||
onDelete: (id: string) => void
|
||||
deletePending?: boolean
|
||||
emptyTitle?: string
|
||||
}
|
||||
|
||||
export function FirewallRulesGrid({
|
||||
rules,
|
||||
communities,
|
||||
isLoading = false,
|
||||
onDelete,
|
||||
deletePending = false,
|
||||
emptyTitle = 'Нет правил — blocklist пуст (default accept).',
|
||||
}: FirewallRulesGridProps) {
|
||||
const columns = useMemo<ColumnDef<FirewallRule>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
|
||||
cell: ({ row }) => row.original.priority,
|
||||
meta: { headerTitle: '#' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge status={row.original.action} label={row.original.action} />
|
||||
),
|
||||
meta: { headerTitle: 'Действие' },
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
enableSorting: false,
|
||||
header: 'Community',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">
|
||||
{row.original.community_id
|
||||
? communityLabel(row.original.community_id, communities)
|
||||
: 'Все'}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Community' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'comment',
|
||||
enableSorting: false,
|
||||
header: 'Комментарий',
|
||||
cell: ({ row }) => row.original.comment || '—',
|
||||
meta: { headerTitle: 'Комментарий' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={deletePending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить правило?"
|
||||
description={
|
||||
r.comment
|
||||
? `Правило #${r.priority} (${r.action}): ${r.comment}`
|
||||
: `Правило #${r.priority} (${r.action}) будет удалено.`
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={() => onDelete(r.id)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[communities, deletePending, onDelete],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: rules,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyTitle}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск правил…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@evobgp/ui/components/drawer'
|
||||
import { FieldGroup } from '@evobgp/ui/components/field'
|
||||
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 +22,11 @@ interface FormDrawerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/edit overlay — Sheet DNA (sheet-8) + FieldGroup (form-7).
|
||||
* @see https://reui.io/preview/base/sheet-8
|
||||
* @see https://reui.io/preview/base/form-7
|
||||
*/
|
||||
export function FormDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -35,17 +37,26 @@ 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('flex w-full flex-col 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="min-h-0 flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<FieldGroup className="p-4">{children}</FieldGroup>
|
||||
</ScrollArea>
|
||||
</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,8 +8,7 @@ import {
|
||||
Settings,
|
||||
BookText,
|
||||
KeyRound,
|
||||
ServerCog,
|
||||
Shield,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -36,16 +35,27 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from '@evobgp/ui/components/breadcrumb'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
|
||||
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { ComponentType, CSSProperties, ReactNode } from 'react'
|
||||
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
||||
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
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
description?: string
|
||||
search?: Record<string, string>
|
||||
/** Explicit permission override; when omitted derived from `to`. */
|
||||
permission?: string
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
@@ -56,119 +66,168 @@ interface NavGroup {
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Обзор',
|
||||
items: [{ to: '/dashboard', label: 'Панель', icon: LayoutDashboard }],
|
||||
items: [
|
||||
{
|
||||
to: '/dashboard',
|
||||
label: 'Панель',
|
||||
icon: LayoutDashboard,
|
||||
description: 'Метрики, модули и активность',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Маршрутизация',
|
||||
items: [
|
||||
{ to: '/modules', label: 'Модули', icon: Boxes },
|
||||
{ to: '/network', label: 'Сеть', icon: Network },
|
||||
{ to: '/directories', label: 'Справочники', icon: BookText },
|
||||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
||||
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
||||
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
||||
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Операции',
|
||||
items: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog },
|
||||
{ to: '/firewall', label: 'Файрвол', icon: Shield },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Система',
|
||||
items: [
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound },
|
||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog },
|
||||
{ to: '/settings', label: 'Настройки UI', icon: Settings },
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
||||
|
||||
/** Nav visible for the current claim (or all items when portal auth is off). */
|
||||
function useVisibleNavGroups(): NavGroup[] {
|
||||
if (!isAuthEnabled()) return NAV_GROUPS
|
||||
return NAV_GROUPS.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => {
|
||||
const perm = item.permission ?? permissionForPath(item.to)
|
||||
return !perm || can(perm)
|
||||
}),
|
||||
})).filter((group) => group.items.length > 0)
|
||||
}
|
||||
|
||||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||
)
|
||||
|
||||
const PARENT_ROUTE: Record<string, string> = {}
|
||||
const PARENT_ROUTE: Record<string, string> = {
|
||||
'/modules/new': '/modules',
|
||||
}
|
||||
|
||||
const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
|
||||
id: item.to,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
to: item.to,
|
||||
search: item.search,
|
||||
keywords: [item.to.replace(/^\//, '')],
|
||||
}))
|
||||
|
||||
/**
|
||||
* Shared ops chrome etalon for CFDM / vps-tracker.
|
||||
* @see https://reui.io/preview/base/app-shell-12
|
||||
* @see docs/ui-design-contract.md — Shared App Shell chrome
|
||||
*/
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const visibleGroups = useVisibleNavGroups()
|
||||
const activeItem =
|
||||
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
||||
ALL_NAV_ITEMS[0]
|
||||
const parentTo = PARENT_ROUTE[activeItem.to]
|
||||
const parentTo = PARENT_ROUTE[pathname] ?? PARENT_ROUTE[activeItem.to]
|
||||
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
|
||||
|
||||
const pageTitle =
|
||||
pathname.startsWith('/modules/') && pathname !== '/modules/new'
|
||||
? 'Модуль'
|
||||
: (ROUTE_LABELS[activeItem.to] ?? activeItem.label)
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
|
||||
B
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-semibold">EvoBGP</span>
|
||||
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<AppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{visibleGroups.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} search={item.search} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<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" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{parentLabel && parentTo ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
</>
|
||||
) : null}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{pageTitle}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AppsMenu />
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{parentLabel && parentTo ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
</>
|
||||
) : null}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? activeItem.label}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</SidebarInset>
|
||||
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { LayoutGridIcon } from 'lucide-react'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
||||
|
||||
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppsMenu() {
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="Приложения" />
|
||||
}
|
||||
>
|
||||
<LayoutGridIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-72"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||
</DropdownMenuLabel>
|
||||
<div className="grid grid-cols-3 gap-1 p-1">
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
||||
const isCurrent = app.id === CURRENT_APP_ID
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
disabled
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
nativeButton={false}
|
||||
render={<a href={app.url} />}
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{isAuthEnabled() ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={authPortalUrl()} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настроить на портале
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Kbd } from '@evobgp/ui/components/kbd'
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
|
||||
export type CommandPaletteItem = {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
to: string
|
||||
search?: Record<string, string>
|
||||
keywords?: string[]
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
items: CommandPaletteItem[]
|
||||
className?: string
|
||||
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
|
||||
hotkeyOnly?: boolean
|
||||
}
|
||||
|
||||
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return items
|
||||
return items.filter((item) => {
|
||||
const haystack = [item.label, item.description, ...(item.keywords ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return haystack.includes(q)
|
||||
})
|
||||
}, [items, query])
|
||||
|
||||
function go(item: CommandPaletteItem) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
void navigate({ to: item.to, search: item.search })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hotkeyOnly ? (
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupContent className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Поиск…
|
||||
</Button>
|
||||
<Search
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
|
||||
/>
|
||||
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
|
||||
⌘K
|
||||
</Kbd>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
if (!next) setQuery('')
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Быстрый переход</DialogTitle>
|
||||
<DialogDescription>Навигация по разделам EvoBGP</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md gap-0 px-0 py-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3">
|
||||
<div className="flex items-center gap-3 border-b px-4 py-3">
|
||||
<Search aria-hidden className="size-4 shrink-0 opacity-60" />
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-9 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Раздел или действие…"
|
||||
aria-label="Поиск разделов"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filtered[0]) go(filtered[0])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ul className="max-h-72 overflow-y-auto p-2" role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="text-muted-foreground px-2 py-6 text-center text-sm">Ничего не найдено</li>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
className="hover:bg-accent flex w-full flex-col items-start gap-0.5 rounded-md px-3 py-2 text-left text-sm transition-colors"
|
||||
onClick={() => go(item)}
|
||||
>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
{item.description ? (
|
||||
<span className="text-muted-foreground text-xs">{item.description}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import {
|
||||
ChevronsUpDownIcon,
|
||||
ExternalLinkIcon,
|
||||
LogOutIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
PaletteIcon,
|
||||
SettingsIcon,
|
||||
SunIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
|
||||
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
import {
|
||||
authPortalUrl,
|
||||
clearPortalToken,
|
||||
getClaims,
|
||||
isAuthEnabled,
|
||||
redirectToPortalLogout,
|
||||
resetPortalHandoff,
|
||||
} from '@/lib/auth'
|
||||
|
||||
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
||||
|
||||
const THEMES = [
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Светлая',
|
||||
icon: <SunIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Тёмная',
|
||||
icon: <MoonIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: 'system',
|
||||
label: 'Системная',
|
||||
icon: <MonitorIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
] as const
|
||||
|
||||
function ThemeSegmentedToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const currentTheme = mounted ? (theme ?? 'system') : 'system'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Тема"
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{THEMES.map(({ value, label, icon }) => {
|
||||
const isActive = currentTheme === value
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(source: string): string {
|
||||
const base = source.trim()
|
||||
if (!base) return '?'
|
||||
const parts = base.split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||
}
|
||||
return base.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const authOn = isAuthEnabled()
|
||||
const claims = getClaims()
|
||||
|
||||
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
|
||||
const email =
|
||||
claims?.email?.trim() || (authOn ? '' : 'локальный API-токен')
|
||||
const fallback = initials(name || email)
|
||||
|
||||
function handleSignOut() {
|
||||
if (authOn) {
|
||||
clearPortalToken()
|
||||
resetPortalHandoff()
|
||||
redirectToPortalLogout()
|
||||
return
|
||||
}
|
||||
setApiToken(null)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||
window.location.assign('/settings?tab=ui&reason=token-required')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--anchor-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
{fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||
>
|
||||
<SettingsIcon aria-hidden />
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
{authOn ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={authPortalUrl()} />}
|
||||
>
|
||||
<ExternalLinkIcon aria-hidden />
|
||||
Открыть Auth Portal
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<PaletteIcon aria-hidden />
|
||||
Тема
|
||||
<div className="ml-auto">
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOutIcon aria-hidden />
|
||||
{authOn ? 'Выйти' : 'Сбросить токен'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Activity, Bird, HeartPulse, ListChecks, Network } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@evobgp/ui/components/popover'
|
||||
import { Progress } from '@evobgp/ui/components/progress'
|
||||
|
||||
import { monitoringHealthQueryOptions } from '@/queries/monitoring'
|
||||
import { networkBirdQueryOptions } from '@/queries/network'
|
||||
import { aggregateNetworkMetrics, overviewJobsQueryOptions, overviewPeersQueryOptions } from '@/queries/overview'
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
unit: string
|
||||
percent: number
|
||||
icon: ReactNode
|
||||
tone: 'success' | 'warning' | 'destructive' | 'info'
|
||||
alert: boolean
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric['tone']) {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
return 'var(--color-success)'
|
||||
case 'warning':
|
||||
return 'var(--color-warning)'
|
||||
case 'destructive':
|
||||
return 'var(--color-destructive)'
|
||||
default:
|
||||
return 'var(--color-info)'
|
||||
}
|
||||
}
|
||||
|
||||
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
const color = toneColor(metric.tone)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item
|
||||
className="flex size-5 shrink-0 items-center justify-center p-0"
|
||||
style={{ backgroundColor: `${color}18` }}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
|
||||
{metric.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
||||
{metric.value}
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={metric.percent}
|
||||
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
||||
style={{ '--bar-color': color } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live system monitor popover (app-shell-7 pattern, EvoBGP API data). */
|
||||
export function SystemMonitorPopover() {
|
||||
const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 })
|
||||
const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 })
|
||||
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
|
||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||
|
||||
const peers = peersQ.data?.items ?? []
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const network = aggregateNetworkMetrics(peers, [])
|
||||
const bgpPct =
|
||||
network.peersEnabled > 0
|
||||
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
|
||||
: 0
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const runningJobs = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
const healthOk = healthQ.data?.ok ?? false
|
||||
const birdRatio =
|
||||
birdQ.data && birdQ.data.bgp_sessions_total > 0
|
||||
? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100)
|
||||
: null
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'api',
|
||||
label: 'Состояние API',
|
||||
value: healthOk ? 'норма' : '—',
|
||||
unit: '',
|
||||
percent: healthOk ? 100 : 0,
|
||||
icon: <HeartPulse aria-hidden />,
|
||||
tone: healthOk ? 'success' : 'destructive',
|
||||
alert: !healthOk,
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
label: 'BGP пиры',
|
||||
value: String(bgpPct),
|
||||
unit: '%',
|
||||
percent: bgpPct,
|
||||
icon: <Network aria-hidden />,
|
||||
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'destructive',
|
||||
alert: bgpPct < 70 && network.peersEnabled > 0,
|
||||
},
|
||||
{
|
||||
id: 'bird',
|
||||
label: 'BIRD сессии',
|
||||
value: birdRatio !== null ? String(birdRatio) : '—',
|
||||
unit: birdRatio !== null ? '%' : '',
|
||||
percent: birdRatio ?? 0,
|
||||
icon: <Bird aria-hidden />,
|
||||
tone:
|
||||
birdRatio === null
|
||||
? 'info'
|
||||
: birdRatio >= 100
|
||||
? 'success'
|
||||
: birdRatio >= 50
|
||||
? 'warning'
|
||||
: 'destructive',
|
||||
alert: birdRatio !== null && birdRatio < 100,
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Ошибки задач',
|
||||
value: String(failedJobs),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, failedJobs * 10),
|
||||
icon: <ListChecks aria-hidden />,
|
||||
tone: failedJobs > 0 ? 'warning' : 'success',
|
||||
alert: failedJobs > 0,
|
||||
},
|
||||
],
|
||||
[bgpPct, birdRatio, failedJobs, healthOk, network.peersEnabled],
|
||||
)
|
||||
|
||||
const spiking = metrics.some((m) => m.alert) || runningJobs > 5
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Монитор системы"
|
||||
className={cn(
|
||||
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
||||
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="relative flex size-3.5 items-center justify-center">
|
||||
<Activity
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3.5 transition-colors',
|
||||
spiking ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
{spiking ? (
|
||||
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
||||
<Badge
|
||||
variant={spiking ? 'destructive-light' : 'success-light'}
|
||||
size="xs"
|
||||
className="h-4 px-1.5 text-[10px]"
|
||||
>
|
||||
{spiking ? 'Внимание' : 'Норма'}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{new Date().toLocaleTimeString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
{metrics.map((metric, i) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={cn(
|
||||
i % 2 === 1 && 'border-border border-l',
|
||||
i >= 2 && 'border-border border-t',
|
||||
)}
|
||||
>
|
||||
<MetricCell metric={metric} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
Активных задач: <span className="text-foreground font-medium tabular-nums">{runningJobs}</span>
|
||||
{' · '}
|
||||
Пиров в каталоге:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{network.peersTotal}</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { Spinner } from '@evobgp/ui/components/spinner'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
type LoadingButtonProps = ComponentProps<typeof Button> & {
|
||||
loading?: boolean
|
||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
|
||||
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** SoT for async actions — Button + Spinner. */
|
||||
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
|
||||
return (
|
||||
<Button disabled={disabled || loading} {...props}>
|
||||
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
|
||||
{loading ? <Spinner data-icon="inline-start" /> : null}
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
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 { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import {
|
||||
Cascader,
|
||||
CascaderContent,
|
||||
CascaderPanel,
|
||||
CascaderTrigger,
|
||||
} from '@/components/reui/cascader/cascader'
|
||||
import { CascaderInput, CascaderValue } from '@/components/reui/cascader/cascader-nav'
|
||||
import type { CascaderNode } from '@/components/reui/cascader/cascader-types'
|
||||
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||
import type {
|
||||
BgpCommunity,
|
||||
LookupQueryKind,
|
||||
LookupResponse,
|
||||
ModuleRow,
|
||||
} from '@/types/api'
|
||||
|
||||
/**
|
||||
* Lookup wizard step 3 — cascader module → community (wizard-2).
|
||||
* @see https://reui.io/preview/base/wizard-2
|
||||
* @see https://reui.io/docs/components/base/cascader
|
||||
* @see https://reui.io/docs/components/base/stepper
|
||||
*/
|
||||
|
||||
function hostPrefixFromIp(ip: string): string {
|
||||
return ip.includes(':') ? `${ip}/128` : `${ip}/32`
|
||||
}
|
||||
|
||||
function moduleTypeForKind(kind: LookupQueryKind): ModuleRow['type'] {
|
||||
return kind === 'domain' ? 'DOMAINS' : 'IP_RANGES'
|
||||
}
|
||||
|
||||
type LookupPick = { kind: 'module' } | { kind: 'community'; communityId: string }
|
||||
|
||||
function encodeModuleValue(moduleId: string): string {
|
||||
return `m:${moduleId}`
|
||||
}
|
||||
|
||||
function encodeCommunityValue(moduleId: string, communityId: string): string {
|
||||
return `c:${moduleId}:${communityId}`
|
||||
}
|
||||
|
||||
function parsePick(
|
||||
value: string,
|
||||
): { moduleId: string; communityId: string | null } | null {
|
||||
if (value.startsWith('m:')) {
|
||||
return { moduleId: value.slice(2), communityId: null }
|
||||
}
|
||||
if (value.startsWith('c:')) {
|
||||
const rest = value.slice(2)
|
||||
const sep = rest.indexOf(':')
|
||||
if (sep <= 0) return null
|
||||
return { moduleId: rest.slice(0, sep), communityId: rest.slice(sep + 1) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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 allowEmptyCommunity = data.query_kind === 'domain'
|
||||
const eligible = useMemo(
|
||||
() => modules.filter((m) => m.type === wantedType),
|
||||
[modules, wantedType],
|
||||
)
|
||||
|
||||
const [pick, setPick] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const cascaderItems = useMemo<CascaderNode<LookupPick>[]>(
|
||||
() =>
|
||||
eligible.map((mod) => ({
|
||||
value: encodeModuleValue(mod.id),
|
||||
label: mod.name,
|
||||
description: mod.id,
|
||||
data: { kind: 'module' },
|
||||
children: communities.map((community) => ({
|
||||
value: encodeCommunityValue(mod.id, community.id),
|
||||
label: community.title,
|
||||
description: community.community,
|
||||
data: { kind: 'community', communityId: community.id },
|
||||
})),
|
||||
})),
|
||||
[eligible, communities],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (eligible.length === 0) {
|
||||
setPick('')
|
||||
return
|
||||
}
|
||||
const first = eligible[0]!
|
||||
const defaultCommunity = first.default_community_id
|
||||
setPick((prev) => {
|
||||
if (prev && parsePick(prev)?.moduleId && eligible.some((m) => m.id === parsePick(prev)?.moduleId)) {
|
||||
return prev
|
||||
}
|
||||
if (defaultCommunity) return encodeCommunityValue(first.id, defaultCommunity)
|
||||
return encodeModuleValue(first.id)
|
||||
})
|
||||
}, [eligible])
|
||||
|
||||
const parsed = pick ? parsePick(pick) : null
|
||||
const moduleId = parsed?.moduleId ?? ''
|
||||
const communityId = parsed?.communityId ?? null
|
||||
|
||||
async function handleAdd() {
|
||||
if (!moduleId) {
|
||||
toast.error('Выберите модуль')
|
||||
return
|
||||
}
|
||||
if (!allowEmptyCommunity && !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" search={{ create: true }} />}>
|
||||
Перейти к модулям
|
||||
</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>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lookup-add-cascader">Модуль и community</FieldLabel>
|
||||
<Cascader
|
||||
items={cascaderItems}
|
||||
value={pick}
|
||||
onValueChange={setPick}
|
||||
selectable={allowEmptyCommunity ? 'any' : 'leaf'}
|
||||
mode="columns"
|
||||
labels={{
|
||||
search: 'Поиск',
|
||||
empty: 'Ничего не найдено',
|
||||
back: 'Назад',
|
||||
rootLevel: 'Модули',
|
||||
}}
|
||||
>
|
||||
<CascaderTrigger
|
||||
id="lookup-add-cascader"
|
||||
className="w-full"
|
||||
aria-label="Модуль и community"
|
||||
>
|
||||
<CascaderValue placeholder="Выберите модуль и community" display="path" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent>
|
||||
<CascaderInput />
|
||||
<CascaderPanel />
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
</Field>
|
||||
<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 || (!allowEmptyCommunity && !communityId)}
|
||||
>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Boxes, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridMonoCell, DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import type { LookupMatch } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Lookup matches grid — ResourcePage hideHeader inside wizard-2 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
|
||||
*/
|
||||
const filterFields: FilterField[] = [
|
||||
{
|
||||
id: 'search',
|
||||
label: 'Поиск',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
placeholder: 'Фильтр совпадений…',
|
||||
},
|
||||
]
|
||||
|
||||
export function LookupMatchesGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: LookupMatch[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
|
||||
const columns = useMemo<DataGridColumnDef<LookupMatch>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'layer',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Слой" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.layer === 'entry' ? 'info-light' : 'primary-light'}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.layer}
|
||||
</Badge>
|
||||
),
|
||||
meta: { headerTitle: 'Слой' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'module_name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridNameCell
|
||||
icon={Boxes}
|
||||
title={row.original.module_name}
|
||||
subtitle={row.original.module_type}
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'matched_value',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<DataGridMonoCell>{row.original.matched_value}</DataGridMonoCell>
|
||||
<DataGridMutedCell>
|
||||
{row.original.resolved_ip
|
||||
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
|
||||
: row.original.match_kind}
|
||||
</DataGridMutedCell>
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Совпадение' },
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
accessorFn: (row) => row.community_title || row.community || '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Community" />,
|
||||
cell: ({ row }) => {
|
||||
const title = row.original.community_title?.trim()
|
||||
const value = row.original.community?.trim()
|
||||
if (!title && !value) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<DataGridMonoCell>{title || value || '—'}</DataGridMonoCell>
|
||||
{title && value && title !== value ? (
|
||||
<DataGridMutedCell>{value}</DataGridMutedCell>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Community' },
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
enableSorting: false,
|
||||
header: 'Источник',
|
||||
cell: ({ row }) =>
|
||||
row.original.source ? (
|
||||
<CategoryBadge>{row.original.source}</CategoryBadge>
|
||||
) : (
|
||||
<DataGridMutedCell>—</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Источник' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Совпадения"
|
||||
description="Entries и snapshots · клик по строке открывает модуль"
|
||||
hideHeader
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={(item, field) => {
|
||||
if (field !== 'search') return undefined
|
||||
return `${item.layer} ${item.module_name} ${item.module_type} ${item.matched_value} ${item.community ?? ''} ${item.community_title ?? ''} ${item.source ?? ''}`
|
||||
}}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) =>
|
||||
`${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`
|
||||
}
|
||||
isLoading={isLoading}
|
||||
onRowClick={(row) =>
|
||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
||||
}
|
||||
emptyState={{ title: 'Нет совпадений' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
|
||||
/**
|
||||
* 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({
|
||||
initialQuery = '',
|
||||
isPending = false,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialQuery?: string
|
||||
isPending?: boolean
|
||||
onSubmit: (q: string) => void
|
||||
}) {
|
||||
const [value, setValue] = useState(initialQuery)
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const q = value.trim()
|
||||
if (!q) return
|
||||
onSubmit(q)
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
|
||||
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Сменить тему</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>Светлая</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>Тёмная</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>Системная</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user