Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b20caef1f1 | ||
|
|
bf43f2846e | ||
|
|
f861d0fa8c | ||
|
|
2f914bcc65 | ||
|
|
7a10138990 | ||
|
|
391f34a53d | ||
|
|
ecd48642de | ||
|
|
09580bbcab | ||
|
|
4d54dacd05 | ||
|
|
a3562cc11e | ||
|
|
95d4305241 | ||
|
|
dc803bcb34 | ||
|
|
e469c421ca | ||
|
|
094f75913b | ||
|
|
764a4e5b4e | ||
|
|
2d29a892f9 | ||
|
|
d9bec85c02 | ||
|
|
d56405af7b | ||
|
|
927e27640a | ||
|
|
5255cd2d30 | ||
|
|
3723ba7ed1 | ||
|
|
c5148ac4a0 |
No files matched your search
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
@@ -106,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -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/*` |
|
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
|
||||||
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/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` |
|
| Мультифильтры | `@reui` | `@/components/reui/filters` |
|
||||||
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
||||||
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
||||||
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
||||||
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
| 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-обёртки
|
## Уже установленные shared-обёртки
|
||||||
|
|
||||||
В `apps/web/src/components/`:
|
В `apps/web/src/components/`:
|
||||||
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
|
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
|
||||||
- `QueryState` — обёртка loading/error/empty для TanStack Query
|
- `QueryState` — обёртка loading/error/empty для TanStack Query
|
||||||
- `EmptyState` — пустые списки
|
- `EmptyState` — пустые списки (empty-state-14 DNA)
|
||||||
- `ConfirmDialog` — подтверждения (не `window.confirm`)
|
- `ConfirmDialog` — подтверждения (не `window.confirm`)
|
||||||
- `LoadingButton` — кнопка с loading-состоянием
|
- `LoadingButton` — SoT кнопка с loading (`Button` + `Spinner`)
|
||||||
- `StatusBadge` — статусные бейджи
|
- `StatusBadge` / `CategoryBadge` / `ModeBadge` — semantic ReUI Badge
|
||||||
- `SectionCards` — сетка KPI-карточек
|
- `SegmentedTabs` — секции страницы (c-tabs-9)
|
||||||
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
|
- `StatusToggleGroup` — статус грида (c-toggle-group-5)
|
||||||
|
- `FormDrawer` — Sheet overlay (sheet-8 / form-7)
|
||||||
|
- `Skeletons` (`TableSkeleton`, `KpiStatGridSkeleton`) — скелетоны
|
||||||
- `TruncatedText` — текст с тултипом
|
- `TruncatedText` — текст с тултипом
|
||||||
- `ModeToggle` — переключатель темы
|
|
||||||
|
Kit: `reui-kit/ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`, `FrameSection`.
|
||||||
|
|
||||||
Перед созданием новой обёртки — проверить существующие через Codegraph.
|
Перед созданием новой обёртки — проверить существующие через Codegraph.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
@@ -106,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ alwaysApply: true
|
|||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
||||||
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
||||||
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
|
| PRO blocks (reference) | — | CLI → adapt into kit; не держать demo-деревья в `src/components/blocks/` |
|
||||||
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
|
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
|
||||||
|
|
||||||
## Установленные ReUI (apps/web)
|
## Установленные 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`
|
**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`
|
**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`, …
|
**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`, …
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ alwaysApply: false
|
|||||||
|
|
||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ alwaysApply: false
|
|||||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
|
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
|
||||||
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell |
|
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell, DetailPanel, FrameSection |
|
||||||
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
| Роуты | `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). Quick Actions: `QuickActionGrid`.
|
**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>`.
|
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
||||||
|
|
||||||
@@ -48,9 +48,16 @@ alwaysApply: false
|
|||||||
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||||
|
|
||||||
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `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`.
|
*Проверка:* `@/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`.
|
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
@@ -106,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR)
|
|||||||
|
|
||||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
**`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
|
```text
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ jobs:
|
|||||||
allow_registry_login: false
|
allow_registry_login: false
|
||||||
secrets:
|
secrets:
|
||||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
docker_hub_token: ${{ secrets.docker_hub_token }}
|
||||||
|
docker_hub_username: ${{ secrets.docker_hub_username }}
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: [quality]
|
needs: [quality]
|
||||||
@@ -107,6 +109,12 @@ jobs:
|
|||||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$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
|
- name: Log in to Gitea Registry
|
||||||
if: steps.rel.outputs.released == 'true'
|
if: steps.rel.outputs.released == 'true'
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
|||||||
@@ -21,3 +21,5 @@ jobs:
|
|||||||
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
secrets:
|
secrets:
|
||||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
docker_hub_token: ${{ secrets.docker_hub_token }}
|
||||||
|
docker_hub_username: ${{ secrets.docker_hub_username }}
|
||||||
@@ -26,6 +26,10 @@ on:
|
|||||||
secrets:
|
secrets:
|
||||||
ACTIONS_PAT:
|
ACTIONS_PAT:
|
||||||
required: false
|
required: false
|
||||||
|
docker_hub_token:
|
||||||
|
required: false
|
||||||
|
docker_hub_username:
|
||||||
|
required: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -48,8 +52,10 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- if: ${{ inputs.is_pull_request == false }}
|
- if: ${{ inputs.is_pull_request == false }}
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
# Полная история: BEFORE_SHA = github.event.before при multi-commit push
|
||||||
|
# лежит глубже shallow-среза, и git diff падает с "bad object".
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 0
|
||||||
- id: detect
|
- id: detect
|
||||||
name: Detect changed paths per module
|
name: Detect changed paths per module
|
||||||
env:
|
env:
|
||||||
@@ -90,7 +96,14 @@ jobs:
|
|||||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||||
before="$BEFORE_SHA"
|
before="$BEFORE_SHA"
|
||||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||||
FILES="$(git diff --name-only "$before" "$after")"
|
# Force-push мог отбросить before; отсутствие объекта — не ошибка,
|
||||||
|
# а сигнал уйти в полный прогон через пустой diff.
|
||||||
|
if git cat-file -e "$before^{commit}" 2>/dev/null; then
|
||||||
|
FILES="$(git diff --name-only "$before" "$after")"
|
||||||
|
else
|
||||||
|
echo "BEFORE_SHA $before not found in checkout — full pipeline fallback"
|
||||||
|
FILES=""
|
||||||
|
fi
|
||||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||||
else
|
else
|
||||||
@@ -397,6 +410,12 @@ jobs:
|
|||||||
name: evobgp
|
name: evobgp
|
||||||
driver: docker-container
|
driver: docker-container
|
||||||
cleanup: false
|
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
|
- name: Log in to Gitea Registry
|
||||||
if: inputs.allow_registry_login
|
if: inputs.allow_registry_login
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
@@ -106,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
|
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
|
||||||
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
|
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
|
||||||
| Точки входа процессов | `cmd/*/` |
|
| Точки входа процессов | `cmd/*/` |
|
||||||
| Веб (SvelteKit) | `web/` |
|
| Веб (React + ReUI) | `apps/web/` |
|
||||||
| Compose, деплой | `deploy/compose/` |
|
| Compose, деплой | `deploy/compose/` |
|
||||||
|
|
||||||
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
|
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] },
|
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
@@ -28,6 +28,9 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['src/components/reui/**/*.{ts,tsx}'],
|
files: ['src/components/reui/**/*.{ts,tsx}'],
|
||||||
|
linterOptions: {
|
||||||
|
reportUnusedDisableDirectives: 'off',
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-unused-vars': 'off',
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
'react-hooks/exhaustive-deps': 'off',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.0.0",
|
"@base-ui/react": "^1.0.0",
|
||||||
|
"@date-fns/tz": "^1.5.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -26,8 +27,8 @@
|
|||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@tanstack/react-router": "^1.130.2",
|
"@tanstack/react-router": "^1.130.2",
|
||||||
"@tanstack/react-router-devtools": "^1.130.2",
|
"@tanstack/react-router-devtools": "^1.130.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^9.1.2",
|
||||||
"@tanstack/react-virtual": "^3.14.4",
|
"@tanstack/react-virtual": "^3.14.10",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
@@ -39,6 +40,8 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.0",
|
||||||
|
"shadcn": "^4.19.0",
|
||||||
|
"shiki": "^4.4.3",
|
||||||
"sonner": "^1.7.0",
|
"sonner": "^1.7.0",
|
||||||
"zod": "^3.25.0"
|
"zod": "^3.25.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Plus, RefreshCw } from 'lucide-react'
|
import { Plus, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
||||||
import { FrameDataGrid } from '@/components/reui-kit'
|
|
||||||
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
||||||
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
@@ -47,46 +45,38 @@ export function AccessApiKeysCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FrameDataGrid
|
<QueryState
|
||||||
title="API-ключи"
|
data={items}
|
||||||
description="Полный токен показывается только при создании и ротации."
|
isLoading={isLoading}
|
||||||
className="min-w-0"
|
isError={isError}
|
||||||
actions={
|
error={error}
|
||||||
<>
|
empty={false}
|
||||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
onRetry={onRetry}
|
||||||
Обновить
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Создать
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<QueryState
|
{(data) => (
|
||||||
data={items}
|
<AccessApiKeysGrid
|
||||||
isLoading={isLoading}
|
items={data}
|
||||||
isError={isError}
|
isLoading={isLoading}
|
||||||
error={error}
|
onRotate={handleRotated}
|
||||||
empty={items.length === 0}
|
onRevoke={(id) => revoke.mutate(id)}
|
||||||
emptyTitle="Нет ключей"
|
rotatePending={rotate.isPending}
|
||||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
revokePending={revoke.isPending}
|
||||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
actions={
|
||||||
onRetry={onRetry}
|
<>
|
||||||
>
|
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||||
{(data) => (
|
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||||
<AccessApiKeysGrid
|
Обновить
|
||||||
items={data}
|
</Button>
|
||||||
isLoading={isLoading}
|
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
||||||
onRotate={handleRotated}
|
<Plus />
|
||||||
onRevoke={(id) => revoke.mutate(id)}
|
Создать
|
||||||
rotatePending={rotate.isPending}
|
</Button>
|
||||||
revokePending={revoke.isPending}
|
</>
|
||||||
/>
|
}
|
||||||
)}
|
/>
|
||||||
</QueryState>
|
)}
|
||||||
</FrameDataGrid>
|
</QueryState>
|
||||||
|
|
||||||
<ApiKeyCreateDialog
|
<ApiKeyCreateDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
import { KeyRound, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import 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 { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import type { ApiKey } from '@/types/api'
|
import type { ApiKey } from '@/types/api'
|
||||||
|
|
||||||
|
const filterFields: FilterField[] = [
|
||||||
|
createSearchFilterField('search', 'Поиск', 'Поиск API-ключей…'),
|
||||||
|
]
|
||||||
|
|
||||||
export function AccessApiKeysGrid({
|
export function AccessApiKeysGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -22,6 +28,7 @@ export function AccessApiKeysGrid({
|
|||||||
onRevoke,
|
onRevoke,
|
||||||
rotatePending = false,
|
rotatePending = false,
|
||||||
revokePending = false,
|
revokePending = false,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
items: ApiKey[]
|
items: ApiKey[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
@@ -29,16 +36,21 @@ export function AccessApiKeysGrid({
|
|||||||
onRevoke: (id: string) => void
|
onRevoke: (id: string) => void
|
||||||
rotatePending?: boolean
|
rotatePending?: boolean
|
||||||
revokePending?: boolean
|
revokePending?: boolean
|
||||||
|
actions?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const columns = useMemo<ColumnDef<ApiKey>[]>(
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumnDef<ApiKey>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridNameCell
|
||||||
|
icon={KeyRound}
|
||||||
title={row.original.name}
|
title={row.original.name}
|
||||||
accent="primary"
|
|
||||||
subtitle={`${row.original.prefix}…`}
|
subtitle={`${row.original.prefix}…`}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -86,6 +98,7 @@ export function AccessApiKeysGrid({
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
|
size: 88,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const k = row.original
|
const k = row.original
|
||||||
@@ -135,24 +148,24 @@ export function AccessApiKeysGrid({
|
|||||||
[onRevoke, onRotate, revokePending, rotatePending],
|
[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 (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title="API-ключи"
|
||||||
recordCount={filteredCount}
|
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}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет ключей"
|
primaryAction={actions}
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
pinLastColumn
|
||||||
searchValue={globalFilter}
|
emptyState={{ title: 'Нет ключей' }}
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск API-ключей…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,12 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@evobgp/ui/components/tooltip'
|
} 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({
|
export function AnalyticsCardShell({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -47,16 +51,16 @@ export function AnalyticsCardShell({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelCard
|
<FrameSection
|
||||||
title={titleNode}
|
title={titleNode}
|
||||||
description={description}
|
description={description}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
className={cn('overflow-hidden', className)}
|
className={cn('overflow-hidden', className)}
|
||||||
contentClassName="flex flex-col gap-5 py-5"
|
contentClassName="flex flex-col gap-5 px-5 py-5"
|
||||||
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
|
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</PanelCard>
|
</FrameSection>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function DashboardPlatformCard({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
|
onClick={() => navigate({ to: '/monitoring' })}
|
||||||
>
|
>
|
||||||
Мониторинг
|
Мониторинг
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, type ComponentType } from "react"
|
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "@evobgp/ui/components/tabs"
|
|
||||||
|
|
||||||
import { BillingTab } from "./billing-tab"
|
|
||||||
import { SETTINGS_TAB_ITEMS } from "./data"
|
|
||||||
import { NotificationsTab } from "./notifications-tab"
|
|
||||||
import { ProfileTab } from "./profile-tab"
|
|
||||||
import { SecurityTab } from "./security-tab"
|
|
||||||
|
|
||||||
const TAB_COMPONENTS: Record<string, ComponentType> = {
|
|
||||||
profile: ProfileTab,
|
|
||||||
security: SecurityTab,
|
|
||||||
notifications: NotificationsTab,
|
|
||||||
billing: BillingTab,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Settings Navigation ──
|
|
||||||
|
|
||||||
function SettingsNavigation({
|
|
||||||
isMobile,
|
|
||||||
activeValue,
|
|
||||||
}: {
|
|
||||||
isMobile: boolean
|
|
||||||
activeValue: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={cn("min-w-0", isMobile ? "w-full" : "w-40 shrink-0")}>
|
|
||||||
{isMobile ? (
|
|
||||||
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
|
||||||
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
|
|
||||||
{SETTINGS_TAB_ITEMS.map((tab) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={tab.value}
|
|
||||||
value={tab.value}
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
|
|
||||||
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.icon}
|
|
||||||
<span className="truncate">{tab.label}</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
|
|
||||||
{SETTINGS_TAB_ITEMS.map((tab) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={tab.value}
|
|
||||||
value={tab.value}
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
|
|
||||||
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.icon}
|
|
||||||
<span className="truncate">{tab.label}</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AccountSettings() {
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const [activeTab, setActiveTab] = useState("profile")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-4xl space-y-8">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="px-1">
|
|
||||||
<h1 className="text-xl font-semibold tracking-tight">
|
|
||||||
Account Settings
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
|
||||||
Update your profile, access, notifications, and billing preferences.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={setActiveTab}
|
|
||||||
orientation={isMobile ? "horizontal" : "vertical"}
|
|
||||||
className={cn("w-full gap-4 lg:gap-8")}
|
|
||||||
>
|
|
||||||
<SettingsNavigation isMobile={isMobile} activeValue={activeTab} />
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
{SETTINGS_TAB_ITEMS.map((tab) => {
|
|
||||||
const TabComponent = TAB_COMPONENTS[tab.value]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TabsContent key={tab.value} value={tab.value} className="mt-0">
|
|
||||||
<TabComponent />
|
|
||||||
</TabsContent>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
FieldLegend,
|
|
||||||
FieldSet,
|
|
||||||
} from "@evobgp/ui/components/field"
|
|
||||||
import { Input } from "@evobgp/ui/components/input"
|
|
||||||
import {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupInput,
|
|
||||||
InputGroupText,
|
|
||||||
} from "@evobgp/ui/components/input-group"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@evobgp/ui/components/select"
|
|
||||||
|
|
||||||
import { BILLING_PLANS, COUNTRIES } from "./data"
|
|
||||||
import { SettingRow } from "./setting-row"
|
|
||||||
import { SettingsCard } from "./settings-card"
|
|
||||||
import { SettingsFieldGroup } from "./settings-field-group"
|
|
||||||
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
|
||||||
|
|
||||||
export function BillingTab() {
|
|
||||||
const [country, setCountry] = useState("us")
|
|
||||||
const handleCountryChange = createSelectValueHandler(setCountry)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Card */}
|
|
||||||
<SettingsCard title="Plan and billing" description="Current subscription">
|
|
||||||
<SettingsFieldGroup
|
|
||||||
legend="Plan and billing"
|
|
||||||
description="Review your current subscription and plan options."
|
|
||||||
>
|
|
||||||
{BILLING_PLANS.map((plan, index) => (
|
|
||||||
<SettingRow
|
|
||||||
key={plan.id}
|
|
||||||
title={plan.name}
|
|
||||||
titleAddon={
|
|
||||||
plan.current ? (
|
|
||||||
<Badge variant="info-light" size="sm">
|
|
||||||
Current
|
|
||||||
</Badge>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
<span className="text-foreground font-medium">
|
|
||||||
{plan.price}
|
|
||||||
</span>{" "}
|
|
||||||
/ {plan.period} · {plan.features.join(", ")}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
last={index === BILLING_PLANS.length - 1}
|
|
||||||
>
|
|
||||||
<Button variant={plan.current ? "outline" : "ghost"} size="sm">
|
|
||||||
{plan.current ? "Manage" : "Switch"}
|
|
||||||
</Button>
|
|
||||||
</SettingRow>
|
|
||||||
))}
|
|
||||||
</SettingsFieldGroup>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
<SettingsCard
|
|
||||||
title="Billing details"
|
|
||||||
description="Invoices and payments"
|
|
||||||
footer={<Button>Update billing</Button>}
|
|
||||||
>
|
|
||||||
<SettingsFieldGroup
|
|
||||||
legend="Billing details"
|
|
||||||
description="Manage invoice contacts and payment details."
|
|
||||||
>
|
|
||||||
<SettingRow
|
|
||||||
title="Billing email"
|
|
||||||
description="Receives invoices and renewal notices."
|
|
||||||
labelFor="settings-7-billing-email"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="settings-7-billing-email"
|
|
||||||
defaultValue="[email protected]"
|
|
||||||
type="email"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Invoice profile"
|
|
||||||
description="Business details used on receipts and tax forms."
|
|
||||||
contentClassName="@md/field-group:w-[22rem]"
|
|
||||||
>
|
|
||||||
<FieldSet className="w-full gap-3">
|
|
||||||
<FieldLegend className="sr-only">Invoice profile</FieldLegend>
|
|
||||||
<FieldDescription className="sr-only">
|
|
||||||
Company identity used for invoices and tax documents.
|
|
||||||
</FieldDescription>
|
|
||||||
|
|
||||||
<FieldGroup className="gap-4">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="settings-7-company-name">
|
|
||||||
Company name
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="settings-7-company-name"
|
|
||||||
defaultValue="Acme Labs"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="settings-7-billing-country">
|
|
||||||
Country
|
|
||||||
</FieldLabel>
|
|
||||||
<Select value={country} onValueChange={handleCountryChange}>
|
|
||||||
<SelectTrigger
|
|
||||||
id="settings-7-billing-country"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<SelectValue>
|
|
||||||
{getOptionLabel(COUNTRIES, country)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{COUNTRIES.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</FieldSet>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Tax ID"
|
|
||||||
description="Optional number used for VAT or company invoices."
|
|
||||||
labelFor="settings-7-tax-id"
|
|
||||||
>
|
|
||||||
<InputGroup className="w-full">
|
|
||||||
<InputGroupAddon align="inline-start">
|
|
||||||
<InputGroupText>VAT</InputGroupText>
|
|
||||||
</InputGroupAddon>
|
|
||||||
<InputGroupInput
|
|
||||||
id="settings-7-tax-id"
|
|
||||||
defaultValue="US-2048-ACME"
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Card on file"
|
|
||||||
description="Used for monthly renewals."
|
|
||||||
last
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
||||||
<span className="text-muted-foreground text-sm">
|
|
||||||
Visa ending in 4242
|
|
||||||
</span>
|
|
||||||
<Button variant="outline">Update</Button>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsFieldGroup>
|
|
||||||
</SettingsCard>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { type ReactNode } from "react"
|
|
||||||
import { UserIcon, ShieldIcon, BellIcon, CreditCardIcon, MonitorIcon, SmartphoneIcon } from "lucide-react"
|
|
||||||
|
|
||||||
// ── Types ──
|
|
||||||
|
|
||||||
export type SelectOption = {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BillingPlan = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
price: string
|
|
||||||
period: string
|
|
||||||
current: boolean
|
|
||||||
features: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Session = {
|
|
||||||
id: string
|
|
||||||
device: string
|
|
||||||
browser: string
|
|
||||||
location: string
|
|
||||||
lastActive: string
|
|
||||||
current: boolean
|
|
||||||
icon: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SettingsTabItem = {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
icon: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Data ──
|
|
||||||
|
|
||||||
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
|
||||||
{
|
|
||||||
value: "profile",
|
|
||||||
label: "My Profile",
|
|
||||||
icon: (
|
|
||||||
<UserIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "security",
|
|
||||||
label: "Security",
|
|
||||||
icon: (
|
|
||||||
<ShieldIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "notifications",
|
|
||||||
label: "Notifications",
|
|
||||||
icon: (
|
|
||||||
<BellIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "billing",
|
|
||||||
label: "Billing",
|
|
||||||
icon: (
|
|
||||||
<CreditCardIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const TIMEZONES: SelectOption[] = [
|
|
||||||
{ value: "utc-8", label: "UTC-8 (Pacific Time)" },
|
|
||||||
{ value: "utc-5", label: "UTC-5 (Eastern Time)" },
|
|
||||||
{ value: "utc+0", label: "UTC+0 (London)" },
|
|
||||||
{ value: "utc+1", label: "UTC+1 (Berlin)" },
|
|
||||||
{ value: "utc+9", label: "UTC+9 (Tokyo)" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const ROLES: SelectOption[] = [
|
|
||||||
{ value: "engineering-lead", label: "Engineering Lead" },
|
|
||||||
{ value: "developer", label: "Developer" },
|
|
||||||
{ value: "designer", label: "Designer" },
|
|
||||||
{ value: "product-manager", label: "Product Manager" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const COUNTRIES: SelectOption[] = [
|
|
||||||
{ value: "us", label: "United States" },
|
|
||||||
{ value: "uk", label: "United Kingdom" },
|
|
||||||
{ value: "de", label: "Germany" },
|
|
||||||
{ value: "uz", label: "Uzbekistan" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const TIMEOUTS: SelectOption[] = [
|
|
||||||
{ value: "5", label: "5 minutes" },
|
|
||||||
{ value: "10", label: "10 minutes" },
|
|
||||||
{ value: "15", label: "15 minutes" },
|
|
||||||
{ value: "30", label: "30 minutes" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const QUIET_HOURS: SelectOption[] = [
|
|
||||||
{ value: "18:00", label: "6:00 PM" },
|
|
||||||
{ value: "20:00", label: "8:00 PM" },
|
|
||||||
{ value: "22:00", label: "10:00 PM" },
|
|
||||||
{ value: "23:00", label: "11:00 PM" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const QUIET_HOURS_END: SelectOption[] = [
|
|
||||||
{ value: "06:00", label: "6:00 AM" },
|
|
||||||
{ value: "08:00", label: "8:00 AM" },
|
|
||||||
{ value: "09:00", label: "9:00 AM" },
|
|
||||||
{ value: "10:00", label: "10:00 AM" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const DIGEST_CADENCE: SelectOption[] = [
|
|
||||||
{ value: "daily", label: "Daily summary" },
|
|
||||||
{ value: "weekly", label: "Weekly digest" },
|
|
||||||
{ value: "mentions", label: "Only mentions" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const RECOVERY_METHODS: SelectOption[] = [
|
|
||||||
{ value: "authenticator", label: "Authenticator first" },
|
|
||||||
{ value: "sms", label: "SMS fallback" },
|
|
||||||
{ value: "email", label: "Email fallback" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const BILLING_PLANS: BillingPlan[] = [
|
|
||||||
{
|
|
||||||
id: "free",
|
|
||||||
name: "Free",
|
|
||||||
price: "$0",
|
|
||||||
period: "forever",
|
|
||||||
current: false,
|
|
||||||
features: ["1 workspace", "Basic exports", "Community support"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pro",
|
|
||||||
name: "Pro",
|
|
||||||
price: "$29",
|
|
||||||
period: "per month",
|
|
||||||
current: true,
|
|
||||||
features: ["Unlimited workspaces", "Automations", "Priority support"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "team",
|
|
||||||
name: "Team",
|
|
||||||
price: "$79",
|
|
||||||
period: "per month",
|
|
||||||
current: false,
|
|
||||||
features: ["Everything in Pro", "SSO", "Shared billing"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SESSIONS: Session[] = [
|
|
||||||
{
|
|
||||||
id: "sess-1",
|
|
||||||
device: "macOS",
|
|
||||||
browser: "Chrome",
|
|
||||||
location: "San Francisco, CA",
|
|
||||||
lastActive: "Active now",
|
|
||||||
current: true,
|
|
||||||
icon: (
|
|
||||||
<MonitorIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sess-2",
|
|
||||||
device: "iPhone",
|
|
||||||
browser: "Safari",
|
|
||||||
location: "San Francisco, CA",
|
|
||||||
lastActive: "2 hours ago",
|
|
||||||
current: false,
|
|
||||||
icon: (
|
|
||||||
<SmartphoneIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sess-3",
|
|
||||||
device: "Windows",
|
|
||||||
browser: "Firefox",
|
|
||||||
location: "New York, NY",
|
|
||||||
lastActive: "3 days ago",
|
|
||||||
current: false,
|
|
||||||
icon: (
|
|
||||||
<MonitorIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
FieldLegend,
|
|
||||||
FieldSet,
|
|
||||||
} from "@evobgp/ui/components/field"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@evobgp/ui/components/select"
|
|
||||||
import { Switch } from "@evobgp/ui/components/switch"
|
|
||||||
|
|
||||||
import { DIGEST_CADENCE, QUIET_HOURS, QUIET_HOURS_END, TIMEOUTS } from "./data"
|
|
||||||
import { SettingRow } from "./setting-row"
|
|
||||||
import { SettingsCard } from "./settings-card"
|
|
||||||
import { SettingsFieldGroup } from "./settings-field-group"
|
|
||||||
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
|
||||||
|
|
||||||
export function NotificationsTab() {
|
|
||||||
const [autoDismiss, setAutoDismiss] = useState("10")
|
|
||||||
const [quietStart, setQuietStart] = useState("20:00")
|
|
||||||
const [quietEnd, setQuietEnd] = useState("08:00")
|
|
||||||
const [digestCadence, setDigestCadence] = useState("weekly")
|
|
||||||
const handleAutoDismissChange = createSelectValueHandler(setAutoDismiss)
|
|
||||||
const handleQuietStartChange = createSelectValueHandler(setQuietStart)
|
|
||||||
const handleQuietEndChange = createSelectValueHandler(setQuietEnd)
|
|
||||||
const handleDigestCadenceChange = createSelectValueHandler(setDigestCadence)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Card */}
|
|
||||||
<SettingsCard title="Activity alerts" description="Desktop and badge">
|
|
||||||
<SettingsFieldGroup
|
|
||||||
legend="Activity alerts"
|
|
||||||
description="Control desktop, badge, and sound alerts."
|
|
||||||
>
|
|
||||||
<SettingRow
|
|
||||||
title="Desktop notifications"
|
|
||||||
description="Show alerts for mentions and approvals."
|
|
||||||
labelFor="settings-7-desktop-notifications"
|
|
||||||
>
|
|
||||||
<Switch id="settings-7-desktop-notifications" defaultChecked />
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Quiet hours"
|
|
||||||
description="Hold non-urgent alerts outside your workday."
|
|
||||||
contentClassName="@md/field-group:w-[22rem]"
|
|
||||||
>
|
|
||||||
<FieldSet className="w-full gap-3">
|
|
||||||
<FieldLegend className="sr-only">Quiet hours</FieldLegend>
|
|
||||||
<FieldDescription className="sr-only">
|
|
||||||
Define when non-urgent activity should stay muted.
|
|
||||||
</FieldDescription>
|
|
||||||
|
|
||||||
<FieldGroup className="gap-3 sm:grid sm:grid-cols-2">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="settings-7-quiet-start">
|
|
||||||
Start
|
|
||||||
</FieldLabel>
|
|
||||||
<Select
|
|
||||||
value={quietStart}
|
|
||||||
onValueChange={handleQuietStartChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id="settings-7-quiet-start"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<SelectValue>
|
|
||||||
{getOptionLabel(QUIET_HOURS, quietStart)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{QUIET_HOURS.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="settings-7-quiet-end">End</FieldLabel>
|
|
||||||
<Select value={quietEnd} onValueChange={handleQuietEndChange}>
|
|
||||||
<SelectTrigger id="settings-7-quiet-end" className="w-full">
|
|
||||||
<SelectValue>
|
|
||||||
{getOptionLabel(QUIET_HOURS_END, quietEnd)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{QUIET_HOURS_END.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</FieldSet>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Unread badge"
|
|
||||||
description="Display a badge when new activity arrives."
|
|
||||||
labelFor="settings-7-unread-badge"
|
|
||||||
>
|
|
||||||
<Switch id="settings-7-unread-badge" defaultChecked />
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Auto dismiss"
|
|
||||||
description="Choose how long alerts stay visible."
|
|
||||||
labelFor="settings-7-auto-dismiss"
|
|
||||||
last
|
|
||||||
>
|
|
||||||
<Select value={autoDismiss} onValueChange={handleAutoDismissChange}>
|
|
||||||
<SelectTrigger id="settings-7-auto-dismiss" className="w-full">
|
|
||||||
<SelectValue>
|
|
||||||
{getOptionLabel(TIMEOUTS, autoDismiss)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{TIMEOUTS.map((timeout) => (
|
|
||||||
<SelectItem key={timeout.value} value={timeout.value}>
|
|
||||||
{timeout.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsFieldGroup>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
<SettingsCard
|
|
||||||
title="Email updates"
|
|
||||||
description="Inbox preferences"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline">Reset</Button>
|
|
||||||
<Button>Save preferences</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SettingsFieldGroup
|
|
||||||
legend="Email updates"
|
|
||||||
description="Control which emails reach your inbox."
|
|
||||||
>
|
|
||||||
<SettingRow
|
|
||||||
title="Team communication"
|
|
||||||
description="Messages, approvals, and activity summaries."
|
|
||||||
labelFor="settings-7-team-communication"
|
|
||||||
>
|
|
||||||
<Switch id="settings-7-team-communication" defaultChecked />
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Product announcements"
|
|
||||||
description="Releases, improvements, and launches."
|
|
||||||
labelFor="settings-7-product-announcements"
|
|
||||||
>
|
|
||||||
<Switch id="settings-7-product-announcements" />
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Weekly digest"
|
|
||||||
description="A recap of workspaces, mentions, and tasks."
|
|
||||||
labelFor="settings-7-weekly-digest"
|
|
||||||
>
|
|
||||||
<Switch id="settings-7-weekly-digest" defaultChecked />
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Digest cadence"
|
|
||||||
description="Set how often summary emails arrive."
|
|
||||||
labelFor="settings-7-digest-cadence"
|
|
||||||
last
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={digestCadence}
|
|
||||||
onValueChange={handleDigestCadenceChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="settings-7-digest-cadence" className="w-full">
|
|
||||||
<SelectValue>
|
|
||||||
{getOptionLabel(DIGEST_CADENCE, digestCadence)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{DIGEST_CADENCE.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsFieldGroup>
|
|
||||||
</SettingsCard>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { useFileUpload } from "@/hooks/use-file-upload"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@evobgp/ui/components/avatar"
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import { UserCircle, XIcon, UploadIcon } from "lucide-react"
|
|
||||||
|
|
||||||
interface ProfileAvatarUploadProps {
|
|
||||||
defaultAvatar: string
|
|
||||||
alt: string
|
|
||||||
inputId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileAvatarUpload({
|
|
||||||
defaultAvatar,
|
|
||||||
alt,
|
|
||||||
inputId,
|
|
||||||
}: ProfileAvatarUploadProps) {
|
|
||||||
const [removedCurrentPhoto, setRemovedCurrentPhoto] = useState(false)
|
|
||||||
const [{ files }, { removeFile, openFileDialog, getInputProps }] =
|
|
||||||
useFileUpload({
|
|
||||||
accept: "image/*",
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentFile = files[0] ?? null
|
|
||||||
const hasSavedPhoto = Boolean(defaultAvatar) && !removedCurrentPhoto
|
|
||||||
const previewUrl =
|
|
||||||
currentFile?.preview ?? (hasSavedPhoto ? defaultAvatar : null)
|
|
||||||
const hasPhoto = Boolean(previewUrl)
|
|
||||||
|
|
||||||
const handleCancelUpload = () => {
|
|
||||||
if (!currentFile) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFile(currentFile.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemovePhoto = () => {
|
|
||||||
if (currentFile) {
|
|
||||||
removeFile(currentFile.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
setRemovedCurrentPhoto(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex grow flex-wrap items-center justify-start gap-2">
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="relative">
|
|
||||||
<Avatar className="size-10">
|
|
||||||
<AvatarImage src={previewUrl ?? undefined} alt={alt} />
|
|
||||||
<AvatarFallback className="text-muted-foreground bg-muted">
|
|
||||||
<UserCircle aria-hidden="true" className="size-4 opacity-60" />
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
{currentFile ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon-xs"
|
|
||||||
onClick={handleCancelUpload}
|
|
||||||
className="absolute -top-1 -right-1 size-4 rounded-full"
|
|
||||||
aria-label={`Cancel ${currentFile.file.name}`}
|
|
||||||
>
|
|
||||||
<XIcon aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative inline-flex">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={openFileDialog}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
>
|
|
||||||
<UploadIcon aria-hidden="true" />
|
|
||||||
{hasPhoto ? "Change" : "Upload"}
|
|
||||||
</Button>
|
|
||||||
<input
|
|
||||||
{...getInputProps({ id: inputId })}
|
|
||||||
className="sr-only"
|
|
||||||
aria-label="Upload profile image"
|
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasPhoto ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={handleRemovePhoto}>
|
|
||||||
<XIcon aria-hidden="true" />
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
AlertAction,
|
|
||||||
AlertDescription,
|
|
||||||
AlertTitle,
|
|
||||||
} from "@/components/reui/alert"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
FieldLegend,
|
|
||||||
FieldSet,
|
|
||||||
} from "@evobgp/ui/components/field"
|
|
||||||
import { Input } from "@evobgp/ui/components/input"
|
|
||||||
import {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupInput,
|
|
||||||
InputGroupText,
|
|
||||||
} from "@evobgp/ui/components/input-group"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@evobgp/ui/components/select"
|
|
||||||
import { Textarea } from "@evobgp/ui/components/textarea"
|
|
||||||
import { ROLES, TIMEZONES } from "./data"
|
|
||||||
import { ProfileAvatarUpload } from "./profile-avatar-upload"
|
|
||||||
import { SettingRow } from "./setting-row"
|
|
||||||
import { SettingsCard } from "./settings-card"
|
|
||||||
import { SettingsFieldGroup } from "./settings-field-group"
|
|
||||||
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
|
||||||
import { UserIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function ProfileTab() {
|
|
||||||
const [role, setRole] = useState("engineering-lead")
|
|
||||||
const [timezone, setTimezone] = useState("utc-8")
|
|
||||||
const handleRoleChange = createSelectValueHandler(setRole)
|
|
||||||
const handleTimezoneChange = createSelectValueHandler(setTimezone)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Alert variant="warning">
|
|
||||||
<UserIcon aria-hidden="true" />
|
|
||||||
<AlertTitle>Complete your profile.</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Add a photo and keep your role and timezone current.
|
|
||||||
</AlertDescription>
|
|
||||||
<AlertAction>
|
|
||||||
<Button type="button" variant="outline" size="xs">
|
|
||||||
Dismiss
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="xs">
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</AlertAction>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{/* Card */}
|
|
||||||
<SettingsCard
|
|
||||||
title="My profile"
|
|
||||||
description="Public account details"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
<Button>Save changes</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SettingsFieldGroup
|
|
||||||
legend="Profile fields"
|
|
||||||
description="Update your personal profile details."
|
|
||||||
>
|
|
||||||
<SettingRow
|
|
||||||
title="Photo"
|
|
||||||
description="Shown in comments and mentions."
|
|
||||||
>
|
|
||||||
<ProfileAvatarUpload
|
|
||||||
defaultAvatar="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80"
|
|
||||||
alt="Alex Morgan"
|
|
||||||
inputId="settings-7-profile-photo"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Full name"
|
|
||||||
description="Used across the workspace."
|
|
||||||
labelFor="settings-7-full-name"
|
|
||||||
>
|
|
||||||
<Input id="settings-7-full-name" defaultValue="Alex Morgan" />
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Email address"
|
|
||||||
description="Primary sign-in email."
|
|
||||||
labelFor="settings-7-email"
|
|
||||||
titleAddon={<Badge variant="success-light">Verified</Badge>}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="settings-7-email"
|
|
||||||
defaultValue="[email protected]"
|
|
||||||
type="email"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Username"
|
|
||||||
description="Visible in mentions and links."
|
|
||||||
labelFor="settings-7-username"
|
|
||||||
>
|
|
||||||
<InputGroup className="w-full">
|
|
||||||
<InputGroupAddon align="inline-start">
|
|
||||||
<InputGroupText>@</InputGroupText>
|
|
||||||
</InputGroupAddon>
|
|
||||||
<InputGroupInput
|
|
||||||
id="settings-7-username"
|
|
||||||
defaultValue="alexmorgan"
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Profile details"
|
|
||||||
description="Public details shared across the workspace."
|
|
||||||
contentClassName="@md/field-group:w-[22rem]"
|
|
||||||
>
|
|
||||||
<FieldSet className="w-full gap-3">
|
|
||||||
<FieldLegend className="sr-only">Profile details</FieldLegend>
|
|
||||||
<FieldDescription className="sr-only">
|
|
||||||
Public profile and workspace defaults.
|
|
||||||
</FieldDescription>
|
|
||||||
|
|
||||||
<FieldGroup className="gap-4">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="settings-7-role">Role</FieldLabel>
|
|
||||||
<Select value={role} onValueChange={handleRoleChange}>
|
|
||||||
<SelectTrigger id="settings-7-role" className="w-full">
|
|
||||||
<SelectValue>{getOptionLabel(ROLES, role)}</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{ROLES.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="settings-7-timezone">
|
|
||||||
Time zone
|
|
||||||
</FieldLabel>
|
|
||||||
<Select value={timezone} onValueChange={handleTimezoneChange}>
|
|
||||||
<SelectTrigger id="settings-7-timezone" className="w-full">
|
|
||||||
<SelectValue>
|
|
||||||
{getOptionLabel(TIMEZONES, timezone)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{TIMEZONES.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="settings-7-website">Website</FieldLabel>
|
|
||||||
<InputGroup className="w-full">
|
|
||||||
<InputGroupAddon align="inline-start">
|
|
||||||
<InputGroupText>https://</InputGroupText>
|
|
||||||
</InputGroupAddon>
|
|
||||||
<InputGroupInput
|
|
||||||
id="settings-7-website"
|
|
||||||
defaultValue="alexmorgan.dev"
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</FieldSet>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Bio"
|
|
||||||
description="Short profile summary."
|
|
||||||
labelFor="settings-7-bio"
|
|
||||||
last
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
id="settings-7-bio"
|
|
||||||
defaultValue="Building developer tools at Acme. Previously at Vercel and Stripe."
|
|
||||||
rows={4}
|
|
||||||
className="min-h-24 resize-none"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsFieldGroup>
|
|
||||||
</SettingsCard>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Fragment, useState } from "react"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
FieldLegend,
|
|
||||||
FieldSet,
|
|
||||||
} from "@evobgp/ui/components/field"
|
|
||||||
import { Input } from "@evobgp/ui/components/input"
|
|
||||||
import {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupInput,
|
|
||||||
InputGroupText,
|
|
||||||
} from "@evobgp/ui/components/input-group"
|
|
||||||
import {
|
|
||||||
Item,
|
|
||||||
ItemActions,
|
|
||||||
ItemContent,
|
|
||||||
ItemDescription,
|
|
||||||
ItemGroup,
|
|
||||||
ItemMedia,
|
|
||||||
ItemSeparator,
|
|
||||||
ItemTitle,
|
|
||||||
} from "@evobgp/ui/components/item"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@evobgp/ui/components/select"
|
|
||||||
import { Switch } from "@evobgp/ui/components/switch"
|
|
||||||
import { RECOVERY_METHODS, SESSIONS } from "./data"
|
|
||||||
import { SettingRow } from "./setting-row"
|
|
||||||
import { SettingsCard } from "./settings-card"
|
|
||||||
import { SettingsFieldGroup } from "./settings-field-group"
|
|
||||||
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
|
||||||
import { LogOutIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function SecurityTab() {
|
|
||||||
const [recoveryMethod, setRecoveryMethod] = useState("authenticator")
|
|
||||||
const handleRecoveryMethodChange = createSelectValueHandler(setRecoveryMethod)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Card */}
|
|
||||||
<SettingsCard
|
|
||||||
title="Change password"
|
|
||||||
description="Access protection"
|
|
||||||
footer={<Button>Update password</Button>}
|
|
||||||
>
|
|
||||||
<SettingsFieldGroup
|
|
||||||
legend="Password settings"
|
|
||||||
description="Update your password and keep your account protected."
|
|
||||||
>
|
|
||||||
<SettingRow
|
|
||||||
title="Current password"
|
|
||||||
description="Verify your identity before making changes."
|
|
||||||
labelFor="settings-7-current-password"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="settings-7-current-password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter current password"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="New password"
|
|
||||||
description="Use at least 12 characters and a unique phrase."
|
|
||||||
contentClassName="@md/field-group:w-[22rem]"
|
|
||||||
last
|
|
||||||
>
|
|
||||||
<FieldSet className="w-full gap-3">
|
|
||||||
<FieldLegend className="sr-only">New password</FieldLegend>
|
|
||||||
<FieldDescription className="sr-only">
|
|
||||||
Create and confirm the next password for this account.
|
|
||||||
</FieldDescription>
|
|
||||||
|
|
||||||
<FieldGroup className="gap-4">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="settings-7-new-password">
|
|
||||||
New password
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="settings-7-new-password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Create a new password"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="settings-7-confirm-password">
|
|
||||||
Confirm password
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="settings-7-confirm-password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Confirm the new password"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</FieldSet>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsFieldGroup>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
<SettingsCard
|
|
||||||
title="Two-step verification"
|
|
||||||
description="Extra sign-in checks"
|
|
||||||
>
|
|
||||||
<SettingsFieldGroup
|
|
||||||
legend="Two-step verification"
|
|
||||||
description="Add backup checks for future sign-ins."
|
|
||||||
>
|
|
||||||
<SettingRow
|
|
||||||
title="Authenticator app"
|
|
||||||
description="Use one-time codes from an app."
|
|
||||||
labelFor="settings-7-authenticator-app"
|
|
||||||
titleAddon={
|
|
||||||
<Badge variant="info-light" size="sm">
|
|
||||||
Recommended
|
|
||||||
</Badge>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch id="settings-7-authenticator-app" defaultChecked />
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Recovery phone"
|
|
||||||
description="Used if you lose access to your authenticator app."
|
|
||||||
labelFor="settings-7-recovery-phone"
|
|
||||||
>
|
|
||||||
<InputGroup className="w-full">
|
|
||||||
<InputGroupAddon align="inline-start">
|
|
||||||
<InputGroupText>+1</InputGroupText>
|
|
||||||
</InputGroupAddon>
|
|
||||||
<InputGroupInput
|
|
||||||
id="settings-7-recovery-phone"
|
|
||||||
defaultValue="415 555 0148"
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Delivery method"
|
|
||||||
description="Choose how backup verification requests are delivered."
|
|
||||||
labelFor="settings-7-recovery-method"
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={recoveryMethod}
|
|
||||||
onValueChange={handleRecoveryMethodChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="settings-7-recovery-method" className="w-full">
|
|
||||||
<SelectValue>
|
|
||||||
{getOptionLabel(RECOVERY_METHODS, recoveryMethod)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{RECOVERY_METHODS.map((method) => (
|
|
||||||
<SelectItem key={method.value} value={method.value}>
|
|
||||||
{method.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Trusted devices"
|
|
||||||
description="Skip repeat prompts on known devices."
|
|
||||||
labelFor="settings-7-trusted-devices"
|
|
||||||
last
|
|
||||||
>
|
|
||||||
<Switch id="settings-7-trusted-devices" defaultChecked />
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsFieldGroup>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
<SettingsCard title="Active sessions" description="Signed-in devices">
|
|
||||||
<ItemGroup className="gap-0">
|
|
||||||
{SESSIONS.map((session, index) => (
|
|
||||||
<Fragment key={session.id}>
|
|
||||||
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
|
||||||
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
|
||||||
<ItemMedia className="self-center!">
|
|
||||||
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
|
||||||
{session.icon}
|
|
||||||
</Item>
|
|
||||||
</ItemMedia>
|
|
||||||
|
|
||||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
|
||||||
<ItemTitle className="gap-2 leading-5">
|
|
||||||
{session.browser} on {session.device}
|
|
||||||
{session.current ? (
|
|
||||||
<Badge variant="success-light" size="xs">
|
|
||||||
Current
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</ItemTitle>
|
|
||||||
<ItemDescription className="leading-5">
|
|
||||||
{session.location} · {session.lastActive}
|
|
||||||
</ItemDescription>
|
|
||||||
</ItemContent>
|
|
||||||
|
|
||||||
<ItemActions className="w-28 shrink-0 justify-end self-center">
|
|
||||||
{session.current ? (
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
This device
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
<LogOutIcon data-icon="inline-start" aria-hidden="true" />
|
|
||||||
Revoke
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</ItemActions>
|
|
||||||
</Item>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</ItemGroup>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
<SettingsCard title="Delete account" description="Irreversible changes">
|
|
||||||
<SettingsFieldGroup
|
|
||||||
legend="Delete account"
|
|
||||||
description="Review irreversible actions before deleting your account."
|
|
||||||
>
|
|
||||||
<SettingRow
|
|
||||||
title="Close account"
|
|
||||||
description="Permanently remove your account, sessions, and recovery settings."
|
|
||||||
last
|
|
||||||
contentClassName="@md/field-group:w-[22rem]"
|
|
||||||
>
|
|
||||||
<div className="flex w-full flex-col items-start gap-2 @md/field-group:items-end">
|
|
||||||
<p className="text-muted-foreground text-xs leading-5">
|
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
<Button variant="destructive">Delete account</Button>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsFieldGroup>
|
|
||||||
</SettingsCard>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */
|
|
||||||
export { SettingRow } from '@/components/settings/setting-row'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */
|
|
||||||
export { SettingsCard } from '@/components/settings/settings-card'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/** @deprecated Import from `@/components/settings/settings-field-group` — settings-7 is not a target. */
|
|
||||||
export { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { type Dispatch, type SetStateAction } from "react"
|
|
||||||
|
|
||||||
import type { SelectOption } from "./data"
|
|
||||||
|
|
||||||
export function getOptionLabel<T extends SelectOption>(
|
|
||||||
options: T[],
|
|
||||||
value: string
|
|
||||||
) {
|
|
||||||
return options.find((option) => option.value === value)?.label ?? value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSelectValueHandler(
|
|
||||||
setValue: Dispatch<SetStateAction<string>>
|
|
||||||
) {
|
|
||||||
return (value: string | null) => {
|
|
||||||
if (value !== null) {
|
|
||||||
setValue(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { AccountSettings } from "./components/account-settings"
|
|
||||||
|
|
||||||
export function Page() {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 md:p-10">
|
|
||||||
<AccountSettings />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,50 +1 @@
|
|||||||
import type { ComponentProps, ReactNode } from 'react'
|
export { CategoryBadge, ModeBadge } from '@/components/status-badge'
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
|
|
||||||
export interface CountedLineTab {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CountedLineTabsProps {
|
|
||||||
tabs: CountedLineTab[]
|
|
||||||
value: string
|
|
||||||
onValueChange: (value: string) => void
|
|
||||||
className?: string
|
|
||||||
listClassName?: string
|
|
||||||
children?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
|
|
||||||
export function CountedLineTabs({
|
|
||||||
tabs,
|
|
||||||
value,
|
|
||||||
onValueChange,
|
|
||||||
className,
|
|
||||||
listClassName,
|
|
||||||
children,
|
|
||||||
}: CountedLineTabsProps) {
|
|
||||||
return (
|
|
||||||
<Tabs value={value} onValueChange={onValueChange} className={className}>
|
|
||||||
<TabsList variant="line" className={cn('gap-5', listClassName)}>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={tab.id}
|
|
||||||
value={tab.id}
|
|
||||||
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
|
||||||
>
|
|
||||||
<span>{tab.label}</span>
|
|
||||||
{tab.count !== undefined ? (
|
|
||||||
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
|
||||||
{tab.count}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
{children}
|
|
||||||
</Tabs>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
|
|
||||||
|
|
||||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import {
|
|
||||||
Timeline,
|
|
||||||
TimelineContent,
|
|
||||||
TimelineHeader,
|
|
||||||
TimelineIndicator,
|
|
||||||
TimelineItem,
|
|
||||||
TimelineSeparator,
|
|
||||||
TimelineTitle,
|
|
||||||
} from '@/components/reui/timeline'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
|
|
||||||
import { recentPlatformActivity } from '@/lib/metrics'
|
|
||||||
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
|
||||||
|
|
||||||
const KIND_META = {
|
|
||||||
job: { icon: Info, className: 'text-info' },
|
|
||||||
revision: { icon: CheckCircle, className: 'text-success' },
|
|
||||||
network: { icon: AlertTriangle, className: 'text-warning' },
|
|
||||||
} as const
|
|
||||||
|
|
||||||
function statusBadgeVariant(status: string) {
|
|
||||||
const s = status.toLowerCase()
|
|
||||||
if (['ok', 'success', 'completed', 'done'].includes(s)) return 'success-light' as const
|
|
||||||
if (['running', 'queued', 'pending'].includes(s)) return 'info-light' as const
|
|
||||||
if (['warning', 'mismatch'].includes(s)) return 'warning-light' as const
|
|
||||||
if (['failed', 'error', 'cancelled'].includes(s)) return 'destructive-light' as const
|
|
||||||
return 'outline' as const
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardActivityTimeline({
|
|
||||||
jobs,
|
|
||||||
revisions,
|
|
||||||
peers,
|
|
||||||
speakers,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
jobs: JobRow[]
|
|
||||||
revisions: RevisionRow[]
|
|
||||||
peers: PeerRow[]
|
|
||||||
speakers: SpeakerRow[]
|
|
||||||
loading?: boolean
|
|
||||||
}) {
|
|
||||||
const items = recentPlatformActivity(jobs, revisions, peers, speakers, 6)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardFramePanel
|
|
||||||
title="Недавняя активность"
|
|
||||||
description="Задачи, ревизии и сетевые события"
|
|
||||||
className="h-full min-w-0"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground px-4 py-6 text-sm">Нет недавних событий</p>
|
|
||||||
) : (
|
|
||||||
<div className="px-4 py-4">
|
|
||||||
<Timeline defaultValue={items.length}>
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const meta = KIND_META[item.kind]
|
|
||||||
const Icon = meta.icon
|
|
||||||
return (
|
|
||||||
<TimelineItem
|
|
||||||
key={item.id}
|
|
||||||
step={index + 1}
|
|
||||||
className="group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-4"
|
|
||||||
>
|
|
||||||
<TimelineHeader>
|
|
||||||
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem)] group-data-[orientation=vertical]/timeline:translate-y-5" />
|
|
||||||
<TimelineIndicator className="border-none bg-transparent group-data-[orientation=vertical]/timeline:-left-6">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'bg-muted/70 flex size-7 items-center justify-center rounded-full',
|
|
||||||
meta.className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="size-3.5" aria-hidden />
|
|
||||||
</span>
|
|
||||||
</TimelineIndicator>
|
|
||||||
</TimelineHeader>
|
|
||||||
<TimelineContent className="min-w-0 pb-1 text-foreground">
|
|
||||||
<TimelineTitle className="text-sm leading-snug font-normal break-words">
|
|
||||||
{item.message}
|
|
||||||
</TimelineTitle>
|
|
||||||
<div className="mt-2">
|
|
||||||
<Badge variant={statusBadgeVariant(item.status)} size="sm">
|
|
||||||
{item.statusLabel ?? item.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</TimelineContent>
|
|
||||||
</TimelineItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Timeline>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DashboardFramePanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
import {
|
|
||||||
PanelCard,
|
|
||||||
panelCardContentFlushClassName,
|
|
||||||
} from '@/components/panel-card'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
|
|
||||||
/** Card-surface panel for dashboard sections (replaces legacy Frame shell). */
|
|
||||||
export function DashboardFramePanel({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
actions,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
contentClassName,
|
|
||||||
}: {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
actions?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
contentClassName?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<PanelCard
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
actions={actions}
|
|
||||||
className={cn('h-full', className)}
|
|
||||||
contentClassName={cn(panelCardContentFlushClassName, contentClassName)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</PanelCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,10 @@ import {
|
|||||||
Boxes,
|
Boxes,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Network,
|
Network,
|
||||||
ServerCog,
|
|
||||||
Share2,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
|
||||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||||
@@ -16,6 +14,11 @@ import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
|||||||
|
|
||||||
type KpiCard = KpiStatItem & { icon: ReactNode }
|
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({
|
function buildKpis({
|
||||||
modules,
|
modules,
|
||||||
peers,
|
peers,
|
||||||
@@ -40,17 +43,34 @@ function buildKpis({
|
|||||||
).length
|
).length
|
||||||
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
||||||
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
||||||
|
const disabledModules = Math.max(0, modules.length - enabledModules)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'modules',
|
id: 'modules',
|
||||||
icon: <Boxes aria-hidden />,
|
icon: <Boxes aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
value: loading ? '—' : enabledModules,
|
||||||
label: 'Модули активны',
|
label: 'Модули активны',
|
||||||
|
progress: loading ? undefined : ratioPercent(enabledModules, modules.length),
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant="primary-light" size="sm">
|
<Badge
|
||||||
{loading ? '…' : `${modules.length} всего`}
|
variant={
|
||||||
|
loading || modules.length === 0
|
||||||
|
? 'outline'
|
||||||
|
: disabledModules === 0
|
||||||
|
? 'success-light'
|
||||||
|
: 'warning-light'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? '…'
|
||||||
|
: modules.length === 0
|
||||||
|
? 'нет модулей'
|
||||||
|
: disabledModules === 0
|
||||||
|
? 'все активны'
|
||||||
|
: `${disabledModules} выкл`}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -60,6 +80,7 @@ function buildKpis({
|
|||||||
iconClassName: 'text-info',
|
iconClassName: 'text-info',
|
||||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||||
label: 'BGP готовность',
|
label: 'BGP готовность',
|
||||||
|
progress: loading || bgpPct === null ? undefined : bgpPct,
|
||||||
footer: (
|
footer: (
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
@@ -73,34 +94,9 @@ function buildKpis({
|
|||||||
>
|
>
|
||||||
{loading || bgpPct === null
|
{loading || bgpPct === null
|
||||||
? 'нет включённых пиров'
|
? 'нет включённых пиров'
|
||||||
: `${network.peersEstablished} установлено`}
|
: bgpPct >= 90
|
||||||
</Badge>
|
? 'сессии в норме'
|
||||||
),
|
: `${network.peersEstablished} установлено`}
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'peers',
|
|
||||||
icon: <Share2 aria-hidden />,
|
|
||||||
iconClassName: 'text-success',
|
|
||||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
|
||||||
label: 'Пиры установлены',
|
|
||||||
footer: (
|
|
||||||
<Badge variant="success-light" size="sm">
|
|
||||||
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'speakers',
|
|
||||||
icon: <ServerCog aria-hidden />,
|
|
||||||
iconClassName: 'text-warning',
|
|
||||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
|
||||||
label: 'Спикеры в сети',
|
|
||||||
footer: (
|
|
||||||
<Badge
|
|
||||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{loading ? '…' : 'в сети'}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -112,7 +108,7 @@ function buildKpis({
|
|||||||
label: 'Активные задачи',
|
label: 'Активные задачи',
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||||
{loading ? '…' : `${jobs.length} в выборке`}
|
{loading ? '…' : running > 0 ? 'выполняются' : 'очередь пуста'}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { Link, useNavigate } from '@tanstack/react-router'
|
|
||||||
import {
|
|
||||||
ArrowUpDownIcon,
|
|
||||||
BoxesIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
FilterIcon,
|
|
||||||
PlusIcon,
|
|
||||||
SearchIcon,
|
|
||||||
XIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|
||||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
|
||||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
|
||||||
import {
|
|
||||||
DataGridTable,
|
|
||||||
DataGridTableHeader,
|
|
||||||
} from '@/components/reui/data-grid/data-grid-table'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@evobgp/ui/components/dropdown-menu'
|
|
||||||
import {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupButton,
|
|
||||||
InputGroupInput,
|
|
||||||
} from '@evobgp/ui/components/input-group'
|
|
||||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
|
||||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
|
||||||
import type { ModuleRow } from '@/types/api'
|
|
||||||
import {
|
|
||||||
getCoreRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type PaginationState,
|
|
||||||
type SortingState,
|
|
||||||
} from '@tanstack/react-table'
|
|
||||||
|
|
||||||
type ModuleSort = 'name' | 'type' | 'priority'
|
|
||||||
type EnabledFilter = 'all' | 'enabled' | 'disabled'
|
|
||||||
|
|
||||||
const sortLabels: Record<ModuleSort, string> = {
|
|
||||||
name: 'Название',
|
|
||||||
type: 'Тип',
|
|
||||||
priority: 'Приоритет',
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
|
|
||||||
|
|
||||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
|
||||||
return [{ id: sortBy, desc: false }]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardModulesGrid({
|
|
||||||
modules,
|
|
||||||
isLoading = false,
|
|
||||||
}: {
|
|
||||||
modules: ModuleRow[]
|
|
||||||
isLoading?: boolean
|
|
||||||
}) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>('all')
|
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 10,
|
|
||||||
})
|
|
||||||
const [sortBy, setSortBy] = useState<ModuleSort>('name')
|
|
||||||
const [sorting, setSorting] = useState<SortingState>(() => buildSorting('name'))
|
|
||||||
|
|
||||||
const filteredModules = useMemo(() => {
|
|
||||||
const q = searchQuery.trim().toLowerCase()
|
|
||||||
return modules.filter((module) => {
|
|
||||||
const matchesSearch =
|
|
||||||
q.length === 0 ||
|
|
||||||
`${module.name} ${module.type} ${moduleTypeRu(module.type)}`.toLowerCase().includes(q)
|
|
||||||
const matchesEnabled =
|
|
||||||
enabledFilter === 'all' ||
|
|
||||||
(enabledFilter === 'enabled' ? module.enabled !== false : module.enabled === false)
|
|
||||||
return matchesSearch && matchesEnabled
|
|
||||||
})
|
|
||||||
}, [modules, searchQuery, enabledFilter])
|
|
||||||
|
|
||||||
const resetPagination = useCallback(() => {
|
|
||||||
setPagination((current) => ({ ...current, pageIndex: 0 }))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSortChange = useCallback(
|
|
||||||
(value: ModuleSort) => {
|
|
||||||
setSortBy(value)
|
|
||||||
setSorting(buildSorting(value))
|
|
||||||
resetPagination()
|
|
||||||
},
|
|
||||||
[resetPagination],
|
|
||||||
)
|
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<ModuleRow>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'name',
|
|
||||||
id: 'name',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
|
||||||
<DataGridPrimaryCell title={row.original.name} accent="primary" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
minSize: 180,
|
|
||||||
meta: { headerTitle: 'Модуль' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'type',
|
|
||||||
id: 'type',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
|
||||||
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
|
|
||||||
meta: { headerTitle: 'Тип' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'priority',
|
|
||||||
id: 'priority',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
|
|
||||||
),
|
|
||||||
size: 88,
|
|
||||||
meta: { headerTitle: 'Приоритет' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: filteredModules,
|
|
||||||
columns,
|
|
||||||
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
|
|
||||||
state: { pagination, sorting },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeFilterCount = enabledFilter === 'all' ? 0 : 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardFramePanel
|
|
||||||
title="Модули"
|
|
||||||
description="Поиск, сортировка и быстрый переход к настройке"
|
|
||||||
className="min-w-0"
|
|
||||||
actions={
|
|
||||||
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
|
|
||||||
<PlusIcon />
|
|
||||||
Создать
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGrid
|
|
||||||
table={table}
|
|
||||||
recordCount={filteredModules.length}
|
|
||||||
isLoading={isLoading}
|
|
||||||
emptyMessage={EMPTY_MESSAGE}
|
|
||||||
tableLayout={{
|
|
||||||
dense: true,
|
|
||||||
rowBorder: true,
|
|
||||||
headerSticky: false,
|
|
||||||
columnsVisibility: false,
|
|
||||||
columnsResizable: false,
|
|
||||||
columnsMovable: false,
|
|
||||||
width: 'auto',
|
|
||||||
}}
|
|
||||||
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
|
||||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
|
|
||||||
<InputGroup className="w-full min-w-0 @3xl:max-w-xs">
|
|
||||||
<InputGroupAddon align="inline-start">
|
|
||||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
|
|
||||||
</InputGroupAddon>
|
|
||||||
<InputGroupInput
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSearchQuery(event.target.value)
|
|
||||||
resetPagination()
|
|
||||||
}}
|
|
||||||
placeholder="Поиск модулей…"
|
|
||||||
aria-label="Поиск модулей"
|
|
||||||
/>
|
|
||||||
{searchQuery.length > 0 ? (
|
|
||||||
<InputGroupAddon align="inline-end">
|
|
||||||
<InputGroupButton
|
|
||||||
size="icon-xs"
|
|
||||||
aria-label="Очистить поиск"
|
|
||||||
onClick={() => {
|
|
||||||
setSearchQuery('')
|
|
||||||
resetPagination()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XIcon className="size-4" aria-hidden />
|
|
||||||
</InputGroupButton>
|
|
||||||
</InputGroupAddon>
|
|
||||||
) : null}
|
|
||||||
</InputGroup>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<DropdownMenu modal={false}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button type="button" variant="outline" size="sm">
|
|
||||||
<ArrowUpDownIcon data-icon="inline-start" aria-hidden />
|
|
||||||
{sortLabels[sortBy]}
|
|
||||||
<ChevronDownIcon data-icon="inline-end" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DropdownMenuContent align="end" className="min-w-44">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
{(Object.keys(sortLabels) as ModuleSort[]).map((value) => (
|
|
||||||
<DropdownMenuItem key={value} onClick={() => handleSortChange(value)}>
|
|
||||||
{sortLabels[value]}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<DropdownMenu modal={false}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button type="button" variant="outline" size="sm">
|
|
||||||
<FilterIcon data-icon="inline-start" aria-hidden />
|
|
||||||
Фильтры
|
|
||||||
{activeFilterCount > 0 ? (
|
|
||||||
<Badge variant="outline" radius="full">
|
|
||||||
{activeFilterCount}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DropdownMenuContent align="end" className="min-w-48">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>Состояние</DropdownMenuLabel>
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
checked={enabledFilter === 'enabled'}
|
|
||||||
closeOnClick={false}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setEnabledFilter(checked ? 'enabled' : 'all')
|
|
||||||
resetPagination()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Только включённые
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
checked={enabledFilter === 'disabled'}
|
|
||||||
closeOnClick={false}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setEnabledFilter(checked ? 'disabled' : 'all')
|
|
||||||
resetPagination()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Только выключенные
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
{activeFilterCount > 0 ? (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
closeOnClick={false}
|
|
||||||
onClick={() => {
|
|
||||||
setEnabledFilter('all')
|
|
||||||
resetPagination()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сбросить фильтры
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredModules.length > 0 ? (
|
|
||||||
<DataGridScrollArea>
|
|
||||||
<DataGridTable />
|
|
||||||
</DataGridScrollArea>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DataGridScrollArea>
|
|
||||||
<DataGridTableHeader />
|
|
||||||
</DataGridScrollArea>
|
|
||||||
<div className="text-muted-foreground flex min-h-40 items-center justify-center px-4 text-center text-sm">
|
|
||||||
{EMPTY_MESSAGE}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="border-t px-4 py-3">
|
|
||||||
{filteredModules.length > 0 ? (
|
|
||||||
<DataGridPagination
|
|
||||||
{...DATA_GRID_PAGINATION_RU}
|
|
||||||
sizes={[10, 15, 20]}
|
|
||||||
className="py-0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground text-center text-sm">0 модулей</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DataGrid>
|
|
||||||
</DashboardFramePanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -60,8 +60,28 @@ export function DashboardNetworkHealth({
|
|||||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
|
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
|
||||||
percent: loading ? 0 : utilization,
|
percent: loading ? 0 : utilization,
|
||||||
badge: (
|
badge: (
|
||||||
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
|
<Badge
|
||||||
{loading ? '…' : `${established}/${total}`}
|
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>
|
</Badge>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ const ACTIONS: QuickActionItem[] = [
|
|||||||
title: 'Мониторинг',
|
title: 'Мониторинг',
|
||||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||||
to: '/monitoring',
|
to: '/monitoring',
|
||||||
search: { tab: 'system' },
|
|
||||||
icon: <Gauge aria-hidden />,
|
icon: <Gauge aria-hidden />,
|
||||||
iconClassName: 'text-destructive',
|
iconClassName: 'text-destructive',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|
||||||
import { DataGridShell } from '@/components/data-grid-shell'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
|
||||||
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)}
|
|
||||||
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, 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 (
|
|
||||||
<DataGridShell
|
|
||||||
table={table}
|
|
||||||
recordCount={filteredCount}
|
|
||||||
isLoading={isLoading}
|
|
||||||
emptyMessage="Нет задач"
|
|
||||||
showPagination={false}
|
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|
||||||
import { DataGridShell } from '@/components/data-grid-shell'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
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, filteredCount } = useClientDataGrid({
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) => row.id,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
pageSize: 8,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridShell
|
|
||||||
table={table}
|
|
||||||
recordCount={filteredCount}
|
|
||||||
isLoading={isLoading}
|
|
||||||
emptyMessage="Нет ревизий"
|
|
||||||
showPagination={false}
|
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,38 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
const ACCENT_CLASS = {
|
/**
|
||||||
primary: 'font-medium text-primary',
|
* Name cell DNA — IconTile elevated size-10.5 + truncate.
|
||||||
default: 'font-medium text-foreground',
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
mono: 'font-mono text-sm text-primary',
|
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||||
} as const
|
*/
|
||||||
|
export function DataGridNameCell({
|
||||||
export function DataGridPrimaryCell({
|
icon: Icon,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
accent = 'default',
|
iconClassName = 'text-muted-foreground',
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
|
icon: LucideIcon
|
||||||
title: ReactNode
|
title: ReactNode
|
||||||
subtitle?: ReactNode
|
subtitle?: ReactNode
|
||||||
accent?: keyof typeof ACCENT_CLASS
|
iconClassName?: string
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-w-0 flex-col gap-0.5', className)}>
|
<div className={cn('flex min-w-0 items-center gap-2.5', className)}>
|
||||||
<span className={cn('truncate', ACCENT_CLASS[accent])}>{title}</span>
|
<IconTile variant="elevated" className="size-10.5">
|
||||||
{subtitle ? (
|
<Icon className={iconClassName} aria-hidden />
|
||||||
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
|
</IconTile>
|
||||||
) : null}
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -42,3 +50,16 @@ export function DataGridMutedCell({
|
|||||||
</span>
|
</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,125 +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 {
|
|
||||||
panelCardContentFlushClassName,
|
|
||||||
panelCardFooterClassName,
|
|
||||||
} from '@/components/panel-card'
|
|
||||||
import { FrameDataGrid } from '@/components/reui-kit/frame-data-grid'
|
|
||||||
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 { FrameFooter } from '@/components/reui/frame'
|
|
||||||
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={cn('min-w-0', className)}
|
|
||||||
onRowClick={onRowClick}
|
|
||||||
>
|
|
||||||
<DataGridContainer border={false}>
|
|
||||||
<DataGridTable />
|
|
||||||
</DataGridContainer>
|
|
||||||
{showPagination ? (
|
|
||||||
<FrameFooter className={cn(panelCardFooterClassName)}>
|
|
||||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
|
||||||
</FrameFooter>
|
|
||||||
) : null}
|
|
||||||
</DataGrid>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated Prefer FrameDataGrid from @/components/reui-kit */
|
|
||||||
export function DataGridCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
actions,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
title?: ReactNode
|
|
||||||
description?: ReactNode
|
|
||||||
actions?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<FrameDataGrid
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
actions={actions}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<div className={panelCardContentFlushClassName}>{children}</div>
|
|
||||||
</FrameDataGrid>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,77 +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
|
|
||||||
/** ReUI Filters trigger or custom filter controls. Preview: https://reui.io/preview/base/data-grid-filtering-2 */
|
|
||||||
filters?: ReactNode
|
|
||||||
actions?: ReactNode
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Search + optional ReUI Filters row for Frame data grids. */
|
|
||||||
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
|
|
||||||
aria-label="Фильтры"
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
disabled
|
|
||||||
title="Передайте filters={<Filters … />} из @/components/reui/filters"
|
|
||||||
>
|
|
||||||
<ListFilterIcon aria-hidden="true" />
|
|
||||||
</InputGroupButton>
|
|
||||||
)}
|
|
||||||
</InputGroupAddon>
|
|
||||||
</InputGroup>
|
|
||||||
</Field>
|
|
||||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +1,52 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import { useMemo } from 'react'
|
import { Tags } from 'lucide-react'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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'
|
import type { BgpCommunity } from '@/types/api'
|
||||||
|
|
||||||
|
const filterFields: FilterField[] = [
|
||||||
|
createSearchFilterField('search', 'Поиск', 'Поиск community…'),
|
||||||
|
]
|
||||||
|
|
||||||
export function DirectoriesCommunitiesGrid({
|
export function DirectoriesCommunitiesGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
canWrite = false,
|
canWrite = false,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
items: BgpCommunity[]
|
items: BgpCommunity[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
canWrite?: boolean
|
canWrite?: boolean
|
||||||
onEdit?: (row: BgpCommunity) => void
|
onEdit?: (row: BgpCommunity) => void
|
||||||
|
actions?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const columns = useMemo<ColumnDef<BgpCommunity>[]>(() => {
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
const cols: ColumnDef<BgpCommunity>[] = [
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumnDef<BgpCommunity>[]>(() => {
|
||||||
|
const cols: DataGridColumnDef<BgpCommunity>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} 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: 'Название' },
|
meta: { headerTitle: 'Название' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'community',
|
accessorKey: 'community',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <DataGridMonoCell>{row.original.community}</DataGridMonoCell>,
|
||||||
<DataGridPrimaryCell title={row.original.community} accent="mono" />
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Значение' },
|
meta: { headerTitle: 'Значение' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -51,9 +64,7 @@ export function DirectoriesCommunitiesGrid({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <DirectoriesRowActions onEdit={() => onEdit(row.original)} />,
|
||||||
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Действия' },
|
meta: { headerTitle: 'Действия' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -61,22 +72,22 @@ export function DirectoriesCommunitiesGrid({
|
|||||||
return cols
|
return cols
|
||||||
}, [canWrite, onEdit])
|
}, [canWrite, onEdit])
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: items,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) => `${row.title} ${row.community}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title="BGP community"
|
||||||
recordCount={filteredCount}
|
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}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет community"
|
primaryAction={actions}
|
||||||
searchValue={globalFilter}
|
pinLastColumn={Boolean(canWrite && onEdit)}
|
||||||
onSearchChange={setGlobalFilter}
|
emptyState={{ title: 'Нет community', action: actions }}
|
||||||
searchPlaceholder="Поиск community…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,36 +1,51 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import { useMemo } from 'react'
|
import { Globe } from 'lucide-react'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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'
|
import type { DohProfile } from '@/types/api'
|
||||||
|
|
||||||
|
const filterFields: FilterField[] = [
|
||||||
|
createSearchFilterField('search', 'Поиск', 'Поиск DoH-профилей…'),
|
||||||
|
]
|
||||||
|
|
||||||
export function DirectoriesDohGrid({
|
export function DirectoriesDohGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
canWrite = false,
|
canWrite = false,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
items: DohProfile[]
|
items: DohProfile[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
canWrite?: boolean
|
canWrite?: boolean
|
||||||
onEdit?: (row: DohProfile) => void
|
onEdit?: (row: DohProfile) => void
|
||||||
|
actions?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const columns = useMemo<ColumnDef<DohProfile>[]>(() => {
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
const cols: ColumnDef<DohProfile>[] = [
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumnDef<DohProfile>[]>(() => {
|
||||||
|
const cols: DataGridColumnDef<DohProfile>[] = [
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row) => row.name ?? row.url,
|
accessorFn: (row) => row.name ?? row.url,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridNameCell
|
||||||
|
icon={Globe}
|
||||||
title={row.original.name ?? row.original.url}
|
title={row.original.name ?? row.original.url}
|
||||||
subtitle={row.original.name ? row.original.url : undefined}
|
subtitle={row.original.name ? row.original.url : undefined}
|
||||||
accent="primary"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Название' },
|
meta: { headerTitle: 'Название' },
|
||||||
@@ -38,7 +53,7 @@ export function DirectoriesDohGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'url',
|
accessorKey: 'url',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="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' },
|
meta: { headerTitle: 'URL' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -56,9 +71,7 @@ export function DirectoriesDohGrid({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <DirectoriesRowActions onEdit={() => onEdit(row.original)} />,
|
||||||
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Действия' },
|
meta: { headerTitle: 'Действия' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -66,22 +79,22 @@ export function DirectoriesDohGrid({
|
|||||||
return cols
|
return cols
|
||||||
}, [canWrite, onEdit])
|
}, [canWrite, onEdit])
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: items,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) => `${row.name ?? ''} ${row.url}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title="DoH-профили"
|
||||||
recordCount={filteredCount}
|
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}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет DoH профилей"
|
primaryAction={actions}
|
||||||
searchValue={globalFilter}
|
pinLastColumn={Boolean(canWrite && onEdit)}
|
||||||
onSearchChange={setGlobalFilter}
|
emptyState={{ title: 'Нет DoH-профилей', action: actions }}
|
||||||
searchPlaceholder="Поиск DoH профилей…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,10 @@ import { InboxIcon, type LucideIcon } from 'lucide-react'
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import { IconStack } from '@/components/reui/icon-stack'
|
import { IconStack } from '@/components/reui/icon-stack'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FramePanel,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyContent,
|
EmptyContent,
|
||||||
@@ -25,6 +29,8 @@ interface EmptyStateProps {
|
|||||||
className?: string
|
className?: string
|
||||||
stackedIcon?: boolean
|
stackedIcon?: boolean
|
||||||
centered?: boolean
|
centered?: boolean
|
||||||
|
/** Wrap in Frame (empty-state-14). Nested grids should leave this false. */
|
||||||
|
framed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
|
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
|
||||||
@@ -44,15 +50,16 @@ export function EmptyState({
|
|||||||
className,
|
className,
|
||||||
stackedIcon = true,
|
stackedIcon = true,
|
||||||
centered = true,
|
centered = true,
|
||||||
|
framed = false,
|
||||||
}: EmptyStateProps) {
|
}: EmptyStateProps) {
|
||||||
const Icon = isLucideIcon(icon) ? icon : InboxIcon
|
const Icon = isLucideIcon(icon) ? icon : InboxIcon
|
||||||
const customIcon = icon && !isLucideIcon(icon) ? icon : null
|
const customIcon = icon && !isLucideIcon(icon) ? icon : null
|
||||||
|
|
||||||
const body = (
|
const empty = (
|
||||||
<Empty
|
<Empty
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-md flex-none border-0 bg-transparent p-0',
|
'max-w-md flex-none border-0 bg-transparent p-0',
|
||||||
!centered && className,
|
!centered && !framed && className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<EmptyHeader className="gap-5 text-center">
|
<EmptyHeader className="gap-5 text-center">
|
||||||
@@ -88,6 +95,16 @@ export function EmptyState({
|
|||||||
</Empty>
|
</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
|
if (!centered) return body
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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,129 +0,0 @@
|
|||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@evobgp/ui/components/card"
|
|
||||||
import { Input } from "@evobgp/ui/components/input"
|
|
||||||
import { Label } from "@evobgp/ui/components/label"
|
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "@evobgp/ui/components/tabs"
|
|
||||||
|
|
||||||
export function Pattern() {
|
|
||||||
return (
|
|
||||||
<div className="flex w-full max-w-xs flex-col gap-6">
|
|
||||||
<Tabs defaultValue="account">
|
|
||||||
<TabsList variant="line" className="mb-3.5 w-full">
|
|
||||||
<TabsTrigger value="account">Account</TabsTrigger>
|
|
||||||
<TabsTrigger value="password">Password</TabsTrigger>
|
|
||||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="account">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Account</CardTitle>
|
|
||||||
<CardDescription className="text-sm">
|
|
||||||
Update your account information.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="underline-name" className="text-sm">
|
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="underline-name"
|
|
||||||
defaultValue="Alex Chen"
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="underline-email" className="text-sm">
|
|
||||||
Email
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="underline-email"
|
|
||||||
type="email"
|
|
||||||
defaultValue="[email protected]"
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="pt-3">
|
|
||||||
<Button size="sm">Save changes</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="password">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Password</CardTitle>
|
|
||||||
<CardDescription className="text-sm">
|
|
||||||
Change your password here.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="underline-current" className="text-sm">
|
|
||||||
Current password
|
|
||||||
</Label>
|
|
||||||
<Input id="underline-current" type="password" className="h-9" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="underline-new" className="text-sm">
|
|
||||||
New password
|
|
||||||
</Label>
|
|
||||||
<Input id="underline-new" type="password" className="h-9" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="pt-3">
|
|
||||||
<Button size="sm">Update password</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="settings">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Settings</CardTitle>
|
|
||||||
<CardDescription className="text-sm">
|
|
||||||
Manage your preferences.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="underline-theme" className="text-sm">
|
|
||||||
Theme
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="underline-theme"
|
|
||||||
defaultValue="Light"
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="underline-language" className="text-sm">
|
|
||||||
Language
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="underline-language"
|
|
||||||
defaultValue="English"
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="pt-3">
|
|
||||||
<Button size="sm">Save settings</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,6 +1,8 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { FieldGroup } from '@evobgp/ui/components/field'
|
||||||
|
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -20,7 +22,11 @@ interface FormDrawerProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
|
/**
|
||||||
|
* 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({
|
export function FormDrawer({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -34,13 +40,17 @@ export function FormDrawer({
|
|||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
side="right"
|
||||||
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
|
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">
|
<SheetHeader className="shrink-0 border-b border-border/50">
|
||||||
<SheetTitle>{title}</SheetTitle>
|
<SheetTitle>{title}</SheetTitle>
|
||||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
|
<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">
|
<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">
|
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||||
{footer}
|
{footer}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Re-export from reui-kit — keep `@/components/kpi-stat-grid` imports stable.
|
|
||||||
* @see https://reui.io/preview/base/stats-12
|
|
||||||
*/
|
|
||||||
export {
|
|
||||||
KpiStatGrid,
|
|
||||||
KpiStatCard,
|
|
||||||
KpiStatCardTile,
|
|
||||||
kpiStatItemKey,
|
|
||||||
type KpiStatItem,
|
|
||||||
type KpiStatCardData,
|
|
||||||
type KpiStatVariant,
|
|
||||||
type OpsKpiCard,
|
|
||||||
} from '@/components/reui-kit/kpi-stat-grid'
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import {
|
|
||||||
LayoutDashboard,
|
|
||||||
Boxes,
|
|
||||||
Network,
|
|
||||||
Cog,
|
|
||||||
ListChecks,
|
|
||||||
Activity,
|
|
||||||
Settings,
|
|
||||||
BookText,
|
|
||||||
KeyRound,
|
|
||||||
Search,
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarFooter,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarInset,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarProvider,
|
|
||||||
SidebarTrigger,
|
|
||||||
} from '@evobgp/ui/components/sidebar'
|
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
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, CSSProperties, ReactNode } from 'react'
|
|
||||||
|
|
||||||
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 {
|
|
||||||
label: string
|
|
||||||
items: NavItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const NAV_GROUPS: NavGroup[] = [
|
|
||||||
{
|
|
||||||
label: 'Обзор',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
to: '/dashboard',
|
|
||||||
label: 'Панель',
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
description: 'Метрики, модули и активность',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Маршрутизация',
|
|
||||||
items: [
|
|
||||||
{ 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, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
|
||||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
|
||||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Система',
|
|
||||||
items: [
|
|
||||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
|
||||||
{ to: '/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> = {
|
|
||||||
'/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[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 (
|
|
||||||
<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>
|
|
||||||
</SidebarInset>
|
|
||||||
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
|
||||||
</SidebarProvider>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
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,226 @@
|
|||||||
|
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
BookTextIcon,
|
||||||
|
BoxesIcon,
|
||||||
|
CogIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
ListChecksIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { AppSwitcher } from '@/components/app-switcher'
|
||||||
|
import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome'
|
||||||
|
import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
|
||||||
|
import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
|
||||||
|
import { can, getClaims, isAuthEnabled, permissionForPath, redirectToPortalLogout, clearPortalToken, resetPortalHandoff } from '@/lib/auth'
|
||||||
|
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||||
|
import { monitoringHealthQueryOptions } from '@/queries/monitoring'
|
||||||
|
import { networkBirdQueryOptions } from '@/queries/network'
|
||||||
|
import {
|
||||||
|
aggregateNetworkMetrics,
|
||||||
|
overviewJobsQueryOptions,
|
||||||
|
overviewModulesQueryOptions,
|
||||||
|
overviewPeersQueryOptions,
|
||||||
|
} from '@/queries/overview'
|
||||||
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
import { SidebarInset, SidebarProvider } from '@evobgp/ui/components/sidebar'
|
||||||
|
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
|
||||||
|
|
||||||
|
const FRESH: Record<string, { badge: string; text: string }> = {
|
||||||
|
'/dashboard': { badge: 'Live', text: 'GET /v1/modules · /v1/peers · /v1/jobs · данные обновляются при открытии страницы' },
|
||||||
|
'/modules': { badge: 'Каталог', text: 'GET /v1/modules · списки префиксов и AS' },
|
||||||
|
'/lookup': { badge: 'Проверка', text: 'GET /v1/lookup · IP или домен по спискам и community' },
|
||||||
|
'/network': { badge: 'BGP', text: 'GET /v1/peers · /v1/speakers · состояние сессий опрашивается живьём' },
|
||||||
|
'/directories': { badge: 'Справочники', text: 'BGP community и DoH-профили' },
|
||||||
|
'/operations': { badge: 'Ревизии', text: 'GET /v1/revisions · /v1/jobs · применение и откат' },
|
||||||
|
'/schedule': { badge: 'Задачи', text: 'Расписание обновления модулей' },
|
||||||
|
'/monitoring': { badge: 'Live', text: 'GET /health · /ready · BIRD через birdc' },
|
||||||
|
'/access': { badge: 'API', text: 'API-ключи и роли доступа' },
|
||||||
|
'/settings': { badge: 'Настройки', text: 'Интерфейс и BIRD' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshFor(pathname: string) {
|
||||||
|
const key = Object.keys(FRESH)
|
||||||
|
.sort((a, b) => b.length - a.length)
|
||||||
|
.find((k) => pathname === k || pathname.startsWith(`${k}/`))
|
||||||
|
return FRESH[key ?? '/dashboard']!
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialsOf(name: string) {
|
||||||
|
const parts = name.trim().split(/[\s@._-]+/).filter(Boolean)
|
||||||
|
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV: Array<Omit<NavItem, 'problem'>> = [
|
||||||
|
{ id: 'dashboard', label: 'Панель', to: '/dashboard', icon: LayoutDashboardIcon, group: 'Обзор' },
|
||||||
|
{ id: 'modules', label: 'Модули', to: '/modules', icon: BoxesIcon, group: 'Маршрутизация' },
|
||||||
|
{ id: 'lookup', label: 'Проверка', to: '/lookup', icon: SearchIcon, group: 'Маршрутизация' },
|
||||||
|
{ id: 'network', label: 'Сеть', to: '/network', icon: NetworkIcon, group: 'Маршрутизация' },
|
||||||
|
{ id: 'directories', label: 'Справочники', to: '/directories', icon: BookTextIcon, group: 'Маршрутизация' },
|
||||||
|
{ id: 'operations', label: 'Операции', to: '/operations', icon: CogIcon, group: 'Операции' },
|
||||||
|
{ id: 'schedule', label: 'Задачи', to: '/schedule', icon: ListChecksIcon, group: 'Операции' },
|
||||||
|
{ id: 'monitoring', label: 'Мониторинг', to: '/monitoring', icon: ActivityIcon, group: 'Операции' },
|
||||||
|
{ id: 'access', label: 'Доступ', to: '/access', icon: KeyRoundIcon, group: 'Система' },
|
||||||
|
{ id: 'settings', label: 'Настройки', to: '/settings', icon: SettingsIcon, group: 'Система' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
if (isAuthEnabled()) {
|
||||||
|
clearPortalToken()
|
||||||
|
resetPortalHandoff()
|
||||||
|
redirectToPortalLogout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Меню и данные оболочки EvoBGP: пункты по правам, точки проблем и монитор из живых запросов. */
|
||||||
|
function useChromeData(): ChromeConfig {
|
||||||
|
const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const modulesQ = useQuery(overviewModulesQueryOptions())
|
||||||
|
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
|
||||||
|
return useMemo<ChromeConfig>(() => {
|
||||||
|
const peers = peersQ.data?.items ?? []
|
||||||
|
const jobs = jobsQ.data?.items ?? []
|
||||||
|
const modules = modulesQ.data?.items ?? []
|
||||||
|
const net = aggregateNetworkMetrics(peers, [])
|
||||||
|
const failedJobs = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase())).length
|
||||||
|
const peersDown = Math.max(0, net.peersEnabled - net.peersEstablished)
|
||||||
|
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 bgpPct = net.peersEnabled > 0 ? Math.round((net.peersEstablished / net.peersEnabled) * 100) : 0
|
||||||
|
|
||||||
|
const visible = NAV.filter((item) => {
|
||||||
|
if (!isAuthEnabled()) return true
|
||||||
|
const perm = permissionForPath(item.to)
|
||||||
|
return !perm || can(perm)
|
||||||
|
})
|
||||||
|
const problems: Record<string, NavItem['problem']> = {
|
||||||
|
network: peersDown > 0 ? { tone: 'er', label: `${peersDown} пиров не в Established` } : undefined,
|
||||||
|
schedule: failedJobs > 0 ? { tone: 'wn', label: `Ошибок задач: ${failedJobs}` } : undefined,
|
||||||
|
monitoring: healthQ.isSuccess && !healthOk ? { tone: 'er', label: 'API не отвечает как норма' } : undefined,
|
||||||
|
}
|
||||||
|
const navItems: NavItem[] = visible.map((i) => ({ ...i, problem: problems[i.id] }))
|
||||||
|
const groups = [...new Set(navItems.map((i) => i.group))]
|
||||||
|
|
||||||
|
const monitorItems: MonitorItem[] = []
|
||||||
|
if (healthQ.isSuccess && !healthOk) {
|
||||||
|
monitorItems.push({ id: 'health', tone: 'er', title: 'API EvoBGP не в норме', text: 'GET /health вернул ошибку', age: 'сейчас', to: '/monitoring' })
|
||||||
|
}
|
||||||
|
if (peersDown > 0) {
|
||||||
|
monitorItems.push({ id: 'peers', tone: 'er', title: `Пиров не в Established: ${peersDown}`, text: `Включено ${net.peersEnabled}, поднято ${net.peersEstablished}`, age: 'сейчас', to: '/network' })
|
||||||
|
}
|
||||||
|
if (failedJobs > 0) {
|
||||||
|
monitorItems.push({ id: 'jobs', tone: 'wn', title: `Ошибок задач: ${failedJobs}`, text: 'Проверьте расписание и журнал ревизий', age: 'недавно', to: '/schedule' })
|
||||||
|
}
|
||||||
|
if (monitorItems.length === 0) {
|
||||||
|
monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, BGP-сессии и задачи без проблем', age: 'сейчас', to: '/monitoring' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics: MonitorMetric[] = [
|
||||||
|
{ l: 'Состояние API', v: healthOk ? 'норма' : '—', p: healthOk ? 100 : 0, tone: healthOk ? 'success' : 'warning' },
|
||||||
|
{ l: 'BGP пиры', v: `${bgpPct}%`, p: bgpPct, tone: bgpPct >= 90 ? 'success' : 'warning' },
|
||||||
|
{ l: 'BIRD сессии', v: birdRatio === null ? '—' : `${birdRatio}%`, p: birdRatio ?? 0, tone: birdRatio === 100 ? 'success' : 'warning' },
|
||||||
|
{ l: 'Ошибки задач', v: String(failedJobs), p: Math.min(100, failedJobs * 10), tone: failedJobs > 0 ? 'warning' : 'success' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const claims = getClaims()
|
||||||
|
const name = claims?.name?.trim() || (isAuthEnabled() ? 'Пользователь' : 'Гость')
|
||||||
|
const email = claims?.email?.trim() || (isAuthEnabled() ? '' : 'локальный API-токен')
|
||||||
|
|
||||||
|
const objects: PaletteObject[] = [
|
||||||
|
...modules.map<PaletteObject>((m) => ({
|
||||||
|
id: `module-${m.id}`,
|
||||||
|
group: 'Модули',
|
||||||
|
label: m.name,
|
||||||
|
hint: `${m.type}${m.enabled ? '' : ' · выключен'}`,
|
||||||
|
dot: m.enabled ? 'ok' : 'mu',
|
||||||
|
to: `/modules/${m.id}`,
|
||||||
|
})),
|
||||||
|
...peers.map<PaletteObject>((p) => ({
|
||||||
|
id: `peer-${p.id}`,
|
||||||
|
group: 'BGP-пиры',
|
||||||
|
label: p.name ?? p.neighbor,
|
||||||
|
hint: `${p.neighbor}${p.remote_asn ? ` · AS${p.remote_asn}` : ''}`,
|
||||||
|
dot: p.session_state === 'Established' ? 'ok' : 'er',
|
||||||
|
to: '/network',
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
navItems,
|
||||||
|
navGroups: groups,
|
||||||
|
brand: <AppSwitcher />,
|
||||||
|
user: { name, email, initials: initialsOf(name || email) },
|
||||||
|
securityTo: '/settings',
|
||||||
|
onLogout: logout,
|
||||||
|
fresh: freshFor,
|
||||||
|
monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: '/monitoring', footerLabel: 'Мониторинг' },
|
||||||
|
palette: {
|
||||||
|
actions: [
|
||||||
|
{ id: 'act-module', label: 'Создать модуль', hint: 'M', icon: PlusIcon, to: '/modules/new' },
|
||||||
|
{ id: 'act-lookup', label: 'Проверить IP или домен', hint: 'L', icon: SearchIcon, to: '/lookup' },
|
||||||
|
{ id: 'act-ops', label: 'Открыть ревизии', hint: 'R', icon: CogIcon, to: '/operations' },
|
||||||
|
],
|
||||||
|
objects,
|
||||||
|
assistantTo: '/lookup',
|
||||||
|
example: 'Подсказка: «пиры не в Established»',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, [healthQ.data, healthQ.isSuccess, peersQ.data, jobsQ.data, modulesQ.data, birdQ.data])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Оболочка EvoBGP: боковое меню, шапка, монитор, палитра ⌘K — блоки общего макета. */
|
||||||
|
export function PortalShell({ children }: { children: ReactNode }) {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
// До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet).
|
||||||
|
const mq = window.matchMedia('(min-width: 1100px)')
|
||||||
|
const sync = () => setOpen(mq.matches)
|
||||||
|
sync()
|
||||||
|
mq.addEventListener('change', sync)
|
||||||
|
setMounted(true)
|
||||||
|
return () => mq.removeEventListener('change', sync)
|
||||||
|
}, [])
|
||||||
|
const config = useChromeData()
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh flex-col gap-4 p-6" aria-busy>
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalUiProvider>
|
||||||
|
<ChromeConfigProvider value={config}>
|
||||||
|
<TooltipProvider delay={0}>
|
||||||
|
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
|
||||||
|
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||||
|
К содержимому
|
||||||
|
</a>
|
||||||
|
<PortalSidebar />
|
||||||
|
<SidebarInset id="main-content" className="min-w-0">
|
||||||
|
<PortalHeader />
|
||||||
|
<div className="mx-auto flex w-full min-w-0 max-w-[1480px] flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
|
||||||
|
</SidebarInset>
|
||||||
|
<CommandPalette />
|
||||||
|
</SidebarProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
</ChromeConfigProvider>
|
||||||
|
</PortalUiProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
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 { Button } from '@evobgp/ui/components/button'
|
||||||
import { Loader2Icon } from 'lucide-react'
|
import { Spinner } from '@evobgp/ui/components/spinner'
|
||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
import type { ComponentProps, ReactNode } from 'react'
|
||||||
|
|
||||||
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
type LoadingButtonProps = ComponentProps<typeof Button> & {
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
|
|
||||||
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
|
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SoT for async actions — Button + Spinner. */
|
||||||
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
|
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
|
||||||
return (
|
return (
|
||||||
<Button disabled={disabled || loading} {...props}>
|
<Button disabled={disabled || loading} {...props}>
|
||||||
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
|
{loading ? <Spinner data-icon="inline-start" /> : null}
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,15 +5,22 @@ import { toast } from 'sonner'
|
|||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||||
|
|
||||||
import { CommunitySelect } from '@/components/modules/community-select'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
} from '@/components/reui/alert'
|
} from '@/components/reui/alert'
|
||||||
import { SelectMenu } from '@/components/select-field'
|
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 { ApiError, apiMutate } from '@/lib/api-client'
|
||||||
|
import { CASCADER_LABELS_RU } from '@/lib/reui-i18n-ru'
|
||||||
import type {
|
import type {
|
||||||
BgpCommunity,
|
BgpCommunity,
|
||||||
LookupQueryKind,
|
LookupQueryKind,
|
||||||
@@ -22,8 +29,10 @@ import type {
|
|||||||
} from '@/types/api'
|
} from '@/types/api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lookup wizard step 3 — module + community (bare content for single Frame).
|
* Lookup wizard step 3 — cascader module → community (wizard-2).
|
||||||
* @see https://reui.io/preview/base/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 {
|
function hostPrefixFromIp(ip: string): string {
|
||||||
@@ -34,6 +43,31 @@ function moduleTypeForKind(kind: LookupQueryKind): ModuleRow['type'] {
|
|||||||
return kind === 'domain' ? 'DOMAINS' : 'IP_RANGES'
|
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({
|
export function LookupAddStep({
|
||||||
data,
|
data,
|
||||||
modules,
|
modules,
|
||||||
@@ -48,49 +82,58 @@ export function LookupAddStep({
|
|||||||
onAdded: () => void | Promise<void>
|
onAdded: () => void | Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const wantedType = moduleTypeForKind(data.query_kind)
|
const wantedType = moduleTypeForKind(data.query_kind)
|
||||||
|
const allowEmptyCommunity = data.query_kind === 'domain'
|
||||||
const eligible = useMemo(
|
const eligible = useMemo(
|
||||||
() => modules.filter((m) => m.type === wantedType),
|
() => modules.filter((m) => m.type === wantedType),
|
||||||
[modules, wantedType],
|
[modules, wantedType],
|
||||||
)
|
)
|
||||||
|
|
||||||
const [moduleId, setModuleId] = useState('')
|
const [pick, setPick] = useState('')
|
||||||
const [communityId, setCommunityId] = useState<string | null>(null)
|
|
||||||
const [saving, setSaving] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (eligible.length === 0) {
|
if (eligible.length === 0) {
|
||||||
setModuleId('')
|
setPick('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setModuleId((prev) =>
|
const first = eligible[0]!
|
||||||
prev && eligible.some((m) => m.id === prev) ? prev : eligible[0]!.id,
|
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])
|
}, [eligible])
|
||||||
|
|
||||||
useEffect(() => {
|
const parsed = pick ? parsePick(pick) : null
|
||||||
const mod = eligible.find((m) => m.id === moduleId)
|
const moduleId = parsed?.moduleId ?? ''
|
||||||
if (!mod) {
|
const communityId = parsed?.communityId ?? null
|
||||||
setCommunityId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setCommunityId(mod.default_community_id ?? null)
|
|
||||||
}, [moduleId, eligible])
|
|
||||||
|
|
||||||
const moduleItems = useMemo(
|
|
||||||
() =>
|
|
||||||
eligible.map((m) => ({
|
|
||||||
value: m.id,
|
|
||||||
label: m.name,
|
|
||||||
})),
|
|
||||||
[eligible],
|
|
||||||
)
|
|
||||||
|
|
||||||
async function handleAdd() {
|
async function handleAdd() {
|
||||||
if (!moduleId) {
|
if (!moduleId) {
|
||||||
toast.error('Выберите модуль')
|
toast.error('Выберите модуль')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (data.query_kind !== 'domain' && !communityId) {
|
if (!allowEmptyCommunity && !communityId) {
|
||||||
toast.error('Укажите community')
|
toast.error('Укажите community')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -151,28 +194,32 @@ export function LookupAddStep({
|
|||||||
«{valueLabel}» отсутствует в списках. Выберите модуль и community.
|
«{valueLabel}» отсутствует в списках. Выберите модуль и community.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<Field>
|
||||||
<Field>
|
<FieldLabel htmlFor="lookup-add-cascader">Модуль и community</FieldLabel>
|
||||||
<FieldLabel htmlFor="lookup-add-module">Модуль ({wantedType})</FieldLabel>
|
<Cascader
|
||||||
<SelectMenu
|
items={cascaderItems}
|
||||||
id="lookup-add-module"
|
value={pick}
|
||||||
items={moduleItems}
|
onValueChange={setPick}
|
||||||
value={moduleId}
|
selectable={allowEmptyCommunity ? 'any' : 'leaf'}
|
||||||
placeholder="Выберите модуль"
|
mode="columns"
|
||||||
onValueChange={(v) => {
|
labels={{
|
||||||
if (v) setModuleId(v)
|
...CASCADER_LABELS_RU,
|
||||||
}}
|
rootLevel: 'Модули',
|
||||||
/>
|
}}
|
||||||
</Field>
|
>
|
||||||
<CommunitySelect
|
<CascaderTrigger
|
||||||
id="lookup-add-comm"
|
id="lookup-add-cascader"
|
||||||
label="Community"
|
className="w-full"
|
||||||
value={communityId}
|
aria-label="Модуль и community"
|
||||||
onValueChange={setCommunityId}
|
>
|
||||||
communities={communities}
|
<CascaderValue placeholder="Выберите модуль и community" display="path" />
|
||||||
nullable={data.query_kind === 'domain'}
|
</CascaderTrigger>
|
||||||
/>
|
<CascaderContent>
|
||||||
</div>
|
<CascaderInput />
|
||||||
|
<CascaderPanel />
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
</Field>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
|
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
Назад
|
Назад
|
||||||
@@ -180,7 +227,7 @@ export function LookupAddStep({
|
|||||||
<LoadingButton
|
<LoadingButton
|
||||||
loading={saving}
|
loading={saving}
|
||||||
onClick={() => void handleAdd()}
|
onClick={() => void handleAdd()}
|
||||||
disabled={!moduleId || (data.query_kind !== 'domain' && !communityId)}
|
disabled={!moduleId || (!allowEmptyCommunity && !communityId)}
|
||||||
>
|
>
|
||||||
Добавить
|
Добавить
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState } from 'react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { Boxes, SearchIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMonoCell, DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
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'
|
import type { LookupMatch } from '@/types/api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lookup matches grid — bare section for wizard-2 single Frame.
|
* Lookup matches grid — ResourcePage hideHeader inside wizard-2 Frame.
|
||||||
* @see https://reui.io/preview/base/wizard-2
|
* @see https://reui.io/preview/base/wizard-2
|
||||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
* @see https://reui.io/docs/components/base/badge
|
* @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({
|
export function LookupMatchesGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -24,8 +38,11 @@ export function LookupMatchesGrid({
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<LookupMatch>[]>(
|
const columns = useMemo<DataGridColumnDef<LookupMatch>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'layer',
|
accessorKey: 'layer',
|
||||||
@@ -44,10 +61,10 @@ export function LookupMatchesGrid({
|
|||||||
accessorKey: 'module_name',
|
accessorKey: 'module_name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridNameCell
|
||||||
|
icon={Boxes}
|
||||||
title={row.original.module_name}
|
title={row.original.module_name}
|
||||||
subtitle={row.original.module_type}
|
subtitle={row.original.module_type}
|
||||||
accent="primary"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Модуль' },
|
meta: { headerTitle: 'Модуль' },
|
||||||
@@ -56,15 +73,14 @@ export function LookupMatchesGrid({
|
|||||||
accessorKey: 'matched_value',
|
accessorKey: 'matched_value',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
title={row.original.matched_value}
|
<DataGridMonoCell>{row.original.matched_value}</DataGridMonoCell>
|
||||||
subtitle={
|
<DataGridMutedCell>
|
||||||
row.original.resolved_ip
|
{row.original.resolved_ip
|
||||||
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
|
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
|
||||||
: row.original.match_kind
|
: row.original.match_kind}
|
||||||
}
|
</DataGridMutedCell>
|
||||||
accent="mono"
|
</div>
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Совпадение' },
|
meta: { headerTitle: 'Совпадение' },
|
||||||
},
|
},
|
||||||
@@ -76,13 +92,15 @@ export function LookupMatchesGrid({
|
|||||||
const title = row.original.community_title?.trim()
|
const title = row.original.community_title?.trim()
|
||||||
const value = row.original.community?.trim()
|
const value = row.original.community?.trim()
|
||||||
if (!title && !value) {
|
if (!title && !value) {
|
||||||
return <span className="text-muted-foreground text-sm">—</span>
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<DataGridPrimaryCell
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
title={title || value || '—'}
|
<DataGridMonoCell>{title || value || '—'}</DataGridMonoCell>
|
||||||
subtitle={title && value && title !== value ? value : undefined}
|
{title && value && title !== value ? (
|
||||||
/>
|
<DataGridMutedCell>{value}</DataGridMutedCell>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
meta: { headerTitle: 'Community' },
|
meta: { headerTitle: 'Community' },
|
||||||
@@ -95,7 +113,7 @@ export function LookupMatchesGrid({
|
|||||||
row.original.source ? (
|
row.original.source ? (
|
||||||
<CategoryBadge>{row.original.source}</CategoryBadge>
|
<CategoryBadge>{row.original.source}</CategoryBadge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">—</span>
|
<DataGridMutedCell>—</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Источник' },
|
meta: { headerTitle: 'Источник' },
|
||||||
},
|
},
|
||||||
@@ -103,35 +121,29 @@ export function LookupMatchesGrid({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: items,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) =>
|
|
||||||
`${row.layer} ${row.module_name} ${row.module_type} ${row.matched_value} ${row.community ?? ''} ${row.community_title ?? ''} ${row.source ?? ''}`,
|
|
||||||
getRowId: (row) =>
|
|
||||||
`${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<ResourcePage
|
||||||
<div className="flex flex-col gap-px">
|
title="Совпадения"
|
||||||
<h3 className="text-sm font-semibold">Совпадения</h3>
|
description="Entries и snapshots · клик по строке открывает модуль"
|
||||||
<p className="text-muted-foreground text-sm">
|
hideHeader
|
||||||
Entries и snapshots · клик по строке открывает модуль
|
filterFields={filterFields}
|
||||||
</p>
|
filterQuery={filterQuery}
|
||||||
</div>
|
onFilterQueryChange={setFilterQuery}
|
||||||
<DataGridSection
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||||
table={table}
|
getFilterFieldValue={(item, field) => {
|
||||||
recordCount={filteredCount}
|
if (field !== 'search') return undefined
|
||||||
isLoading={isLoading}
|
return `${item.layer} ${item.module_name} ${item.module_type} ${item.matched_value} ${item.community ?? ''} ${item.community_title ?? ''} ${item.source ?? ''}`
|
||||||
emptyMessage="Нет совпадений"
|
}}
|
||||||
searchValue={globalFilter}
|
columns={columns}
|
||||||
onSearchChange={setGlobalFilter}
|
data={items}
|
||||||
searchPlaceholder="Фильтр совпадений…"
|
getRowId={(row) =>
|
||||||
onRowClick={(row) =>
|
`${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`
|
||||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
}
|
||||||
}
|
isLoading={isLoading}
|
||||||
/>
|
onRowClick={(row) =>
|
||||||
</div>
|
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
||||||
|
}
|
||||||
|
emptyState={{ title: 'Нет совпадений' }}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@ import { toast } from 'sonner'
|
|||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
||||||
|
import { Field, FieldDescription, FieldLabel } from '@evobgp/ui/components/field'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
|
||||||
|
|
||||||
import { FormDrawer } from '@/components/form-drawer'
|
import { FormDrawer } from '@/components/form-drawer'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
@@ -171,42 +171,42 @@ export function ModuleCreateDialog({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label htmlFor="mod-create-name">Название</Label>
|
<FieldLabel htmlFor="mod-create-name">Название</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="mod-create-name"
|
id="mod-create-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Имя модуля"
|
placeholder="Имя модуля"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
|
<Field orientation="horizontal">
|
||||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
<Label htmlFor="mod-create-enabled">Включён</Label>
|
<FieldLabel htmlFor="mod-create-enabled">Включён</FieldLabel>
|
||||||
<p className="text-xs text-muted-foreground">
|
<FieldDescription>
|
||||||
Выключенный модуль не участвует в обновлении и применении.
|
Выключенный модуль не участвует в обновлении и применении.
|
||||||
</p>
|
</FieldDescription>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="mod-create-enabled"
|
id="mod-create-enabled"
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
onCheckedChange={(v) => setEnabled(v === true)}
|
onCheckedChange={(v) => setEnabled(v === true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label htmlFor="mod-create-priority">Приоритет</Label>
|
<FieldLabel htmlFor="mod-create-priority">Приоритет</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="mod-create-priority"
|
id="mod-create-priority"
|
||||||
type="number"
|
type="number"
|
||||||
value={priority}
|
value={priority}
|
||||||
onChange={(e) => setPriority(e.target.value)}
|
onChange={(e) => setPriority(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
|
<FieldLabel htmlFor="mod-create-interval">Интервал обновления (сек)</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="mod-create-interval"
|
id="mod-create-interval"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -215,17 +215,17 @@ export function ModuleCreateDialog({
|
|||||||
value={refreshIntervalSec}
|
value={refreshIntervalSec}
|
||||||
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
|
<FieldLabel htmlFor="mod-create-cron">Cron (опционально)</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="mod-create-cron"
|
id="mod-create-cron"
|
||||||
placeholder="0 * * * *"
|
placeholder="0 * * * *"
|
||||||
value={cronExpr}
|
value={cronExpr}
|
||||||
onChange={(e) => setCronExpr(e.target.value)}
|
onChange={(e) => setCronExpr(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<CommunitySelect
|
<CommunitySelect
|
||||||
id="mod-create-community"
|
id="mod-create-community"
|
||||||
@@ -248,8 +248,8 @@ export function ModuleCreateDialog({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label>DoH профили</Label>
|
<FieldLabel>DoH профили</FieldLabel>
|
||||||
{dohProfiles.length === 0 ? (
|
{dohProfiles.length === 0 ? (
|
||||||
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -281,7 +281,7 @@ export function ModuleCreateDialog({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Field>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</FormDrawer>
|
</FormDrawer>
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { Pencil, SearchIcon, Trash2 } from 'lucide-react'
|
||||||
import { Pencil, Trash2 } from 'lucide-react'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMonoCell, DataGridMutedCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
|
import {
|
||||||
|
ResourcePage,
|
||||||
|
createTextFilterQuery,
|
||||||
|
type DataGridColumnDef,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
import { formatDateTime } from '@/lib/modules/display'
|
import { formatDateTime } from '@/lib/modules/display'
|
||||||
import { communityLabel } from '@/lib/modules/helpers'
|
import { communityLabel } from '@/lib/modules/helpers'
|
||||||
import { cdnSourceKindRu } from '@/lib/ui-labels'
|
import { cdnSourceKindRu } from '@/lib/ui-labels'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import type {
|
import type {
|
||||||
AsEntry,
|
AsEntry,
|
||||||
BgpCommunity,
|
BgpCommunity,
|
||||||
@@ -27,6 +30,18 @@ type DeleteTarget =
|
|||||||
| { kind: 'cdn'; entry: CdnSource }
|
| { kind: 'cdn'; entry: CdnSource }
|
||||||
| { kind: 'as'; entry: AsEntry }
|
| { kind: 'as'; entry: AsEntry }
|
||||||
|
|
||||||
|
type EntryRow = DomainEntry | IpRangeEntry | CdnSource | AsEntry
|
||||||
|
|
||||||
|
const filterFields: FilterField[] = [
|
||||||
|
{
|
||||||
|
id: 'search',
|
||||||
|
label: 'Поиск',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Поиск записей…',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
|
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
@@ -47,6 +62,12 @@ function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () =>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchText(row: EntryRow): string {
|
||||||
|
return Object.values(row as Record<string, unknown>)
|
||||||
|
.filter((value) => typeof value === 'string' || typeof value === 'number')
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
export function ModuleEntriesGrid({
|
export function ModuleEntriesGrid({
|
||||||
mod,
|
mod,
|
||||||
rows,
|
rows,
|
||||||
@@ -54,6 +75,14 @@ export function ModuleEntriesGrid({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
isError = false,
|
||||||
|
error = null,
|
||||||
|
onRetry,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
emptyTitle = 'Нет записей',
|
||||||
|
emptyDescription,
|
||||||
}: {
|
}: {
|
||||||
mod: ModuleRow
|
mod: ModuleRow
|
||||||
rows: Record<string, unknown>[]
|
rows: Record<string, unknown>[]
|
||||||
@@ -61,198 +90,207 @@ export function ModuleEntriesGrid({
|
|||||||
onEdit: (target: DeleteTarget) => void
|
onEdit: (target: DeleteTarget) => void
|
||||||
onDelete: (target: DeleteTarget) => void
|
onDelete: (target: DeleteTarget) => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
isError?: boolean
|
||||||
|
error?: Error | null
|
||||||
|
onRetry?: () => void
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
}) {
|
}) {
|
||||||
const columns = useMemo(() => {
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumnDef<EntryRow>[]>(() => {
|
||||||
if (mod.type === 'DOMAINS') {
|
if (mod.type === 'DOMAINS') {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'fqdn',
|
accessorKey: 'fqdn',
|
||||||
header: ({ column }: { column: { id: string } }) => (
|
header: ({ column }) => <DataGridColumnHeader column={column} title="FQDN" />,
|
||||||
<DataGridColumnHeader column={column as never} title="FQDN" />
|
cell: ({ row }) => (
|
||||||
),
|
<DataGridMonoCell>{(row.original as DomainEntry).fqdn}</DataGridMonoCell>
|
||||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
|
||||||
<DataGridPrimaryCell title={row.original.fqdn} accent="mono" />
|
|
||||||
),
|
),
|
||||||
|
meta: { headerTitle: 'FQDN' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'community',
|
id: 'community',
|
||||||
header: 'Community',
|
header: 'Community',
|
||||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{communityLabel(row.original.community_id, communities)}
|
{communityLabel((row.original as DomainEntry).community_id, communities)}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<RowActions
|
<RowActions
|
||||||
onEdit={() => onEdit({ kind: 'domain', entry: row.original })}
|
onEdit={() => onEdit({ kind: 'domain', entry: row.original as DomainEntry })}
|
||||||
onDelete={() => onDelete({ kind: 'domain', entry: row.original })}
|
onDelete={() => onDelete({ kind: 'domain', entry: row.original as DomainEntry })}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] as ColumnDef<DomainEntry>[]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mod.type === 'IP_RANGES') {
|
if (mod.type === 'IP_RANGES') {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'prefix',
|
accessorKey: 'prefix',
|
||||||
header: ({ column }: { column: { id: string } }) => (
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс (CIDR)" />,
|
||||||
<DataGridColumnHeader column={column as never} title="Префикс (CIDR)" />
|
cell: ({ row }) => (
|
||||||
),
|
<DataGridMonoCell>{(row.original as IpRangeEntry).prefix}</DataGridMonoCell>
|
||||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
|
||||||
<DataGridPrimaryCell title={row.original.prefix} accent="mono" />
|
|
||||||
),
|
),
|
||||||
|
meta: { headerTitle: 'Префикс (CIDR)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'community',
|
id: 'community',
|
||||||
header: 'Community',
|
header: 'Community',
|
||||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{communityLabel(row.original.community_id, communities)}
|
{communityLabel((row.original as IpRangeEntry).community_id, communities)}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<RowActions
|
<RowActions
|
||||||
onEdit={() => onEdit({ kind: 'ip-range', entry: row.original })}
|
onEdit={() => onEdit({ kind: 'ip-range', entry: row.original as IpRangeEntry })}
|
||||||
onDelete={() => onDelete({ kind: 'ip-range', entry: row.original })}
|
onDelete={() => onDelete({ kind: 'ip-range', entry: row.original as IpRangeEntry })}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] as ColumnDef<IpRangeEntry>[]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mod.type === 'CDN_CIDRS') {
|
if (mod.type === 'CDN_CIDRS') {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'url',
|
accessorKey: 'url',
|
||||||
header: ({ column }: { column: { id: string } }) => (
|
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
|
||||||
<DataGridColumnHeader column={column as never} title="URL" />
|
cell: ({ row }) => (
|
||||||
),
|
<DataGridMonoCell className="max-w-xs">
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
{(row.original as CdnSource).url}
|
||||||
<DataGridPrimaryCell title={row.original.url} accent="mono" className="max-w-xs" />
|
</DataGridMonoCell>
|
||||||
),
|
),
|
||||||
|
meta: { headerTitle: 'URL' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'source_kind',
|
accessorKey: 'source_kind',
|
||||||
header: 'Тип',
|
header: 'Тип',
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
cell: ({ row }) => (
|
||||||
<CategoryBadge>{cdnSourceKindRu(row.original.source_kind)}</CategoryBadge>
|
<CategoryBadge>{cdnSourceKindRu((row.original as CdnSource).source_kind)}</CategoryBadge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'community',
|
id: 'community',
|
||||||
header: 'Community',
|
header: 'Community',
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{communityLabel(row.original.community_id, communities)}
|
{communityLabel((row.original as CdnSource).community_id, communities)}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'last_refreshed_at',
|
id: 'last_refreshed_at',
|
||||||
accessorFn: (row: CdnSource) => row.last_refreshed_at ?? '',
|
accessorFn: (row) => (row as CdnSource).last_refreshed_at ?? '',
|
||||||
header: ({ column }: { column: { id: string } }) => (
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||||
<DataGridColumnHeader column={column as never} title="Обновлено" />
|
cell: ({ row }) => (
|
||||||
),
|
<DataGridMutedCell>
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
{formatDateTime((row.original as CdnSource).last_refreshed_at)}
|
||||||
<DataGridMutedCell>{formatDateTime(row.original.last_refreshed_at)}</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
|
meta: { headerTitle: 'Обновлено' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
cell: ({ row }) => (
|
||||||
<RowActions
|
<RowActions
|
||||||
onEdit={() => onEdit({ kind: 'cdn', entry: row.original })}
|
onEdit={() => onEdit({ kind: 'cdn', entry: row.original as CdnSource })}
|
||||||
onDelete={() => onDelete({ kind: 'cdn', entry: row.original })}
|
onDelete={() => onDelete({ kind: 'cdn', entry: row.original as CdnSource })}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] as ColumnDef<CdnSource>[]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'asn',
|
accessorKey: 'asn',
|
||||||
header: ({ column }: { column: { id: string } }) => (
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||||
<DataGridColumnHeader column={column as never} title="ASN" />
|
cell: ({ row }) => <DataGridMonoCell>{(row.original as AsEntry).asn}</DataGridMonoCell>,
|
||||||
),
|
meta: { headerTitle: 'ASN' },
|
||||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
|
||||||
<span className="font-mono text-sm">{row.original.asn}</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'asn_name',
|
accessorKey: 'asn_name',
|
||||||
header: 'Имя',
|
header: 'Имя',
|
||||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm">{row.original.asn_name ?? '—'}</span>
|
<span className="text-sm">{(row.original as AsEntry).asn_name ?? '—'}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'prefix_count',
|
accessorKey: 'prefix_count',
|
||||||
header: 'Префиксов',
|
header: 'Префиксов',
|
||||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-sm">{row.original.prefix_count ?? '—'}</span>
|
<DataGridMonoCell>{(row.original as AsEntry).prefix_count ?? '—'}</DataGridMonoCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'community',
|
id: 'community',
|
||||||
header: 'Community',
|
header: 'Community',
|
||||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{communityLabel(row.original.community_id, communities)}
|
{communityLabel((row.original as AsEntry).community_id, communities)}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<RowActions
|
<RowActions
|
||||||
onEdit={() => onEdit({ kind: 'as', entry: row.original })}
|
onEdit={() => onEdit({ kind: 'as', entry: row.original as AsEntry })}
|
||||||
onDelete={() => onDelete({ kind: 'as', entry: row.original })}
|
onDelete={() => onDelete({ kind: 'as', entry: row.original as AsEntry })}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] as ColumnDef<AsEntry>[]
|
]
|
||||||
}, [communities, mod.type, onDelete, onEdit])
|
}, [communities, mod.type, onDelete, onEdit])
|
||||||
|
|
||||||
type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry
|
const data = rows as unknown as EntryRow[]
|
||||||
const data = rows as unknown as RowType[]
|
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data,
|
|
||||||
columns: columns as ColumnDef<RowType>[],
|
|
||||||
getSearchText: (row) => {
|
|
||||||
const r = row as Record<string, unknown>
|
|
||||||
return Object.values(r)
|
|
||||||
.filter((v) => typeof v === 'string' || typeof v === 'number')
|
|
||||||
.join(' ')
|
|
||||||
},
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title={title}
|
||||||
recordCount={filteredCount}
|
description={description}
|
||||||
|
filterFields={filterFields}
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
onFilterQueryChange={setFilterQuery}
|
||||||
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||||
|
getFilterFieldValue={(item, field) => {
|
||||||
|
if (field !== 'search') return undefined
|
||||||
|
return searchText(item)
|
||||||
|
}}
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет записей"
|
isError={isError}
|
||||||
searchValue={globalFilter}
|
error={error}
|
||||||
onSearchChange={setGlobalFilter}
|
onRetry={onRetry}
|
||||||
searchPlaceholder="Поиск записей…"
|
primaryAction={actions}
|
||||||
|
pinLastColumn
|
||||||
|
emptyState={{ title: emptyTitle, description: emptyDescription, action: actions }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import { toast } from 'sonner'
|
|||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { FrameDataGrid } from '@/components/reui-kit'
|
|
||||||
import { QueryState } from '@/components/query-state'
|
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
|
||||||
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
|
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
|
||||||
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
|
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
|
||||||
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
|
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
|
||||||
@@ -126,47 +123,37 @@ export function ModuleEntriesSection({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addButton = (
|
||||||
|
<Button size="sm" type="button" onClick={openCreate}>
|
||||||
|
<Plus />
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FrameDataGrid
|
<ModuleEntriesGrid
|
||||||
|
mod={mod}
|
||||||
|
rows={items}
|
||||||
|
communities={communities}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
onRetry={onRetry}
|
||||||
title={meta.title}
|
title={meta.title}
|
||||||
description={meta.description}
|
description={meta.description}
|
||||||
actions={
|
actions={addButton}
|
||||||
<Button size="sm" type="button" onClick={openCreate}>
|
emptyTitle={meta.emptyTitle}
|
||||||
<Plus />
|
emptyDescription={meta.emptyDescription}
|
||||||
Добавить
|
onEdit={(target) => {
|
||||||
</Button>
|
if (target.kind === 'domain') setEditDomain(target.entry)
|
||||||
}
|
if (target.kind === 'ip-range') setEditIpRange(target.entry)
|
||||||
>
|
if (target.kind === 'cdn') setEditCdn(target.entry)
|
||||||
<QueryState
|
if (target.kind === 'as') setEditAs(target.entry)
|
||||||
data={items}
|
setDialogOpen(true)
|
||||||
isLoading={isLoading}
|
}}
|
||||||
isError={isError}
|
onDelete={setDeleteTarget}
|
||||||
error={error}
|
/>
|
||||||
empty={items.length === 0}
|
|
||||||
emptyTitle={meta.emptyTitle}
|
|
||||||
emptyDescription={meta.emptyDescription}
|
|
||||||
skeleton={<TableSkeleton rows={5} cols={3} />}
|
|
||||||
onRetry={onRetry}
|
|
||||||
>
|
|
||||||
{(rows) => (
|
|
||||||
<ModuleEntriesGrid
|
|
||||||
mod={mod}
|
|
||||||
rows={rows}
|
|
||||||
communities={communities}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onEdit={(target) => {
|
|
||||||
if (target.kind === 'domain') setEditDomain(target.entry)
|
|
||||||
if (target.kind === 'ip-range') setEditIpRange(target.entry)
|
|
||||||
if (target.kind === 'cdn') setEditCdn(target.entry)
|
|
||||||
if (target.kind === 'as') setEditAs(target.entry)
|
|
||||||
setDialogOpen(true)
|
|
||||||
}}
|
|
||||||
onDelete={setDeleteTarget}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</FrameDataGrid>
|
|
||||||
|
|
||||||
{mod.type === 'DOMAINS' ? (
|
{mod.type === 'DOMAINS' ? (
|
||||||
<ModuleDomainEntryDialog
|
<ModuleDomainEntryDialog
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
|
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
|
||||||
|
|
||||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
||||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||||
|
|||||||
@@ -1,35 +1,91 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { Boxes } from 'lucide-react'
|
import { Boxes, SearchIcon } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
|
import {
|
||||||
|
ResourcePage,
|
||||||
|
createTextFilterQuery,
|
||||||
|
renderSingleSelectedLabel,
|
||||||
|
type DataGridColumnDef,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||||
import type { ModuleRow } from '@/types/api'
|
import type { ModuleRow } from '@/types/api'
|
||||||
|
|
||||||
|
const MODULE_TABS = [
|
||||||
|
{ id: 'all', label: 'Все' },
|
||||||
|
{ id: 'enabled', label: 'Вкл' },
|
||||||
|
{ id: 'disabled', label: 'Выкл' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
|
||||||
|
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
|
||||||
|
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
|
||||||
|
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filterFields: FilterField[] = [
|
||||||
|
{
|
||||||
|
id: 'search',
|
||||||
|
label: 'Поиск',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Название или тип…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'type',
|
||||||
|
label: 'Тип',
|
||||||
|
type: 'select',
|
||||||
|
searchable: false,
|
||||||
|
options: TYPE_OPTIONS,
|
||||||
|
renderValue: ({ values }) => renderSingleSelectedLabel(values, TYPE_OPTIONS),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function getFilterFieldValue(item: ModuleRow, field: string): unknown {
|
||||||
|
switch (field) {
|
||||||
|
case 'search':
|
||||||
|
return `${item.name} ${item.type} ${moduleTypeRu(item.type)} ${item.enabled ? 'включён' : 'выключен'}`
|
||||||
|
case 'type':
|
||||||
|
return item.type
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabFilter(item: ModuleRow, tabId: string): boolean {
|
||||||
|
if (tabId === 'enabled') return item.enabled !== false
|
||||||
|
if (tabId === 'disabled') return item.enabled === false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function ModulesListGrid({
|
export function ModulesListGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
title = 'Все модули',
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
items: ModuleRow[]
|
items: ModuleRow[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
title?: string
|
||||||
|
actions?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<ModuleRow>[]>(
|
const columns = useMemo<DataGridColumnDef<ModuleRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
<DataGridNameCell icon={Boxes} title={row.original.name} className="max-w-[280px]" />
|
||||||
<Boxes className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[280px]" />
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Название' },
|
meta: { headerTitle: 'Название' },
|
||||||
},
|
},
|
||||||
@@ -65,34 +121,32 @@ export function ModulesListGrid({
|
|||||||
: '—'}
|
: '—'}
|
||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
sortingFn: (a, b) => {
|
|
||||||
const av = a.original.last_refreshed_at ?? ''
|
|
||||||
const bv = b.original.last_refreshed_at ?? ''
|
|
||||||
return av.localeCompare(bv)
|
|
||||||
},
|
|
||||||
meta: { headerTitle: 'Обновлено' },
|
meta: { headerTitle: 'Обновлено' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: items,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'включён' : 'выключен'}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title={title}
|
||||||
recordCount={filteredCount}
|
description="Маршрутные списки"
|
||||||
|
tabs={MODULE_TABS}
|
||||||
|
tabFilter={tabFilter}
|
||||||
|
filterFields={filterFields}
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
onFilterQueryChange={setFilterQuery}
|
||||||
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||||
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет модулей"
|
primaryAction={actions}
|
||||||
searchValue={globalFilter}
|
onRowClick={(row) =>
|
||||||
onSearchChange={setGlobalFilter}
|
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })
|
||||||
searchPlaceholder="Поиск модулей…"
|
}
|
||||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
emptyState={{ title: 'Нет модулей' }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
|
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
|
||||||
import {
|
import {
|
||||||
isReadyCheckOk,
|
isReadyCheckOk,
|
||||||
isSystemReady,
|
isSystemReady,
|
||||||
@@ -14,8 +12,6 @@ import {
|
|||||||
} from '@/lib/metrics'
|
} from '@/lib/metrics'
|
||||||
import { readyCheckRu } from '@/lib/ui-labels'
|
import { readyCheckRu } from '@/lib/ui-labels'
|
||||||
import type { ReadyStatus } from '@/queries/monitoring'
|
import type { ReadyStatus } from '@/queries/monitoring'
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
|
|
||||||
const READY_CHECK_ICONS: Record<string, typeof Database> = {
|
const READY_CHECK_ICONS: Record<string, typeof Database> = {
|
||||||
postgres: Database,
|
postgres: Database,
|
||||||
@@ -79,32 +75,19 @@ export function MonitoringReadyGrid({
|
|||||||
return rows
|
return rows
|
||||||
}, [health?.ok, ready.checks, ready.status])
|
}, [health?.ok, ready.checks, ready.status])
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<ReadyCheckRow>[]>(
|
const columns = useMemo<DataGridColumnDef<ReadyCheckRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'label',
|
accessorKey: 'label',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Проверка" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Проверка" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const Icon = row.original.icon
|
<DataGridNameCell
|
||||||
return (
|
icon={row.original.icon}
|
||||||
<div className="flex min-w-0 items-center gap-2.5 py-0.5">
|
title={row.original.label}
|
||||||
<Item
|
subtitle={row.original.subtitle}
|
||||||
className={cn(
|
iconClassName={row.original.iconClassName}
|
||||||
'border-background bg-muted flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-3.5',
|
/>
|
||||||
row.original.iconClassName,
|
),
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
|
||||||
<Icon />
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
<DataGridPrimaryCell
|
|
||||||
title={row.original.label}
|
|
||||||
subtitle={row.original.subtitle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
meta: { headerTitle: 'Проверка' },
|
meta: { headerTitle: 'Проверка' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -112,9 +95,7 @@ export function MonitoringReadyGrid({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: 'Статус',
|
header: 'Статус',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex justify-start">
|
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
||||||
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Статус' },
|
meta: { headerTitle: 'Статус' },
|
||||||
},
|
},
|
||||||
@@ -122,23 +103,15 @@ export function MonitoringReadyGrid({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) => `${row.label} ${row.subtitle ?? ''} ${row.statusLabel}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
pageSize: 20,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<FrameDataGrid
|
||||||
table={table}
|
title="Доступность и готовность"
|
||||||
recordCount={filteredCount}
|
description="Проверки живучести и готовности (/v1/health, /v1/ready)"
|
||||||
showPagination={false}
|
columns={columns}
|
||||||
emptyMessage="Нет проверок"
|
data={data}
|
||||||
searchValue={globalFilter}
|
rowId={(row) => row.id}
|
||||||
onSearchChange={setGlobalFilter}
|
emptyTitle="Нет проверок"
|
||||||
searchPlaceholder="Поиск проверок…"
|
pagination={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import { CheckIcon, Network, SearchIcon, XIcon } from 'lucide-react'
|
||||||
import { CheckIcon, SearchIcon, XIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { FormDrawer } from '@/components/form-drawer'
|
import { FormDrawer } from '@/components/form-drawer'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
import {
|
import {
|
||||||
createFilter,
|
ResourcePage,
|
||||||
type Filter,
|
createTextFilterQuery,
|
||||||
type FilterFieldConfig,
|
type DataGridColumnDef,
|
||||||
} from '@/components/reui/filters'
|
} from '@/components/reui-kit'
|
||||||
import { ResourcePage } from '@/components/reui-kit'
|
|
||||||
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||||
import {
|
import {
|
||||||
useApproveDiscoveredPeerMutation,
|
useApproveDiscoveredPeerMutation,
|
||||||
@@ -40,17 +39,12 @@ function speakerLabel(s: SpeakerRow): string {
|
|||||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultFilters(): Filter[] {
|
const filterFields: FilterField[] = [
|
||||||
return [createFilter('neighbor', 'contains', [''])]
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterFields: FilterFieldConfig[] = [
|
|
||||||
{
|
{
|
||||||
key: 'neighbor',
|
id: 'neighbor',
|
||||||
label: 'Сосед',
|
label: 'Сосед',
|
||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-48',
|
|
||||||
placeholder: 'IP или ID соседа…',
|
placeholder: 'IP или ID соседа…',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -74,7 +68,9 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
}: NetworkDiscoveredPeersCardProps) {
|
}: NetworkDiscoveredPeersCardProps) {
|
||||||
const approveMutation = useApproveDiscoveredPeerMutation()
|
const approveMutation = useApproveDiscoveredPeerMutation()
|
||||||
const rejectMutation = useRejectDiscoveredPeerMutation()
|
const rejectMutation = useRejectDiscoveredPeerMutation()
|
||||||
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('neighbor'),
|
||||||
|
)
|
||||||
const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null)
|
const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null)
|
||||||
const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null)
|
const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
@@ -88,7 +84,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
[speakers],
|
[speakers],
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<PeerDiscoveryRow, unknown>[]>(
|
const columns = useMemo<DataGridColumnDef<PeerDiscoveryRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: 'neighbor_id',
|
id: 'neighbor_id',
|
||||||
@@ -97,10 +93,10 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
<DataGridColumnHeader column={column} title="ID соседа" />
|
<DataGridColumnHeader column={column} title="ID соседа" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridNameCell
|
||||||
|
icon={Network}
|
||||||
title={row.original.neighbor_id || '—'}
|
title={row.original.neighbor_id || '—'}
|
||||||
subtitle={row.original.neighbor}
|
subtitle={row.original.neighbor}
|
||||||
accent="mono"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'ID соседа' },
|
meta: { headerTitle: 'ID соседа' },
|
||||||
@@ -109,7 +105,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
accessorKey: 'remote_asn',
|
accessorKey: 'remote_asn',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-xs">{row.original.remote_asn || '—'}</span>
|
<DataGridMonoCell className="text-xs">{row.original.remote_asn || '—'}</DataGridMonoCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'ASN' },
|
meta: { headerTitle: 'ASN' },
|
||||||
},
|
},
|
||||||
@@ -186,9 +182,9 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
title="На одобрение"
|
title="На одобрение"
|
||||||
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
|
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
|
||||||
filterFields={filterFields}
|
filterFields={filterFields}
|
||||||
filters={filters}
|
filterQuery={filterQuery}
|
||||||
onFiltersChange={setFilters}
|
onFilterQueryChange={setFilterQuery}
|
||||||
onClearFilters={() => setFilters(createDefaultFilters())}
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('neighbor'))}
|
||||||
getFilterFieldValue={getFilterFieldValue}
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
@@ -197,6 +193,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
isError={isError}
|
isError={isError}
|
||||||
error={error instanceof Error ? error : null}
|
error={error instanceof Error ? error : null}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
|
pinLastColumn
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет ожидающих пиров',
|
title: 'Нет ожидающих пиров',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { Plus, SearchIcon, ActivityIcon, Trash2 } from 'lucide-react'
|
import { Plus, SearchIcon, ActivityIcon, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
@@ -11,12 +10,13 @@ import {
|
|||||||
peerColumns,
|
peerColumns,
|
||||||
peerTabFilter,
|
peerTabFilter,
|
||||||
} from '@/components/network/network-peers-grid'
|
} from '@/components/network/network-peers-grid'
|
||||||
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
import {
|
import {
|
||||||
createFilter,
|
ResourcePage,
|
||||||
type Filter,
|
createTextFilterQuery,
|
||||||
type FilterFieldConfig,
|
renderSingleSelectedLabel,
|
||||||
} from '@/components/reui/filters'
|
type DataGridColumnDef,
|
||||||
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { useDeletePeerMutation } from '@/queries/network'
|
import { useDeletePeerMutation } from '@/queries/network'
|
||||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
@@ -46,37 +46,29 @@ const SESSION_STATE_OPTIONS = [
|
|||||||
{ value: 'OpenConfirm', label: 'Open подтверждён' },
|
{ value: 'OpenConfirm', label: 'Open подтверждён' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDefaultPeerFilters(): Filter[] {
|
const peerFilterFields: FilterField[] = [
|
||||||
return [createFilter('name', 'contains', [''])]
|
|
||||||
}
|
|
||||||
|
|
||||||
const peerFilterFields: FilterFieldConfig[] = [
|
|
||||||
{
|
{
|
||||||
key: 'name',
|
id: 'name',
|
||||||
label: 'Имя',
|
label: 'Имя',
|
||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-48',
|
|
||||||
placeholder: 'Поиск по имени…',
|
placeholder: 'Поиск по имени…',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'neighbor',
|
id: 'neighbor',
|
||||||
label: 'Сосед',
|
label: 'Сосед',
|
||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-48',
|
|
||||||
placeholder: 'Адрес соседа…',
|
placeholder: 'Адрес соседа…',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'session_state',
|
id: 'session_state',
|
||||||
label: 'Состояние',
|
label: 'Состояние',
|
||||||
icon: <ActivityIcon className="size-3.5" aria-hidden />,
|
icon: <ActivityIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
searchable: false,
|
searchable: false,
|
||||||
className: 'w-[160px]',
|
|
||||||
options: SESSION_STATE_OPTIONS,
|
options: SESSION_STATE_OPTIONS,
|
||||||
customValueRenderer: (values) =>
|
renderValue: ({ values }) => renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
|
||||||
renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -100,7 +92,7 @@ export function NetworkPeersCard({
|
|||||||
const deleteMutation = useDeletePeerMutation()
|
const deleteMutation = useDeletePeerMutation()
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<PeerRow | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<PeerRow | null>(null)
|
||||||
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() => createTextFilterQuery('name'))
|
||||||
|
|
||||||
const addButton = useMemo(
|
const addButton = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -112,7 +104,7 @@ export function NetworkPeersCard({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<PeerRow, unknown>[]>(
|
const columns = useMemo<DataGridColumnDef<PeerRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
...peerColumns,
|
...peerColumns,
|
||||||
{
|
{
|
||||||
@@ -147,9 +139,9 @@ export function NetworkPeersCard({
|
|||||||
tabs={PEER_TABS}
|
tabs={PEER_TABS}
|
||||||
tabFilter={peerTabFilter}
|
tabFilter={peerTabFilter}
|
||||||
filterFields={peerFilterFields}
|
filterFields={peerFilterFields}
|
||||||
filters={filters}
|
filterQuery={filterQuery}
|
||||||
onFiltersChange={setFilters}
|
onFilterQueryChange={setFilterQuery}
|
||||||
onClearFilters={() => setFilters(createDefaultPeerFilters())}
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('name'))}
|
||||||
getFilterFieldValue={getPeerFilterFieldValue}
|
getFilterFieldValue={getPeerFilterFieldValue}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
@@ -159,6 +151,7 @@ export function NetworkPeersCard({
|
|||||||
error={error instanceof Error ? error : null}
|
error={error instanceof Error ? error : null}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
primaryAction={addButton}
|
primaryAction={addButton}
|
||||||
|
pinLastColumn
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет пиров',
|
title: 'Нет пиров',
|
||||||
description: 'Добавьте первого BGP-соседа.',
|
description: 'Добавьте первого BGP-соседа.',
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import { Share2 } from 'lucide-react'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import type { DataGridColumnDef } from '@/components/reui-kit'
|
||||||
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||||
import type { PeerRow } from '@/types/api'
|
import type { PeerRow } from '@/types/api'
|
||||||
|
|
||||||
export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
|
export const peerColumns: DataGridColumnDef<PeerRow>[] = [
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row) => row.name ?? row.neighbor,
|
accessorFn: (row) => row.name ?? row.neighbor,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridNameCell
|
||||||
|
icon={Share2}
|
||||||
title={row.original.name ?? row.original.neighbor}
|
title={row.original.name ?? row.original.neighbor}
|
||||||
subtitle={row.original.name ? row.original.neighbor : undefined}
|
subtitle={row.original.name ? row.original.neighbor : undefined}
|
||||||
accent="primary"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Имя' },
|
meta: { headerTitle: 'Имя' },
|
||||||
@@ -24,16 +25,14 @@ export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'neighbor',
|
accessorKey: 'neighbor',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <DataGridMonoCell>{row.original.neighbor}</DataGridMonoCell>,
|
||||||
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Адрес соседа' },
|
meta: { headerTitle: 'Адрес соседа' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'remote_asn',
|
accessorKey: 'remote_asn',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
|
<DataGridMonoCell className="text-xs">{row.original.remote_asn ?? '—'}</DataGridMonoCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'ASN' },
|
meta: { headerTitle: 'ASN' },
|
||||||
},
|
},
|
||||||
@@ -58,7 +57,7 @@ export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
|
|||||||
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
|
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'name':
|
case 'name':
|
||||||
return item.name ?? item.neighbor
|
return `${item.name ?? ''} ${item.neighbor} ${item.remote_asn ?? ''}`
|
||||||
case 'neighbor':
|
case 'neighbor':
|
||||||
return item.neighbor
|
return item.neighbor
|
||||||
case 'session_state':
|
case 'session_state':
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { Plus, SearchIcon, TagIcon, Trash2 } from 'lucide-react'
|
import { Plus, SearchIcon, TagIcon, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
@@ -11,12 +10,13 @@ import {
|
|||||||
speakerColumns,
|
speakerColumns,
|
||||||
speakerTabFilter,
|
speakerTabFilter,
|
||||||
} from '@/components/network/network-speakers-grid'
|
} from '@/components/network/network-speakers-grid'
|
||||||
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
import {
|
import {
|
||||||
createFilter,
|
ResourcePage,
|
||||||
type Filter,
|
createTextFilterQuery,
|
||||||
type FilterFieldConfig,
|
renderSingleSelectedLabel,
|
||||||
} from '@/components/reui/filters'
|
type DataGridColumnDef,
|
||||||
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { useDeleteSpeakerMutation } from '@/queries/network'
|
import { useDeleteSpeakerMutation } from '@/queries/network'
|
||||||
import type { SpeakerRow } from '@/types/api'
|
import type { SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
@@ -36,34 +36,26 @@ const SPEAKER_TABS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
{ value: 'primary', label: 'Основной' },
|
{ value: 'replica', label: 'Реплика' },
|
||||||
{ value: 'secondary', label: 'Резервный' },
|
{ value: 'master', label: 'Мастер' },
|
||||||
{ value: 'speaker', label: 'Спикер' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDefaultSpeakerFilters(): Filter[] {
|
const speakerFilterFields: FilterField[] = [
|
||||||
return [createFilter('endpoint', 'contains', [''])]
|
|
||||||
}
|
|
||||||
|
|
||||||
const speakerFilterFields: FilterFieldConfig[] = [
|
|
||||||
{
|
{
|
||||||
key: 'endpoint',
|
id: 'endpoint',
|
||||||
label: 'Конечная точка',
|
label: 'Конечная точка',
|
||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-52',
|
|
||||||
placeholder: 'Адрес агента…',
|
placeholder: 'Адрес агента…',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'role',
|
id: 'role',
|
||||||
label: 'Роль',
|
label: 'Роль',
|
||||||
icon: <TagIcon className="size-3.5" aria-hidden />,
|
icon: <TagIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
searchable: false,
|
searchable: false,
|
||||||
className: 'w-[140px]',
|
|
||||||
options: ROLE_OPTIONS,
|
options: ROLE_OPTIONS,
|
||||||
customValueRenderer: (values) =>
|
renderValue: ({ values }) => renderSingleSelectedLabel(values, ROLE_OPTIONS),
|
||||||
renderSingleSelectedLabel(values, ROLE_OPTIONS),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -85,7 +77,9 @@ export function NetworkSpeakersCard({
|
|||||||
const deleteMutation = useDeleteSpeakerMutation()
|
const deleteMutation = useDeleteSpeakerMutation()
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<SpeakerRow | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<SpeakerRow | null>(null)
|
||||||
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('endpoint'),
|
||||||
|
)
|
||||||
|
|
||||||
const addButton = useMemo(
|
const addButton = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -97,7 +91,7 @@ export function NetworkSpeakersCard({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<SpeakerRow, unknown>[]>(
|
const columns = useMemo<DataGridColumnDef<SpeakerRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
...speakerColumns,
|
...speakerColumns,
|
||||||
{
|
{
|
||||||
@@ -132,9 +126,9 @@ export function NetworkSpeakersCard({
|
|||||||
tabs={SPEAKER_TABS}
|
tabs={SPEAKER_TABS}
|
||||||
tabFilter={speakerTabFilter}
|
tabFilter={speakerTabFilter}
|
||||||
filterFields={speakerFilterFields}
|
filterFields={speakerFilterFields}
|
||||||
filters={filters}
|
filterQuery={filterQuery}
|
||||||
onFiltersChange={setFilters}
|
onFilterQueryChange={setFilterQuery}
|
||||||
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('endpoint'))}
|
||||||
getFilterFieldValue={getSpeakerFilterFieldValue}
|
getFilterFieldValue={getSpeakerFilterFieldValue}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
@@ -144,6 +138,7 @@ export function NetworkSpeakersCard({
|
|||||||
error={error instanceof Error ? error : null}
|
error={error instanceof Error ? error : null}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
primaryAction={addButton}
|
primaryAction={addButton}
|
||||||
|
pinLastColumn
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет спикеров',
|
title: 'Нет спикеров',
|
||||||
description: 'Добавьте первого BIRD-агента на ноде.',
|
description: 'Добавьте первого BIRD-агента на ноде.',
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import { ServerCog } from 'lucide-react'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import type { DataGridColumnDef } from '@/components/reui-kit'
|
||||||
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
|
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
|
||||||
import type { SpeakerRow } from '@/types/api'
|
import type { SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'endpoint',
|
accessorKey: 'endpoint',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
<DataGridNameCell
|
||||||
|
icon={ServerCog}
|
||||||
|
title={row.original.agent_domain ?? row.original.endpoint}
|
||||||
|
subtitle={row.original.agent_domain ? row.original.endpoint : undefined}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Конечная точка' },
|
meta: { headerTitle: 'Конечная точка' },
|
||||||
},
|
},
|
||||||
@@ -51,9 +56,9 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
|||||||
const live = row.original.live
|
const live = row.original.live
|
||||||
if (!live) return '—'
|
if (!live) return '—'
|
||||||
return (
|
return (
|
||||||
<span className="text-xs">
|
<DataGridMonoCell className="text-xs">
|
||||||
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
||||||
</span>
|
</DataGridMonoCell>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
meta: { headerTitle: 'BGP' },
|
meta: { headerTitle: 'BGP' },
|
||||||
@@ -63,7 +68,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
|||||||
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
|
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'endpoint':
|
case 'endpoint':
|
||||||
return item.endpoint
|
return `${item.endpoint} ${item.agent_domain ?? ''} ${item.id}`
|
||||||
case 'role':
|
case 'role':
|
||||||
return item.role
|
return item.role
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Copy, TriangleAlert } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
|
import { Textarea } from '@evobgp/ui/components/textarea'
|
||||||
|
|
||||||
import { FormDrawer } from '@/components/form-drawer'
|
import { FormDrawer } from '@/components/form-drawer'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { useCreateSpeakerMutation } from '@/queries/network'
|
import { useCreateSpeakerMutation } from '@/queries/network'
|
||||||
import type { BgpSpeakerCreate } from '@/types/api'
|
import type { BgpSpeakerCreate, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
interface SpeakerFormDialogProps {
|
interface SpeakerFormDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -35,8 +39,20 @@ function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string)
|
|||||||
return JSON.stringify(meta)
|
return JSON.stringify(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tlsIncomplete(
|
||||||
|
agentDomain: string,
|
||||||
|
letsencryptEmail: string,
|
||||||
|
cfToken: string,
|
||||||
|
panelIP: string,
|
||||||
|
): boolean {
|
||||||
|
return !agentDomain.trim() || !letsencryptEmail.trim() || !cfToken.trim() || !panelIP.trim()
|
||||||
|
}
|
||||||
|
|
||||||
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
|
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
|
||||||
const createMutation = useCreateSpeakerMutation()
|
const createMutation = useCreateSpeakerMutation()
|
||||||
|
const { isCopied, copyToClipboard } = useCopyToClipboard({
|
||||||
|
onCopy: () => toast.success('Команда скопирована'),
|
||||||
|
})
|
||||||
|
|
||||||
const [endpoint, setEndpoint] = useState('')
|
const [endpoint, setEndpoint] = useState('')
|
||||||
const [role, setRole] = useState('replica')
|
const [role, setRole] = useState('replica')
|
||||||
@@ -44,6 +60,13 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
const [nodeIpv4, setNodeIpv4] = useState('')
|
const [nodeIpv4, setNodeIpv4] = useState('')
|
||||||
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
|
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
|
||||||
const [bgpSourceManual, setBgpSourceManual] = useState(false)
|
const [bgpSourceManual, setBgpSourceManual] = useState(false)
|
||||||
|
const [letsencryptEmail, setLetsencryptEmail] = useState('')
|
||||||
|
const [cfDnsToken, setCfDnsToken] = useState('')
|
||||||
|
const [panelIP, setPanelIP] = useState('')
|
||||||
|
const [created, setCreated] = useState<SpeakerRow | null>(null)
|
||||||
|
|
||||||
|
const isReplica = role === 'replica'
|
||||||
|
const installCommands = created?.install?.docker_commands ?? ''
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -53,6 +76,10 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
setNodeIpv4('')
|
setNodeIpv4('')
|
||||||
setBgpSourceIpv4('')
|
setBgpSourceIpv4('')
|
||||||
setBgpSourceManual(false)
|
setBgpSourceManual(false)
|
||||||
|
setLetsencryptEmail('')
|
||||||
|
setCfDnsToken('')
|
||||||
|
setPanelIP('')
|
||||||
|
setCreated(null)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
function handleEndpointChange(value: string) {
|
function handleEndpointChange(value: string) {
|
||||||
@@ -81,82 +108,211 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
endpoint: ep,
|
endpoint: ep,
|
||||||
role: role.trim() || 'replica',
|
role: role.trim() || 'replica',
|
||||||
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
|
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
|
||||||
|
control_plane_url: window.location.origin,
|
||||||
|
}
|
||||||
|
if (isReplica) {
|
||||||
|
if (letsencryptEmail.trim()) body.letsencrypt_email = letsencryptEmail.trim()
|
||||||
|
if (cfDnsToken.trim()) body.cf_dns_api_token = cfDnsToken.trim()
|
||||||
|
if (panelIP.trim()) body.panel_ip_whitelist = panelIP.trim()
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createMutation.mutateAsync(body)
|
const row = await createMutation.mutateAsync(body)
|
||||||
|
if (row.install?.docker_commands) {
|
||||||
|
setCreated(row)
|
||||||
|
return
|
||||||
|
}
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} catch {
|
} catch {
|
||||||
// toast in mutation
|
// toast in mutation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showingInstall = created !== null && Boolean(installCommands)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormDrawer
|
<FormDrawer
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title="Новый спикер"
|
title={showingInstall ? 'Установка на ноду' : 'Новый спикер'}
|
||||||
description="BIRD-агент на ноде реплики или плоскости управления"
|
description={
|
||||||
className="sm:max-w-md"
|
showingInstall
|
||||||
|
? 'Секреты показываются один раз. Скопируйте команду на VPS реплики.'
|
||||||
|
: 'BIRD-агент на ноде реплики или плоскости управления'
|
||||||
|
}
|
||||||
|
className={showingInstall ? 'sm:max-w-2xl' : 'sm:max-w-md'}
|
||||||
footer={
|
footer={
|
||||||
<>
|
showingInstall ? (
|
||||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
<>
|
||||||
Отмена
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
type="button"
|
||||||
Создать
|
onClick={() => copyToClipboard(installCommands)}
|
||||||
</LoadingButton>
|
>
|
||||||
</>
|
<Copy />
|
||||||
|
{isCopied ? 'Скопировано' : 'Копировать команду'}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
Готово
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
||||||
|
Создать
|
||||||
|
</LoadingButton>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2">
|
{created && installCommands ? (
|
||||||
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
<SpeakerInstallStep created={created} commands={installCommands} />
|
||||||
<Input
|
) : (
|
||||||
id="speaker-endpoint"
|
<>
|
||||||
placeholder="https://node.example.com:8443"
|
<div className="flex flex-col gap-2">
|
||||||
value={endpoint}
|
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
||||||
onChange={(e) => handleEndpointChange(e.target.value)}
|
<Input
|
||||||
/>
|
id="speaker-endpoint"
|
||||||
</div>
|
placeholder="https://node.example.com"
|
||||||
<SelectField
|
value={endpoint}
|
||||||
id="speaker-role"
|
onChange={(e) => handleEndpointChange(e.target.value)}
|
||||||
label="Роль"
|
/>
|
||||||
items={[
|
</div>
|
||||||
{ value: 'replica', label: 'Реплика' },
|
<SelectField
|
||||||
{ value: 'master', label: 'Мастер (плоскость)' },
|
id="speaker-role"
|
||||||
]}
|
label="Роль"
|
||||||
value={role}
|
items={[
|
||||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
{ value: 'replica', label: 'Реплика' },
|
||||||
/>
|
{ value: 'master', label: 'Основной' },
|
||||||
<div className="flex flex-col gap-2">
|
]}
|
||||||
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
value={role}
|
||||||
<Input
|
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||||
id="speaker-agent-domain"
|
/>
|
||||||
placeholder="bird-agent.example.com"
|
<div className="flex flex-col gap-2">
|
||||||
value={agentDomain}
|
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
||||||
onChange={(e) => setAgentDomain(e.target.value)}
|
<Input
|
||||||
/>
|
id="speaker-agent-domain"
|
||||||
</div>
|
placeholder="bird-agent.example.com"
|
||||||
<div className="flex flex-col gap-2">
|
value={agentDomain}
|
||||||
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
onChange={(e) => setAgentDomain(e.target.value)}
|
||||||
<Input
|
/>
|
||||||
id="speaker-node-ipv4"
|
</div>
|
||||||
placeholder="203.0.113.10"
|
<div className="flex flex-col gap-2">
|
||||||
value={nodeIpv4}
|
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
||||||
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
<Input
|
||||||
/>
|
id="speaker-node-ipv4"
|
||||||
</div>
|
placeholder="203.0.113.10"
|
||||||
<div className="flex flex-col gap-2">
|
value={nodeIpv4}
|
||||||
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
||||||
<Input
|
/>
|
||||||
id="speaker-bgp-source"
|
</div>
|
||||||
placeholder="203.0.113.10"
|
<div className="flex flex-col gap-2">
|
||||||
value={bgpSourceIpv4}
|
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
||||||
onChange={(e) => {
|
<Input
|
||||||
setBgpSourceManual(true)
|
id="speaker-bgp-source"
|
||||||
setBgpSourceIpv4(e.target.value)
|
placeholder="203.0.113.10"
|
||||||
}}
|
value={bgpSourceIpv4}
|
||||||
/>
|
onChange={(e) => {
|
||||||
</div>
|
setBgpSourceManual(true)
|
||||||
|
setBgpSourceIpv4(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isReplica ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="speaker-le-email">Email Let's Encrypt</Label>
|
||||||
|
<Input
|
||||||
|
id="speaker-le-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
value={letsencryptEmail}
|
||||||
|
onChange={(e) => setLetsencryptEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="speaker-cf-token">Cloudflare DNS API token</Label>
|
||||||
|
<Input
|
||||||
|
id="speaker-cf-token"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Zone:DNS:Edit"
|
||||||
|
value={cfDnsToken}
|
||||||
|
onChange={(e) => setCfDnsToken(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="speaker-panel-ip">IP панели (whitelist)</Label>
|
||||||
|
<Input
|
||||||
|
id="speaker-panel-ip"
|
||||||
|
placeholder="203.0.113.1/32"
|
||||||
|
value={panelIP}
|
||||||
|
onChange={(e) => setPanelIP(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{tlsIncomplete(agentDomain, letsencryptEmail, cfDnsToken, panelIP) ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<TriangleAlert />
|
||||||
|
<AlertTitle>Traefik не выпустит сертификат</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Нужны домен агента, email LE, Cloudflare token и IP панели. Иначе в
|
||||||
|
команде останутся плейсхолдеры CHANGE_ME_*.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FormDrawer>
|
</FormDrawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SpeakerInstallStep({
|
||||||
|
created,
|
||||||
|
commands,
|
||||||
|
}: {
|
||||||
|
created: SpeakerRow
|
||||||
|
commands: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Alert variant="warning">
|
||||||
|
<TriangleAlert />
|
||||||
|
<AlertTitle>Сохраните сейчас</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
agent_secret и node_token больше не будут показаны. Traefik на ноде выпускает
|
||||||
|
сертификат через DNS-01 (Cloudflare). MikroTik стучится на IP ноды:179; 80/443 —
|
||||||
|
только агент панели. Логи: docker compose logs -f bird2 evobgp-agent.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label>ID спикера</Label>
|
||||||
|
<code className="break-all font-mono text-xs">{created.id}</code>
|
||||||
|
</div>
|
||||||
|
{created.agent_secret ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label>agent_secret</Label>
|
||||||
|
<code className="break-all font-mono text-xs">{created.agent_secret}</code>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{created.node_token ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label>node_token</Label>
|
||||||
|
<code className="break-all font-mono text-xs">{created.node_token}</code>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="speaker-docker-commands">Docker-команды</Label>
|
||||||
|
<Textarea
|
||||||
|
id="speaker-docker-commands"
|
||||||
|
readOnly
|
||||||
|
value={commands}
|
||||||
|
className="min-h-64 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Timeline,
|
||||||
|
TimelineContent,
|
||||||
|
TimelineDate,
|
||||||
|
TimelineHeader,
|
||||||
|
TimelineIndicator,
|
||||||
|
TimelineItem,
|
||||||
|
TimelineSeparator,
|
||||||
|
TimelineTitle,
|
||||||
|
} from '@/components/reui/timeline'
|
||||||
|
import { jobStatusRu, jobTriggerRu } from '@/lib/ui-labels'
|
||||||
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReUI Timeline в раскрытии строки задач: создана → взята в работу → результат.
|
||||||
|
* Активный шаг выводится из фактических отметок времени задачи.
|
||||||
|
* @see https://reui.io/docs/components/base/timeline
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ruDateTime = (value: string | null | undefined) =>
|
||||||
|
value ? new Date(value).toLocaleString('ru-RU') : undefined
|
||||||
|
|
||||||
|
function resultSubtitle(job: JobRow): string | undefined {
|
||||||
|
if (job.error) return job.error
|
||||||
|
const summary = job.meta?.apply_summary
|
||||||
|
if (summary && typeof summary === 'object') {
|
||||||
|
const message = (summary as Record<string, unknown>).message
|
||||||
|
if (typeof message === 'string' && message !== '') return message
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobTimeline({ job }: { job: JobRow }) {
|
||||||
|
const trigger = jobTriggerRu(job.meta?.trigger)
|
||||||
|
const completed = job.finished_at ? 3 : job.started_at ? 2 : 1
|
||||||
|
const failed = ['failed', 'error'].includes(job.status.toLowerCase())
|
||||||
|
const subtitle = resultSubtitle(job)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-4 ps-3 pe-2">
|
||||||
|
<Timeline defaultValue={completed}>
|
||||||
|
<TimelineItem step={1}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>{ruDateTime(job.created_at)}</TimelineDate>
|
||||||
|
<TimelineTitle>Создана{trigger ? ` (${trigger})` : ''}</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineContent>Идентификатор: {job.job_id}</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
<TimelineItem step={2}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>{ruDateTime(job.started_at)}</TimelineDate>
|
||||||
|
<TimelineTitle>Взята в работу</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
</TimelineItem>
|
||||||
|
<TimelineItem step={3}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>{ruDateTime(job.finished_at)}</TimelineDate>
|
||||||
|
<TimelineTitle className={failed ? 'text-destructive' : undefined}>
|
||||||
|
{job.finished_at ? jobStatusRu(job.status) : 'Ещё выполняется'}
|
||||||
|
</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
{subtitle ? <TimelineContent>{subtitle}</TimelineContent> : null}
|
||||||
|
</TimelineItem>
|
||||||
|
</Timeline>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,34 +1,18 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { LayoutGrid, Table2 } from 'lucide-react'
|
||||||
|
|
||||||
import { FrameDataGrid } from '@/components/reui-kit'
|
|
||||||
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
|
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
|
||||||
|
import { OperationsJobsKanban } from '@/components/operations/operations-jobs-kanban'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { ToggleGroup, ToggleGroupItem } from '@evobgp/ui/components/toggle-group'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
type JobTab = 'all' | 'active' | 'failed' | 'succeeded'
|
/**
|
||||||
|
* Jobs ResourcePage + optional Kanban board (не замена грида).
|
||||||
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
if (tab === 'all') return items
|
* @see https://reui.io/docs/components/base/kanban
|
||||||
if (tab === 'active') return items.filter((j) => j.status === 'running' || j.status === 'queued')
|
*/
|
||||||
if (tab === 'failed')
|
|
||||||
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
|
||||||
return items.filter((j) => j.status === 'succeeded')
|
|
||||||
}
|
|
||||||
|
|
||||||
function tabCounts(items: JobRow[]) {
|
|
||||||
return {
|
|
||||||
all: items.length,
|
|
||||||
active: items.filter((j) => j.status === 'running' || j.status === 'queued').length,
|
|
||||||
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
|
||||||
.length,
|
|
||||||
succeeded: items.filter((j) => j.status === 'succeeded').length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** data-grid-filtering-1 style jobs card with status tabs. */
|
|
||||||
export function OperationsJobsCard({
|
export function OperationsJobsCard({
|
||||||
jobs,
|
jobs,
|
||||||
nameById,
|
nameById,
|
||||||
@@ -46,41 +30,54 @@ export function OperationsJobsCard({
|
|||||||
error: unknown
|
error: unknown
|
||||||
onRetry: () => void
|
onRetry: () => void
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<JobTab>('all')
|
const [view, setView] = useState<'table' | 'board'>('table')
|
||||||
const counts = useMemo(() => tabCounts(jobs), [jobs])
|
|
||||||
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FrameDataGrid title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="px-5 pt-3">
|
<div className="flex justify-end">
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
|
<ToggleGroup
|
||||||
<TabsList variant="line" className="w-full justify-start gap-6">
|
multiple={false}
|
||||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
value={[view]}
|
||||||
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger>
|
onValueChange={(values) => {
|
||||||
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
|
const next = values[0]
|
||||||
<TabsTrigger value="failed">Ошибки ({counts.failed})</TabsTrigger>
|
if (next === 'table' || next === 'board') setView(next)
|
||||||
</TabsList>
|
}}
|
||||||
</Tabs>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Вид задач"
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="table" aria-label="Таблица">
|
||||||
|
<Table2 className="size-4" />
|
||||||
|
Таблица
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="board" aria-label="Доска">
|
||||||
|
<LayoutGrid className="size-4" />
|
||||||
|
Доска
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
<QueryState
|
<QueryState
|
||||||
data={filtered}
|
data={jobs}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
error={error}
|
error={error}
|
||||||
empty={filtered.length === 0}
|
empty={false}
|
||||||
emptyTitle="Нет задач в выборке"
|
|
||||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
>
|
>
|
||||||
{(items) => (
|
{(items) =>
|
||||||
<OperationsJobsGrid
|
view === 'board' ? (
|
||||||
items={items}
|
<OperationsJobsKanban items={items} nameById={nameById} />
|
||||||
nameById={nameById}
|
) : (
|
||||||
qc={qc}
|
<OperationsJobsGrid
|
||||||
isLoading={isLoading && items.length > 0}
|
items={items}
|
||||||
/>
|
nameById={nameById}
|
||||||
)}
|
qc={qc}
|
||||||
|
isLoading={isLoading && items.length > 0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</FrameDataGrid>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,50 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState } from 'react'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useMemo } from 'react'
|
import { ListTodo, SearchIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { JobTimeline } from '@/components/operations/job-timeline'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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,
|
||||||
|
createTextFilterQuery,
|
||||||
|
type DataGridColumnDef,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
import { apiMutate } from '@/lib/api-client'
|
import { apiMutate } from '@/lib/api-client'
|
||||||
import { jobKindRu } from '@/lib/ui-labels'
|
import { jobKindRu, jobSubtitleRu, jobTriggerRu } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
const JOB_TABS = [
|
||||||
|
{ id: 'all', label: 'Все' },
|
||||||
|
{ id: 'active', label: 'Активные' },
|
||||||
|
{ id: 'succeeded', label: 'Успешные' },
|
||||||
|
{ id: 'failed', label: 'Ошибки' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filterFields: FilterField[] = [
|
||||||
|
{
|
||||||
|
id: 'search',
|
||||||
|
label: 'Поиск',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Поиск задач…',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function tabFilter(item: JobRow, tabId: string): boolean {
|
||||||
|
if (tabId === 'active') return item.status === 'running' || item.status === 'queued'
|
||||||
|
if (tabId === 'failed')
|
||||||
|
return ['failed', 'error', 'cancelled'].includes(item.status.toLowerCase())
|
||||||
|
if (tabId === 'succeeded') return item.status === 'succeeded'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function OperationsJobsGrid({
|
export function OperationsJobsGrid({
|
||||||
items,
|
items,
|
||||||
nameById,
|
nameById,
|
||||||
@@ -26,6 +56,9 @@ export function OperationsJobsGrid({
|
|||||||
qc: QueryClient
|
qc: QueryClient
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
const cancelMutation = useMutation({
|
const cancelMutation = useMutation({
|
||||||
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
|
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -35,23 +68,32 @@ export function OperationsJobsGrid({
|
|||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<JobRow>[]>(
|
const columns = useMemo<DataGridColumnDef<JobRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<DataGridPrimaryCell
|
const trigger = jobTriggerRu(row.original.meta?.trigger)
|
||||||
title={jobKindRu(row.original.kind)}
|
return (
|
||||||
subtitle={
|
<DataGridNameCell
|
||||||
row.original.meta?.module_id
|
icon={ListTodo}
|
||||||
? (nameById.get(String(row.original.meta.module_id)) ??
|
title={
|
||||||
String(row.original.meta.module_id))
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
: undefined
|
<span className="truncate">{jobKindRu(row.original.kind)}</span>
|
||||||
}
|
{trigger ? (
|
||||||
/>
|
<Badge variant="outline" size="sm" className="shrink-0 font-normal">
|
||||||
),
|
{trigger}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
subtitle={jobSubtitleRu(row.original.kind, row.original.meta, nameById)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
meta: { headerTitle: 'Вид' },
|
meta: { headerTitle: 'Вид' },
|
||||||
|
size: 320,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
@@ -64,6 +106,7 @@ export function OperationsJobsGrid({
|
|||||||
return <StatusBadge status={row.original.status} hint={hint} />
|
return <StatusBadge status={row.original.status} hint={hint} />
|
||||||
},
|
},
|
||||||
meta: { headerTitle: 'Статус' },
|
meta: { headerTitle: 'Статус' },
|
||||||
|
size: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'created_at',
|
id: 'created_at',
|
||||||
@@ -77,6 +120,7 @@ export function OperationsJobsGrid({
|
|||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Создана' },
|
meta: { headerTitle: 'Создана' },
|
||||||
|
size: 190,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'finished_at',
|
id: 'finished_at',
|
||||||
@@ -90,6 +134,7 @@ export function OperationsJobsGrid({
|
|||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Завершена' },
|
meta: { headerTitle: 'Завершена' },
|
||||||
|
size: 190,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -112,27 +157,31 @@ export function OperationsJobsGrid({
|
|||||||
[cancelMutation, nameById],
|
[cancelMutation, nameById],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: items,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) => {
|
|
||||||
const moduleName = row.meta?.module_id
|
|
||||||
? (nameById.get(String(row.meta.module_id)) ?? String(row.meta.module_id))
|
|
||||||
: ''
|
|
||||||
return `${jobKindRu(row.kind)} ${row.status} ${row.job_id} ${moduleName}`
|
|
||||||
},
|
|
||||||
getRowId: (row) => row.job_id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title="Задачи"
|
||||||
recordCount={filteredCount}
|
description="Фильтр по статусу"
|
||||||
|
tabs={JOB_TABS}
|
||||||
|
tabFilter={tabFilter}
|
||||||
|
filterFields={filterFields}
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
onFilterQueryChange={setFilterQuery}
|
||||||
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||||
|
getFilterFieldValue={(item, field) => {
|
||||||
|
if (field !== 'search') return undefined
|
||||||
|
const subtitle = jobSubtitleRu(item.kind, item.meta, nameById)
|
||||||
|
const trigger = jobTriggerRu(item.meta?.trigger)
|
||||||
|
return `${jobKindRu(item.kind)} ${item.status} ${item.job_id} ${subtitle ?? ''} ${trigger ?? ''}`
|
||||||
|
}}
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
getRowId={(row) => row.job_id}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет задач"
|
pinLastColumn
|
||||||
searchValue={globalFilter}
|
virtualization={items.length > 80}
|
||||||
onSearchChange={setGlobalFilter}
|
emptyState={{ title: 'Нет задач' }}
|
||||||
searchPlaceholder="Поиск задач…"
|
getRowCanExpand={() => true}
|
||||||
|
expandedContent={(row) => <JobTimeline job={row} />}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import { DataGridMutedCell } from '@/components/data-grid-cell'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import {
|
||||||
|
Kanban,
|
||||||
|
KanbanBoard,
|
||||||
|
KanbanColumn,
|
||||||
|
KanbanColumnContent,
|
||||||
|
KanbanItem,
|
||||||
|
KanbanOverlay,
|
||||||
|
} from '@/components/reui/kanban'
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@evobgp/ui/components/item'
|
||||||
|
import { jobKindRu } from '@/lib/ui-labels'
|
||||||
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations jobs board — ReUI Kanban (read-only columns).
|
||||||
|
* @see https://reui.io/preview/base/kanban-board-8
|
||||||
|
* @see https://reui.io/docs/components/base/kanban
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COLUMN_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const
|
||||||
|
|
||||||
|
const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
|
||||||
|
queued: 'В очереди',
|
||||||
|
running: 'Выполняются',
|
||||||
|
succeeded: 'Успешные',
|
||||||
|
failed: 'Ошибки',
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnForStatus(status: string): (typeof COLUMN_ORDER)[number] {
|
||||||
|
const s = status.toLowerCase()
|
||||||
|
if (s === 'queued') return 'queued'
|
||||||
|
if (s === 'running') return 'running'
|
||||||
|
if (s === 'succeeded') return 'succeeded'
|
||||||
|
return 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyColumns(): Record<string, JobRow[]> {
|
||||||
|
return {
|
||||||
|
queued: [],
|
||||||
|
running: [],
|
||||||
|
succeeded: [],
|
||||||
|
failed: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperationsJobsKanban({
|
||||||
|
items,
|
||||||
|
nameById,
|
||||||
|
}: {
|
||||||
|
items: JobRow[]
|
||||||
|
nameById: Map<string, string>
|
||||||
|
}) {
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
const next = emptyColumns()
|
||||||
|
for (const job of items) {
|
||||||
|
next[columnForStatus(job.status)].push(job)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="w-full min-w-0">
|
||||||
|
<FramePanel className="p-4">
|
||||||
|
<Kanban
|
||||||
|
value={columns}
|
||||||
|
onValueChange={() => undefined}
|
||||||
|
getItemValue={(job) => job.job_id}
|
||||||
|
>
|
||||||
|
<KanbanBoard className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{COLUMN_ORDER.map((columnId) => (
|
||||||
|
<KanbanColumn
|
||||||
|
key={columnId}
|
||||||
|
value={columnId}
|
||||||
|
disabled
|
||||||
|
className="bg-muted/30 flex min-h-40 flex-col gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium">{COLUMN_LABELS[columnId]}</p>
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{columns[columnId]?.length ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<KanbanColumnContent value={columnId}>
|
||||||
|
{(columns[columnId] ?? []).map((job) => (
|
||||||
|
<KanbanItem
|
||||||
|
key={job.job_id}
|
||||||
|
value={job.job_id}
|
||||||
|
disabled
|
||||||
|
className="p-0"
|
||||||
|
>
|
||||||
|
<Item variant="outline" size="sm">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{jobKindRu(job.kind)}</ItemTitle>
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
{job.meta?.module_id ? (
|
||||||
|
<ItemDescription>
|
||||||
|
<DataGridMutedCell>
|
||||||
|
{nameById.get(String(job.meta.module_id)) ??
|
||||||
|
String(job.meta.module_id)}
|
||||||
|
</DataGridMutedCell>
|
||||||
|
</ItemDescription>
|
||||||
|
) : null}
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
</KanbanItem>
|
||||||
|
))}
|
||||||
|
</KanbanColumnContent>
|
||||||
|
</KanbanColumn>
|
||||||
|
))}
|
||||||
|
</KanbanBoard>
|
||||||
|
<KanbanOverlay>
|
||||||
|
<Item variant="outline" size="sm">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>Задача</ItemTitle>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
</KanbanOverlay>
|
||||||
|
</Kanban>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,32 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState } from 'react'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { GitCommitHorizontal, RefreshCw, SearchIcon } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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,
|
||||||
|
createTextFilterQuery,
|
||||||
|
type DataGridColumnDef,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
import { apiMutate } from '@/lib/api-client'
|
import { apiMutate } from '@/lib/api-client'
|
||||||
import type { RevisionRow } from '@/types/api'
|
import type { RevisionRow } from '@/types/api'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
const filterFields: FilterField[] = [
|
||||||
|
{
|
||||||
|
id: 'search',
|
||||||
|
label: 'Поиск',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Поиск ревизий…',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export function OperationsRevisionsGrid({
|
export function OperationsRevisionsGrid({
|
||||||
items,
|
items,
|
||||||
qc,
|
qc,
|
||||||
@@ -24,6 +36,9 @@ export function OperationsRevisionsGrid({
|
|||||||
qc: QueryClient
|
qc: QueryClient
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
const rollbackMutation = useMutation({
|
const rollbackMutation = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) =>
|
||||||
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
|
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
|
||||||
@@ -34,16 +49,17 @@ export function OperationsRevisionsGrid({
|
|||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<RevisionRow>[]>(
|
const columns = useMemo<DataGridColumnDef<RevisionRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: 'id',
|
id: 'id',
|
||||||
accessorFn: (row) => row.id,
|
accessorFn: (row) => row.id,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell title={`${row.original.id.slice(0, 12)}…`} accent="mono" />
|
<DataGridNameCell icon={GitCommitHorizontal} title={row.original.id} />
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'ID' },
|
meta: { headerTitle: 'ID' },
|
||||||
|
size: 340,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'created_at',
|
accessorKey: 'created_at',
|
||||||
@@ -54,6 +70,7 @@ export function OperationsRevisionsGrid({
|
|||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Создана' },
|
meta: { headerTitle: 'Создана' },
|
||||||
|
size: 210,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'materialized_prefix_count',
|
accessorKey: 'materialized_prefix_count',
|
||||||
@@ -64,6 +81,7 @@ export function OperationsRevisionsGrid({
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Префиксов' },
|
meta: { headerTitle: 'Префиксов' },
|
||||||
|
size: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -76,7 +94,7 @@ export function OperationsRevisionsGrid({
|
|||||||
<RefreshCw className="size-3.5" />
|
<RefreshCw className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
title={`Откатиться к ревизии ${row.original.id}?`}
|
||||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
||||||
confirmLabel="Откатить"
|
confirmLabel="Откатить"
|
||||||
destructive
|
destructive
|
||||||
@@ -88,22 +106,21 @@ export function OperationsRevisionsGrid({
|
|||||||
[rollbackMutation],
|
[rollbackMutation],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: items,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) => `${row.id} ${row.materialized_prefix_count}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title="История ревизий"
|
||||||
recordCount={filteredCount}
|
filterFields={filterFields}
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
onFilterQueryChange={setFilterQuery}
|
||||||
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||||
|
getFilterFieldValue={(item) => `${item.id} ${item.materialized_prefix_count}`}
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет ревизий"
|
pinLastColumn
|
||||||
searchValue={globalFilter}
|
virtualization={items.length > 80}
|
||||||
onSearchChange={setGlobalFilter}
|
emptyState={{ title: 'Нет ревизий' }}
|
||||||
searchPlaceholder="Поиск ревизий…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,92 +1,11 @@
|
|||||||
import type { ReactNode } from 'react'
|
export {
|
||||||
|
FrameSection,
|
||||||
import {
|
FrameSection as PanelCard,
|
||||||
Frame,
|
panelCardClassName,
|
||||||
FrameDescription,
|
panelCardHeaderClassName,
|
||||||
FrameFooter,
|
panelCardContentFlushClassName,
|
||||||
FrameHeader,
|
panelCardInsetClassName,
|
||||||
FramePanel,
|
panelCardFooterClassName,
|
||||||
FrameTitle,
|
type FrameSectionProps,
|
||||||
} from '@/components/reui/frame'
|
type FrameSectionProps as PanelCardProps,
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
} from '@/components/reui-kit/frame-section'
|
||||||
|
|
||||||
/**
|
|
||||||
* Frame shell class constants (legacy Card names kept for DataGridShell / toolbar).
|
|
||||||
* @see https://reui.io/docs/components/base/frame
|
|
||||||
*/
|
|
||||||
export const panelCardClassName = 'w-full gap-0 p-0'
|
|
||||||
export const panelCardHeaderClassName = 'border-b'
|
|
||||||
/** Flush body inside FramePanel (toolbar / data-grid). */
|
|
||||||
export const panelCardContentFlushClassName = 'p-0'
|
|
||||||
/** Horizontal inset for toolbar / pagination rows. */
|
|
||||||
export const panelCardInsetClassName = 'px-5 py-3'
|
|
||||||
export const panelCardFooterClassName = 'border-t bg-transparent'
|
|
||||||
|
|
||||||
interface PanelCardProps {
|
|
||||||
title?: ReactNode
|
|
||||||
description?: ReactNode
|
|
||||||
actions?: ReactNode
|
|
||||||
footer?: ReactNode
|
|
||||||
children?: ReactNode
|
|
||||||
className?: string
|
|
||||||
headerClassName?: string
|
|
||||||
contentClassName?: string
|
|
||||||
footerClassName?: string
|
|
||||||
size?: 'default' | 'sm'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PanelCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
actions,
|
|
||||||
footer,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
headerClassName,
|
|
||||||
contentClassName,
|
|
||||||
footerClassName,
|
|
||||||
size = 'default',
|
|
||||||
}: PanelCardProps) {
|
|
||||||
const hasHeader = Boolean(title || description || actions)
|
|
||||||
const spacing = size === 'sm' ? 'sm' : 'default'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Frame
|
|
||||||
dense
|
|
||||||
spacing={spacing}
|
|
||||||
className={cn(panelCardClassName, 'min-w-0', className)}
|
|
||||||
>
|
|
||||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
|
||||||
{hasHeader ? (
|
|
||||||
<FrameHeader
|
|
||||||
className={cn(
|
|
||||||
panelCardHeaderClassName,
|
|
||||||
'flex-row items-start justify-between gap-3',
|
|
||||||
headerClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
|
||||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
|
||||||
{description ? (
|
|
||||||
<FrameDescription>{description}</FrameDescription>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{actions ? (
|
|
||||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
|
||||||
{actions}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</FrameHeader>
|
|
||||||
) : null}
|
|
||||||
{children != null && children !== false ? (
|
|
||||||
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
|
|
||||||
) : null}
|
|
||||||
{footer ? (
|
|
||||||
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
|
|
||||||
{footer}
|
|
||||||
</FrameFooter>
|
|
||||||
) : null}
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,13 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import { IconStack } from '@/components/reui/icon-stack'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FramePanel,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyMedia,
|
|
||||||
EmptyTitle,
|
|
||||||
} from '@evobgp/ui/components/empty'
|
|
||||||
|
|
||||||
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
|
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
|
||||||
export function IllustratedEmptyState({
|
export function IllustratedEmptyState({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon: Icon,
|
icon,
|
||||||
action,
|
action,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
@@ -27,23 +16,13 @@ export function IllustratedEmptyState({
|
|||||||
action?: ReactNode
|
action?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<EmptyState
|
||||||
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
framed
|
||||||
{action ? <div className="w-full self-end">{action}</div> : null}
|
centered={false}
|
||||||
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
icon={icon}
|
||||||
<EmptyHeader className="items-center gap-5 text-center">
|
title={title}
|
||||||
<EmptyMedia className="mb-0">
|
description={description}
|
||||||
<IconStack aria-hidden>
|
action={action}
|
||||||
<Icon strokeWidth={1.9} aria-hidden />
|
/>
|
||||||
</IconStack>
|
|
||||||
</EmptyMedia>
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
|
|
||||||
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
|
|
||||||
</div>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export { DonutBreakdownCard } from './donut-breakdown-card'
|
export { DonutBreakdownCard } from './donut-breakdown-card'
|
||||||
export { IllustratedEmptyState } from './illustrated-empty-state'
|
export { IllustratedEmptyState } from './illustrated-empty-state'
|
||||||
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
||||||
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid'
|
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/reui-kit'
|
||||||
export { PanelCorners } from './panel-corners'
|
export { PanelCorners } from './panel-corners'
|
||||||
export { ProjectsEmptyState } from './projects-empty-state'
|
export { ProjectsEmptyState } from './projects-empty-state'
|
||||||
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Boxes, Plus } from 'lucide-react'
|
import { Boxes, Plus } from 'lucide-react'
|
||||||
|
|
||||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
/** empty-state-3 pattern for first module. */
|
/** empty-state-14 for first module. Preview: https://reui.io/preview/base/empty-state-14 */
|
||||||
export function ProjectsEmptyState({
|
export function ProjectsEmptyState({
|
||||||
canCreate = true,
|
canCreate = true,
|
||||||
onCreate,
|
onCreate,
|
||||||
@@ -12,7 +12,9 @@ export function ProjectsEmptyState({
|
|||||||
onCreate?: () => void
|
onCreate?: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<IllustratedEmptyState
|
<EmptyState
|
||||||
|
framed
|
||||||
|
centered={false}
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
title="Создайте первый модуль"
|
title="Создайте первый модуль"
|
||||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@evobgp/ui/components/alert-dialog'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@evobgp/ui/components/sheet'
|
||||||
|
import type { Tone } from './tone'
|
||||||
|
import { TONE_BADGE } from './tone'
|
||||||
|
|
||||||
|
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
|
||||||
|
|
||||||
|
/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */
|
||||||
|
export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) {
|
||||||
|
const glyph = TONE_GLYPH[tone]
|
||||||
|
return (
|
||||||
|
<Badge variant={TONE_BADGE[tone]} size="sm" className="whitespace-nowrap">
|
||||||
|
{glyph ? <span aria-hidden className="text-[0.55rem]">{glyph}</span> : null}
|
||||||
|
{children}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Заголовок страницы: название, описание, метка свежести и действия. */
|
||||||
|
export function PageHead({
|
||||||
|
title,
|
||||||
|
sub,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
sub: string
|
||||||
|
actions?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">{sub}</p>
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALERT_ICON = {
|
||||||
|
er: <CircleAlertIcon aria-hidden />,
|
||||||
|
wn: <TriangleAlertIcon aria-hidden />,
|
||||||
|
in: <InfoIcon aria-hidden />,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const ALERT_VARIANT = {
|
||||||
|
er: 'destructive',
|
||||||
|
wn: 'warning',
|
||||||
|
in: 'info',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function SignalAlert({
|
||||||
|
tone,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
tone: 'er' | 'wn' | 'in'
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
action?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Alert variant={ALERT_VARIANT[tone]}>
|
||||||
|
{ALERT_ICON[tone]}
|
||||||
|
<AlertTitle>{title}</AlertTitle>
|
||||||
|
<AlertDescription>{children}</AlertDescription>
|
||||||
|
{action ? <AlertAction>{action}</AlertAction> : null}
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
|
||||||
|
export function PortalSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
size = 'md',
|
||||||
|
dirty,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
children: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
|
||||||
|
size?: 'md' | 'lg'
|
||||||
|
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
|
||||||
|
dirty?: boolean
|
||||||
|
}) {
|
||||||
|
const [touchedState, setTouched] = useState(false)
|
||||||
|
const touched = dirty ?? touchedState
|
||||||
|
const [guard, setGuard] = useState(false)
|
||||||
|
|
||||||
|
function requestChange(next: boolean) {
|
||||||
|
if (!next && touched) {
|
||||||
|
setGuard(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!next) setTouched(false)
|
||||||
|
onOpenChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sheet open={open} onOpenChange={requestChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
showCloseButton={false}
|
||||||
|
className={cn(
|
||||||
|
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
|
||||||
|
'data-[side=right]:sm:max-w-none',
|
||||||
|
size === 'lg'
|
||||||
|
? 'data-[side=right]:w-[min(45rem,100vw)]'
|
||||||
|
: 'data-[side=right]:w-[min(30rem,100vw)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SheetHeader className="shrink-0 gap-0 p-0">
|
||||||
|
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
||||||
|
<SheetTitle className="min-w-0 truncate text-base font-semibold">{title}</SheetTitle>
|
||||||
|
<SheetClose
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</SheetClose>
|
||||||
|
</div>
|
||||||
|
<SheetDescription className="text-muted-foreground px-4 pt-3 text-sm">
|
||||||
|
{description}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4"
|
||||||
|
onInput={() => setTouched(true)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<SheetFooter
|
||||||
|
className="flex-row flex-wrap justify-end gap-2 border-t px-4 py-3"
|
||||||
|
onClickCapture={(e) => {
|
||||||
|
// «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение.
|
||||||
|
const label = (e.target as HTMLElement).closest('button')?.textContent?.trim()
|
||||||
|
if (touched && (label === 'Закрыть' || label === 'Отмена')) {
|
||||||
|
e.stopPropagation()
|
||||||
|
setGuard(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>{footer}</SheetFooter>
|
||||||
|
) : null}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
<AlertDialog open={guard} onOpenChange={setGuard}>
|
||||||
|
<AlertDialogContent size="sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Закрыть без сохранения?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Изменения в форме будут потеряны.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Остаться</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setGuard(false)
|
||||||
|
setTouched(false)
|
||||||
|
onOpenChange(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Закрыть без сохранения
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */
|
||||||
|
export function TypedConfirm({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
expected,
|
||||||
|
confirmLabel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: string
|
||||||
|
description: ReactNode
|
||||||
|
expected: string
|
||||||
|
confirmLabel: string
|
||||||
|
onConfirm: () => void
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
const matches = value.trim().toLowerCase() === expected.toLowerCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) setValue('')
|
||||||
|
onOpenChange(next)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="typed-confirm">
|
||||||
|
Введите <span className="font-mono font-semibold">{expected}</span> для подтверждения
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="typed-confirm"
|
||||||
|
value={value}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
aria-invalid={value.length > 0 && !matches}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!matches}
|
||||||
|
onClick={() => {
|
||||||
|
setValue('')
|
||||||
|
onConfirm()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KvFrame({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
items: Array<{ label: string; value: ReactNode }>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
<FrameDescription>{description}</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<dl className="grid divide-y @container sm:grid-cols-2 sm:divide-y-0">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="flex flex-col gap-0.5 border-b px-4 py-3 last:border-b-0 sm:odd:border-r"
|
||||||
|
>
|
||||||
|
<dt className="text-muted-foreground text-xs">{item.label}</dt>
|
||||||
|
<dd className="text-sm font-medium break-words">{item.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Список «значение + подпись + копировать» (параметры issuer). */
|
||||||
|
export function CopyList({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
items: Array<{ label: string; value: string }>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
<FrameDescription>{description}</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.label}
|
||||||
|
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
|
||||||
|
>
|
||||||
|
<code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
|
||||||
|
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
aria-label={`Скопировать: ${item.label}`}
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
|
||||||
|
toast.success(`${item.label} скопирован`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Копировать
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Компактный список «ключ — значение» для боковых окон. */
|
||||||
|
export function KvList({ items }: { items: Array<[string, string]> }) {
|
||||||
|
return (
|
||||||
|
<dl className="flex flex-col divide-y rounded-lg border">
|
||||||
|
{items.map(([k, v]) => (
|
||||||
|
<div key={k} className="flex flex-col gap-0.5 px-3 py-2">
|
||||||
|
<dt className="text-muted-foreground text-xs">{k}</dt>
|
||||||
|
<dd className="text-sm break-words">{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeBlock({ label, children }: { label: string; children: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-muted-foreground text-xs">{label}</span>
|
||||||
|
<pre className="bg-muted/40 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сегментированный переключатель на Tabs (вид таблицы, период). */
|
||||||
|
export function Segmented({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
options: string[]
|
||||||
|
value: number
|
||||||
|
onChange: (index: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
|
||||||
|
<TabsList className="max-w-full justify-start overflow-x-auto">
|
||||||
|
{options.map((o, i) => (
|
||||||
|
<TabsTrigger key={o} value={String(i)}>
|
||||||
|
{o}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
|
||||||
|
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
|
||||||
|
return (
|
||||||
|
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
|
||||||
|
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
|
||||||
|
export function PageTabsList({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto">
|
||||||
|
{children}
|
||||||
|
</TabsList>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
|
||||||
|
return (
|
||||||
|
<TabsTrigger value={value} className="flex-none gap-1.5 px-4 text-sm">
|
||||||
|
{label}
|
||||||
|
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
|
||||||
|
</TabsTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
|
||||||
|
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
|
||||||
|
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
|
||||||
|
<b className="text-sm font-medium">{title}</b>
|
||||||
|
<span className="text-muted-foreground text-[13px]">{description}</span>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
|
||||||
|
export function FormFrame({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
badge,
|
||||||
|
tone = 'ok',
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
badge: string
|
||||||
|
tone?: Tone
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
|
||||||
|
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
<FrameDescription>{description}</FrameDescription>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone={tone}>{badge}</StatusBadge>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">{children}</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import type { Tone } from './tone'
|
||||||
|
import { StatusBadge } from './bits'
|
||||||
|
|
||||||
|
export type AppCardItem = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
desc: string
|
||||||
|
badge: string
|
||||||
|
badgeVariant?: 'info-light' | 'warning-light' | 'outline'
|
||||||
|
meta: string
|
||||||
|
icon: ReactNode
|
||||||
|
onSelect?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плитки приложений как на доске «Приложения»: Frame с шапкой, внутри сетка auto-fit от 260px,
|
||||||
|
* в плитке иконка, название, плашка действия, описание и адрес моноширинным шрифтом.
|
||||||
|
*/
|
||||||
|
export function AppCards({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
items,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
items: AppCardItem[]
|
||||||
|
action?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
|
||||||
|
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
<FrameDescription className="text-pretty">{description}</FrameDescription>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-3">
|
||||||
|
<ul className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,16.25rem),1fr))] gap-3">
|
||||||
|
{items.map((c) => {
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
|
||||||
|
{c.icon}
|
||||||
|
</IconTile>
|
||||||
|
<b className="min-w-0 text-sm leading-tight font-semibold">{c.title}</b>
|
||||||
|
</span>
|
||||||
|
<Badge variant={c.badgeVariant ?? 'info-light'} size="sm" className="shrink-0">
|
||||||
|
{c.badge}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1.5 text-[13px] text-pretty">{c.desc}</p>
|
||||||
|
<p className="text-muted-foreground mt-2 truncate font-mono text-[11px]">{c.meta}</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left'
|
||||||
|
return (
|
||||||
|
<li key={c.id} className="min-w-0">
|
||||||
|
{c.onSelect ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={c.onSelect}
|
||||||
|
aria-label={`${c.title}: ${c.badge}`}
|
||||||
|
className={cn(cls, 'hover:bg-muted/50 focus-visible:ring-ring transition-colors outline-none focus-visible:ring-2')}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className={cls}>{body}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOT: Record<Tone, string> = {
|
||||||
|
ok: 'bg-success',
|
||||||
|
wn: 'bg-warning',
|
||||||
|
er: 'bg-destructive',
|
||||||
|
mu: 'bg-muted-foreground',
|
||||||
|
in: 'bg-chart-4',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок
|
||||||
|
* и строка «кто · источник · IP».
|
||||||
|
*/
|
||||||
|
export function ActivityFeed({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
items,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }>
|
||||||
|
action?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
|
||||||
|
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
<FrameDescription className="text-pretty">{description}</FrameDescription>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="py-1.5">
|
||||||
|
<ol>
|
||||||
|
{items.map((e) => (
|
||||||
|
<li key={`${e.t}-${e.title}`} className="grid grid-cols-[3.9rem_1rem_minmax(0,1fr)] items-start gap-2.5 px-4 py-2 text-[13px]">
|
||||||
|
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{e.t}</span>
|
||||||
|
<span aria-hidden className="flex h-full flex-col items-center">
|
||||||
|
<i className={cn('mt-1 size-[9px] rounded-full', DOT[e.tone])} />
|
||||||
|
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="mr-2 inline-block align-middle">
|
||||||
|
<StatusBadge tone={e.tone}>{e.label}</StatusBadge>
|
||||||
|
</span>
|
||||||
|
<b className="font-medium">{e.title}</b>
|
||||||
|
<span className="text-muted-foreground mt-0.5 block text-xs">{e.text}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
|
||||||
|
|
||||||
|
export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
|
||||||
|
return <ChromeConfigContext.Provider value={value}>{children}</ChromeConfigContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStored<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
return raw ? (JSON.parse(raw) as T) : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStored(key: string, value: unknown) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
/* без сохранения настроек */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
|
||||||
|
export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||||
|
const [density, setDensityState] = useState<Density>(() => readStored(`${storageKey}:density`, 'comfortable'))
|
||||||
|
const [contrast, setContrastState] = useState<Contrast>(() => readStored(`${storageKey}:contrast`, 'standard'))
|
||||||
|
const [pins, setPins] = useState<string[]>(() => readStored(`${storageKey}:pins`, []))
|
||||||
|
|
||||||
|
const setDensity = useCallback(
|
||||||
|
(v: Density) => {
|
||||||
|
setDensityState(v)
|
||||||
|
writeStored(`${storageKey}:density`, v)
|
||||||
|
},
|
||||||
|
[storageKey],
|
||||||
|
)
|
||||||
|
const setContrast = useCallback(
|
||||||
|
(v: Contrast) => {
|
||||||
|
setContrastState(v)
|
||||||
|
writeStored(`${storageKey}:contrast`, v)
|
||||||
|
},
|
||||||
|
[storageKey],
|
||||||
|
)
|
||||||
|
const togglePin = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setPins((prev) => {
|
||||||
|
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
writeStored(`${storageKey}:pins`, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[storageKey],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.dataset.density = density
|
||||||
|
root.dataset.contrast = contrast
|
||||||
|
return () => {
|
||||||
|
delete root.dataset.density
|
||||||
|
delete root.dataset.contrast
|
||||||
|
}
|
||||||
|
}, [density, contrast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
setPaletteOpen((v) => !v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo<UiStore>(
|
||||||
|
() => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
|
||||||
|
[paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
|
||||||
|
)
|
||||||
|
return <UiStoreContext.Provider value={value}>{children}</UiStoreContext.Provider>
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export type NavItem = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
to: string
|
||||||
|
icon: LucideIcon
|
||||||
|
group: string
|
||||||
|
/** Точка проблемы в углу иконки. */
|
||||||
|
problem?: { tone: 'er' | 'wn'; label: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonitorItem = {
|
||||||
|
id: string
|
||||||
|
tone: 'er' | 'wn' | 'ok'
|
||||||
|
title: string
|
||||||
|
text: string
|
||||||
|
age: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
|
||||||
|
|
||||||
|
export type PaletteAction = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
/** Горячая буква справа. */
|
||||||
|
hint: string
|
||||||
|
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaletteObject = {
|
||||||
|
id: string
|
||||||
|
group: string
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
dot?: 'ok' | 'er' | 'mu'
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
|
||||||
|
export type ChromeConfig = {
|
||||||
|
navItems: NavItem[]
|
||||||
|
navGroups: string[]
|
||||||
|
/** Верх бокового меню: бренд и переключатель приложений. */
|
||||||
|
brand: ReactNode
|
||||||
|
user: { name: string; email: string; initials: string }
|
||||||
|
securityTo: string
|
||||||
|
onLogout: () => void
|
||||||
|
fresh: (pathname: string) => { badge: string; text: string }
|
||||||
|
crumbFor?: (pathname: string, label: string | undefined) => string
|
||||||
|
monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
|
||||||
|
palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChromeConfigContext = createContext<ChromeConfig | null>(null)
|
||||||
|
|
||||||
|
export function useChromeConfig(): ChromeConfig {
|
||||||
|
const ctx = useContext(ChromeConfigContext)
|
||||||
|
if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Density = 'comfortable' | 'compact'
|
||||||
|
export type Contrast = 'standard' | 'high'
|
||||||
|
|
||||||
|
export type UiStore = {
|
||||||
|
paletteOpen: boolean
|
||||||
|
setPaletteOpen: (open: boolean) => void
|
||||||
|
pins: string[]
|
||||||
|
togglePin: (id: string) => void
|
||||||
|
density: Density
|
||||||
|
setDensity: (v: Density) => void
|
||||||
|
contrast: Contrast
|
||||||
|
setContrast: (v: Contrast) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UiStoreContext = createContext<UiStore | null>(null)
|
||||||
|
|
||||||
|
export function useUiStore(): UiStore {
|
||||||
|
const ctx = useContext(UiStoreContext)
|
||||||
|
if (!ctx) throw new Error('PortalUiProvider отсутствует')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import {
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ComponentType,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
Link,
|
||||||
|
useNavigate,
|
||||||
|
useRouterState,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronsUpDownIcon,
|
||||||
|
FingerprintIcon,
|
||||||
|
InfoIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
SearchIcon,
|
||||||
|
StarIcon,
|
||||||
|
MoonIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
useTheme,
|
||||||
|
} from 'next-themes'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
} from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
} from '@evobgp/ui/components/breadcrumb'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
} from '@evobgp/ui/components/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@evobgp/ui/components/dialog'
|
||||||
|
import {
|
||||||
|
Kbd,
|
||||||
|
} from '@evobgp/ui/components/kbd'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@evobgp/ui/components/popover'
|
||||||
|
import {
|
||||||
|
Separator,
|
||||||
|
} from '@evobgp/ui/components/separator'
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
} from '@evobgp/ui/components/sidebar'
|
||||||
|
import {
|
||||||
|
cn,
|
||||||
|
} from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Segmented,
|
||||||
|
} from './bits'
|
||||||
|
import {
|
||||||
|
useChromeConfig,
|
||||||
|
useUiStore,
|
||||||
|
type NavItem,
|
||||||
|
} from './chrome-store'
|
||||||
|
|
||||||
|
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
|
||||||
|
|
||||||
|
function PortalNavUser() {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||||
|
const { density, setDensity, contrast, setContrast } = useUiStore()
|
||||||
|
const { user, securityTo, onLogout } = useChromeConfig()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
|
||||||
|
className="bg-muted/40 aria-expanded:bg-muted"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||||
|
{user.initials}
|
||||||
|
</div>
|
||||||
|
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
||||||
|
<span className="truncate text-sm font-semibold">{user.name}</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||||
|
{user.initials}
|
||||||
|
</div>
|
||||||
|
<div className="grid leading-tight">
|
||||||
|
<span className="text-sm font-semibold">{user.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate({ to: securityTo })}
|
||||||
|
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
|
||||||
|
>
|
||||||
|
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
|
||||||
|
Безопасность
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className={LABEL_CLASS}>Тема</span>
|
||||||
|
<Segmented
|
||||||
|
label="Тема"
|
||||||
|
options={['Тёмная', 'Светлая']}
|
||||||
|
value={current === 'light' ? 1 : 0}
|
||||||
|
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
|
||||||
|
/>
|
||||||
|
<span className={LABEL_CLASS}>Плотность</span>
|
||||||
|
<Segmented
|
||||||
|
label="Плотность"
|
||||||
|
options={['Обычная', 'Компактная']}
|
||||||
|
value={density === 'compact' ? 1 : 0}
|
||||||
|
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
|
||||||
|
/>
|
||||||
|
<span className={LABEL_CLASS}>Контраст</span>
|
||||||
|
<Segmented
|
||||||
|
label="Контраст"
|
||||||
|
options={['Стандартный', 'Высокий']}
|
||||||
|
value={contrast === 'high' ? 1 : 0}
|
||||||
|
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
|
||||||
|
>
|
||||||
|
<LogOutIcon className="size-4" aria-hidden />
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
|
||||||
|
|
||||||
|
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
|
||||||
|
const Icon = item.icon
|
||||||
|
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
|
||||||
|
isActive={active}
|
||||||
|
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
|
||||||
|
render={<Link to={item.to} />}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{item.problem ? (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={item.problem.label}
|
||||||
|
className={cn(
|
||||||
|
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
|
||||||
|
DOT_CLASS[item.problem.tone],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
<SidebarMenuAction
|
||||||
|
showOnHover={!pinned}
|
||||||
|
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
|
||||||
|
aria-pressed={pinned}
|
||||||
|
onClick={onTogglePin}
|
||||||
|
>
|
||||||
|
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
|
||||||
|
</SidebarMenuAction>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalSidebar() {
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const { pins, togglePin } = useUiStore()
|
||||||
|
const { navItems, navGroups, brand } = useChromeConfig()
|
||||||
|
const [closed, setClosed] = useState<Record<string, boolean>>({})
|
||||||
|
const pinned = navItems.filter((i) => pins.includes(i.id))
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader className="pb-1.5">
|
||||||
|
{brand}
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
{pinned.length > 0 ? (
|
||||||
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||||
|
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{pinned.map((item) => (
|
||||||
|
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
) : null}
|
||||||
|
{navGroups.map((group, gi) => (
|
||||||
|
<div key={group} className="flex flex-col">
|
||||||
|
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
|
||||||
|
<SidebarGroup className="py-0">
|
||||||
|
<SidebarGroupLabel
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={!closed[group]}
|
||||||
|
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
|
||||||
|
>
|
||||||
|
{group}
|
||||||
|
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
|
||||||
|
<SidebarMenu className="gap-px">
|
||||||
|
{navItems.filter((i) => i.group === group).map((item) => (
|
||||||
|
<NavEntry
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
pathname={pathname}
|
||||||
|
pinned={pins.includes(item.id)}
|
||||||
|
onTogglePin={() => togglePin(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<PortalNavUser />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
|
||||||
|
function MonitorPopover() {
|
||||||
|
const { monitor } = useChromeConfig()
|
||||||
|
const MONITOR_ITEMS = monitor.items
|
||||||
|
const MONITOR_METRICS = monitor.metrics
|
||||||
|
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
|
||||||
|
const errors = bad.filter((m) => m.tone === 'er').length
|
||||||
|
const warnings = bad.length - errors
|
||||||
|
const ok = MONITOR_ITEMS.length - bad.length
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
|
||||||
|
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActivityIcon className="text-warning size-4" aria-hidden />
|
||||||
|
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
|
||||||
|
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
|
||||||
|
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
|
||||||
|
{errors}
|
||||||
|
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
|
||||||
|
{warnings}
|
||||||
|
</span>
|
||||||
|
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
|
||||||
|
{bad.length}
|
||||||
|
</span>
|
||||||
|
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
|
||||||
|
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
|
||||||
|
<div className="grid leading-tight">
|
||||||
|
<b className="text-sm font-semibold">Состояние системы</b>
|
||||||
|
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{[
|
||||||
|
{ n: errors, l: 'сбой', c: 'text-destructive' },
|
||||||
|
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
|
||||||
|
{ n: ok, l: 'в норме', c: 'text-success' },
|
||||||
|
].map((x) => (
|
||||||
|
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
|
||||||
|
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
|
||||||
|
{x.l}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-0.5">
|
||||||
|
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
|
||||||
|
<li key={m.id}>
|
||||||
|
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
|
||||||
|
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
|
||||||
|
<span className="grid min-w-0 flex-1 leading-tight">
|
||||||
|
<b className="truncate text-[13px] font-medium">{m.title}</b>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{MONITOR_METRICS.map((m) => (
|
||||||
|
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
|
||||||
|
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
|
||||||
|
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
|
||||||
|
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
|
||||||
|
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
|
||||||
|
{monitor.footerLabel}
|
||||||
|
<ArrowRightIcon className="size-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FreshnessPopover() {
|
||||||
|
const pathname = useRouterState({ select: (st) => st.location.pathname })
|
||||||
|
const { fresh: freshFor } = useChromeConfig()
|
||||||
|
const fresh = freshFor(pathname)
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InfoIcon aria-hidden />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
|
||||||
|
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
|
||||||
|
<p className="text-muted-foreground text-sm">{fresh.text}</p>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalHeader() {
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const { setPaletteOpen } = useUiStore()
|
||||||
|
const { navItems, crumbFor } = useChromeConfig()
|
||||||
|
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
|
||||||
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
|
<Breadcrumb className="min-w-0">
|
||||||
|
<BreadcrumbList className="flex-nowrap">
|
||||||
|
<BreadcrumbItem className="min-w-0">
|
||||||
|
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
|
<FreshnessPopover />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Командная палитра (⌘K)"
|
||||||
|
className="md:hidden"
|
||||||
|
onClick={() => setPaletteOpen(true)}
|
||||||
|
>
|
||||||
|
<SearchIcon aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<MonitorPopover />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaletteEntry = {
|
||||||
|
id: string
|
||||||
|
group: string
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||||
|
dot?: 'ok' | 'er' | 'mu'
|
||||||
|
run: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
|
||||||
|
|
||||||
|
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
|
||||||
|
export function CommandPalette() {
|
||||||
|
const { paletteOpen, setPaletteOpen } = useUiStore()
|
||||||
|
const { navItems, palette } = useChromeConfig()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [active, setActive] = useState(0)
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
|
||||||
|
const go = (to: string) => () => void navigate({ to })
|
||||||
|
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||||
|
const list: PaletteEntry[] = [
|
||||||
|
...[
|
||||||
|
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
|
||||||
|
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
|
||||||
|
]
|
||||||
|
.filter((e) => match(e.label))
|
||||||
|
.map((e) => ({ ...e, group: 'Действия' })),
|
||||||
|
...navItems.filter((i) => match(i.label, i.group))
|
||||||
|
.slice(0, 7)
|
||||||
|
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
|
||||||
|
...Object.values(
|
||||||
|
palette.objects
|
||||||
|
.filter((o) => match(o.label, o.hint))
|
||||||
|
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
|
||||||
|
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
|
||||||
|
]
|
||||||
|
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
|
||||||
|
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'ai',
|
||||||
|
group: 'Ассистент · запрос на естественном языке',
|
||||||
|
label: `Понял так: «${query.trim()}»`,
|
||||||
|
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
|
||||||
|
run: () => void navigate({ to: palette.assistantTo }),
|
||||||
|
} as PaletteEntry,
|
||||||
|
...list,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
|
||||||
|
shown.forEach((e, index) => {
|
||||||
|
const list = map.get(e.group) ?? []
|
||||||
|
list.push({ ...e, index })
|
||||||
|
map.set(e.group, list)
|
||||||
|
})
|
||||||
|
return [...map.entries()]
|
||||||
|
}, [shown])
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setPaletteOpen(false)
|
||||||
|
setQuery('')
|
||||||
|
setActive(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
|
||||||
|
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
|
||||||
|
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
|
||||||
|
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
|
||||||
|
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
aria-label="Поиск и команды"
|
||||||
|
placeholder="Раздел, пользователь, приложение или действие…"
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
setActive(0)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActive((a) => Math.min(a + 1, shown.length - 1))
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActive((a) => Math.max(a - 1, 0))
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
shown[active]?.run()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Kbd>Esc</Kbd>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
|
||||||
|
) : (
|
||||||
|
groups.map(([group, list]) => (
|
||||||
|
<div key={group} className="flex flex-col">
|
||||||
|
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
|
||||||
|
{list.map((e) => {
|
||||||
|
const Icon = e.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={e.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={e.index === active}
|
||||||
|
onMouseEnter={() => setActive(e.index)}
|
||||||
|
onClick={() => {
|
||||||
|
e.run()
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
|
||||||
|
>
|
||||||
|
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
|
||||||
|
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
|
||||||
|
<span className="min-w-0 truncate">{e.label}</span>
|
||||||
|
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
|
||||||
|
<span>↑↓ выбор</span>
|
||||||
|
<span>↵ открыть</span>
|
||||||
|
<span>Esc закрыть</span>
|
||||||
|
<span className="ml-auto">{palette.example}</span>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
|
||||||
|
|
||||||
|
export const DATA_GRID_CELL_PAD = 'py-3'
|
||||||
|
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
|
||||||
|
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
|
||||||
|
|
||||||
|
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
|
||||||
|
rowBorder: true,
|
||||||
|
headerBackground: true,
|
||||||
|
headerBorder: true,
|
||||||
|
columnsResizable: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
|
||||||
|
headerRow: 'border-b border-border',
|
||||||
|
bodyRow: 'group/row',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
|
||||||
|
|
||||||
|
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
|
||||||
|
export function cellPad(index: number, count: number) {
|
||||||
|
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
|
||||||
|
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
|
||||||
|
return DATA_GRID_CELL_PAD
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
|
||||||
|
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
|
||||||
|
*/
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import type { Column } from '@tanstack/react-table'
|
||||||
|
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { InputGroup, InputGroupAddon, InputGroupInput } from '@evobgp/ui/components/input-group'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
||||||
|
|
||||||
|
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<Frame dense className={cn('w-full min-w-0', className)}>
|
||||||
|
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SegmentedControl<T extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
value: T
|
||||||
|
onChange: (value: T) => void
|
||||||
|
options: { value: T; label: string; count?: number }[]
|
||||||
|
label?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === o.value}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
|
||||||
|
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataPageToolbar({
|
||||||
|
leading,
|
||||||
|
segmented,
|
||||||
|
filterControl,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
searchPlaceholder = 'Поиск…',
|
||||||
|
countLabel,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
leading?: ReactNode
|
||||||
|
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
|
||||||
|
filterControl?: ReactNode
|
||||||
|
search?: string
|
||||||
|
onSearchChange?: (value: string) => void
|
||||||
|
searchPlaceholder?: string
|
||||||
|
countLabel?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
||||||
|
{leading}
|
||||||
|
{segmented ? <SegmentedControl {...segmented} /> : null}
|
||||||
|
{filterControl}
|
||||||
|
{onSearchChange ? (
|
||||||
|
<InputGroup className="max-w-sm min-w-[220px]">
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-3.5" aria-hidden />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
|
||||||
|
</InputGroup>
|
||||||
|
) : null}
|
||||||
|
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataGridSortHeader<TData extends object>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
column: Column<DataGridFeatures, TData, unknown>
|
||||||
|
title: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const sorted = column.getIsSorted()
|
||||||
|
if (!column.getCanSort()) {
|
||||||
|
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{sorted === 'asc' ? (
|
||||||
|
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' })
|
||||||
|
const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
||||||
|
function parse(iso: string | null | undefined): Date | null {
|
||||||
|
if (!iso) return null
|
||||||
|
const d = new Date(iso)
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 12.09.2026 */
|
||||||
|
export function fmtDate(iso: string | null | undefined, empty = '—') {
|
||||||
|
const d = parse(iso)
|
||||||
|
return d ? DATE.format(d) : empty
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */
|
||||||
|
export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) {
|
||||||
|
const d = parse(iso)
|
||||||
|
if (!d) return empty
|
||||||
|
const dayMs = 24 * 60 * 60 * 1000
|
||||||
|
const startOfToday = new Date(now)
|
||||||
|
startOfToday.setHours(0, 0, 0, 0)
|
||||||
|
const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0)
|
||||||
|
if (diff === 0) return `сегодня ${TIME.format(d)}`
|
||||||
|
if (diff === dayMs) return `вчера ${TIME.format(d)}`
|
||||||
|
return `${SHORT.format(d)} ${TIME.format(d)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Время дня 14:02. */
|
||||||
|
export function fmtTime(iso: string | null | undefined, empty = '—') {
|
||||||
|
const d = parse(iso)
|
||||||
|
return d ? TIME.format(d) : empty
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «5 дн назад», «3 ч назад», «только что». */
|
||||||
|
export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) {
|
||||||
|
const d = parse(iso)
|
||||||
|
if (!d) return empty
|
||||||
|
const sec = Math.max(0, Math.round((now - d.getTime()) / 1000))
|
||||||
|
if (sec < 60) return 'только что'
|
||||||
|
if (sec < 3600) return `${Math.floor(sec / 60)} мин назад`
|
||||||
|
if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад`
|
||||||
|
return `${Math.floor(sec / 86400)} дн назад`
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import type { KpiItem, KpiTone } from './kpi'
|
||||||
|
|
||||||
|
const VALUE: Record<KpiTone, string> = {
|
||||||
|
neutral: 'text-foreground',
|
||||||
|
ok: 'text-foreground',
|
||||||
|
in: 'text-foreground',
|
||||||
|
wn: 'text-warning',
|
||||||
|
er: 'text-destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
|
||||||
|
const COLS: Record<number, string> = {
|
||||||
|
1: 'grid-cols-1',
|
||||||
|
2: 'grid-cols-2 max-[640px]:grid-cols-1',
|
||||||
|
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
|
||||||
|
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TILE: Record<KpiTone, string> = {
|
||||||
|
neutral: 'text-muted-foreground',
|
||||||
|
ok: 'text-success',
|
||||||
|
in: 'text-info',
|
||||||
|
wn: 'text-warning',
|
||||||
|
er: 'text-destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
|
||||||
|
|
||||||
|
const BADGE = {
|
||||||
|
neutral: 'outline',
|
||||||
|
ok: 'success-light',
|
||||||
|
in: 'info-light',
|
||||||
|
wn: 'warning-light',
|
||||||
|
er: 'destructive-light',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полоса показателей как на досках: один Frame, ячейки разделены линиями,
|
||||||
|
* в ячейке подпись, крупное значение и плашка-подсказка.
|
||||||
|
*/
|
||||||
|
export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
|
||||||
|
return (
|
||||||
|
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
|
||||||
|
<FramePanel className="overflow-hidden p-0">
|
||||||
|
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
|
||||||
|
{items.map((k) => {
|
||||||
|
const Icon = k.icon
|
||||||
|
return (
|
||||||
|
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
|
||||||
|
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
|
||||||
|
<Icon />
|
||||||
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<div className="text-muted-foreground text-[13px]">{k.label}</div>
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
|
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
|
||||||
|
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
|
||||||
|
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
|
||||||
|
{k.hint}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
|
||||||
|
|
||||||
|
export type KpiItem = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
/** Плашка-подсказка рядом со значением. */
|
||||||
|
hint: string
|
||||||
|
tone: KpiTone
|
||||||
|
/** Иконка показателя: плитка слева от подписи. */
|
||||||
|
icon: LucideIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
|
||||||
|
export function kpi(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
value: string,
|
||||||
|
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
|
||||||
|
): KpiItem {
|
||||||
|
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
|
||||||
|
}
|
||||||
Loaded 100 of 302 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user