Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b13c233679 | ||
|
|
37f28dfcfd | ||
|
|
6c6e76fca3 | ||
|
|
26f5172f88 | ||
|
|
8fe74c1d3b | ||
|
|
13e3d21ce2 | ||
|
|
a0cfbcdab1 | ||
|
|
c3369059af | ||
|
|
53ce80c9ff | ||
|
|
1e04e91dd8 | ||
|
|
5fbe9c9b56 | ||
|
|
fd3a217cbe | ||
|
|
ff6efec4c5 | ||
|
|
738d2e2256 | ||
|
|
2e3e1493f5 | ||
|
|
fd2fd8298d | ||
|
|
df7cd99060 | ||
|
|
c273cea067 | ||
|
|
1f969e6cac | ||
|
|
c1132cbe19 | ||
|
|
b28ad88b22 | ||
|
|
b871d62de6 | ||
|
|
4d83b8d673 | ||
|
|
2820cff988 | ||
|
|
54b7ea3bd5 | ||
|
|
57bcfcd9e1 | ||
|
|
1317a73e9a | ||
|
|
039d2f3dd9 | ||
|
|
54a0b5b966 | ||
|
|
1639ba40f3 | ||
|
|
26a96bc824 | ||
|
|
4fc5c96e63 | ||
|
|
3859983ae5 | ||
|
|
aa1779170a | ||
|
|
bbcf9d76d9 | ||
|
|
55eb2a6c89 | ||
|
|
a3f3ffd672 | ||
|
|
1a142e68a9 | ||
|
|
46e7b2aa6b | ||
|
|
1152bb40f7 | ||
|
|
538daea0f1 | ||
|
|
39f1295438 |
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `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 for Agents
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -32,6 +32,34 @@ Common mistakes:
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||
|
||||
## event-calendar
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `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 for Agents
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -32,6 +32,34 @@ Common mistakes:
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||
|
||||
## event-calendar
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"pid": 43636,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1783570299043
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101206}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101204}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138640}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138642}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605143758}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605144457}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605145907}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152669}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152671}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222064}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222067}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605819320}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605827145}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605864391}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606471768}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606472923}
|
||||
@@ -0,0 +1,8 @@
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613834659}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613834740}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613854903}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613854967}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613897698}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613897769}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614164222}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614181455}
|
||||
@@ -11,9 +11,9 @@
|
||||
]
|
||||
},
|
||||
"reui": {
|
||||
"url": "https://mcp.reui.io/api/mcp?style=base-nova",
|
||||
"url": "https://mcp.reui.io",
|
||||
"headers": {
|
||||
"X-Reui-Style": "base-nova"
|
||||
"Authorization": "Bearer <REUI_LICENSE_KEY>"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# KPI hybrid — только kit (stats-12 DNA)
|
||||
|
||||
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
||||
|
||||
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc).
|
||||
|
||||
## MUST
|
||||
|
||||
| Зона | Компонент / DNA |
|
||||
|------|-----------------|
|
||||
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
||||
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
|
||||
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
|
||||
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
|
||||
|
||||
Импорты UI: `@evobgp/ui/components/*`.
|
||||
|
||||
## NEVER
|
||||
|
||||
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||
- Другой size / radius / solid brand fill вместо `bg-muted`
|
||||
- `card-35` как замена stats-12 hybrid KPI
|
||||
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
||||
- Голый lucide `size-4` в name-cell без hybrid tile
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
description: ReUI PRO (@reui) — MCP user-reui, Frame surface, kit, license, матрица выбора
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# ReUI MCP — обязательно (PRO + free)
|
||||
|
||||
Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
|
||||
|
||||
Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
|
||||
|
||||
| Документ | URL |
|
||||
|----------|-----|
|
||||
| **llms.txt** | https://reui.io/llms.txt |
|
||||
| **Get Started** | https://reui.io/docs/get-started |
|
||||
| **Styling** | https://reui.io/docs/styling |
|
||||
| **MCP** | https://reui.io/docs/mcp |
|
||||
| **Blocks** | https://reui.io/blocks |
|
||||
| **Settings blocks** | https://reui.io/blocks/application/settings |
|
||||
| **License** | https://reui.io/docs/license-setup |
|
||||
| **Base UI components** | https://reui.io/docs/components/base/<name> |
|
||||
|
||||
## Primary MCP
|
||||
|
||||
1. **`user-reui`** — `search` / `compose_page` / `get_block` / `get_component` / `get_install_command` / `validate_usage` / `get_audit_checklist`
|
||||
2. **`plugin-shadcn-shadcn`** — primitives `@shadcn`; для `@reui` — вторично
|
||||
|
||||
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
|
||||
|
||||
## Когда ReUI vs shadcn
|
||||
|
||||
| Задача | Registry | Импорт |
|
||||
|--------|----------|--------|
|
||||
| Button, Sheet, Field, Sidebar, Tabs | `@shadcn` | `@evobgp/ui/components/*` |
|
||||
| PRO pages/sections (settings, stats, auth, dashboard) | `@reui` blocks | adapt → `apps/web/src/components/` / `reui-kit/` |
|
||||
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
|
||||
| Filters | `@reui` | `@/components/reui/filters` |
|
||||
| Frame surface | `@reui` | `@/components/reui/frame` |
|
||||
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
|
||||
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
|
||||
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
|
||||
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
|
||||
|
||||
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
|
||||
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
|
||||
|
||||
## MCP workflow
|
||||
|
||||
0. Codegraph / поиск существующих `reui-kit/*`, `@/components/reui/*`
|
||||
1. `user-reui` `search` (`surface: "frame"`, `category` при известном)
|
||||
2. Страница целиком → `compose_page`; секция → `get_block`
|
||||
3. `get_component` для API primitives из `componentsUsed`
|
||||
4. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --yes`
|
||||
5. Post-add: shadcn imports → `@evobgp/ui/components/*`
|
||||
6. Adapt by reuse → kit / route
|
||||
7. `validate_usage` + `get_audit_checklist`
|
||||
|
||||
## Размещение
|
||||
|
||||
| Слой | Путь | Импорт |
|
||||
|------|------|--------|
|
||||
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
||||
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
||||
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
|
||||
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
|
||||
|
||||
## Установленные ReUI (apps/web)
|
||||
|
||||
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
|
||||
|
||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
|
||||
|
||||
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
|
||||
|
||||
## License
|
||||
|
||||
```env
|
||||
# .env.local (gitignored)
|
||||
REUI_LICENSE_KEY=
|
||||
```
|
||||
|
||||
`apps/web/components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
|
||||
|
||||
## Эталоны preview
|
||||
|
||||
| Зона | Preview |
|
||||
|------|---------|
|
||||
| KPI / Quick Actions | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
|
||||
| List | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||
| Settings | https://reui.io/preview/base/settings-16 |
|
||||
| Auth | https://reui.io/preview/base/auth-13 |
|
||||
| Shell | https://reui.io/preview/base/app-shell-12 |
|
||||
| Empty | https://reui.io/preview/base/empty-state-12 |
|
||||
|
||||
## Запрещено
|
||||
|
||||
- Копипаст с reui.io без CLI
|
||||
- ReUI в `packages/ui` / импорт как `@evobgp/ui`
|
||||
- Radix-варианты docs — только Base UI
|
||||
- Raw `bg-emerald-*` вместо ReUI `variant`
|
||||
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
|
||||
- Смешивать Card и Frame на ops-экране
|
||||
- Ставить shadcn/ui blocks выше ReUI PRO
|
||||
|
||||
## Чеклист
|
||||
|
||||
- [ ] `user-reui` search/get_block + previewUrl
|
||||
- [ ] `surface: frame`
|
||||
- [ ] CLI add из `apps/web` при новом item
|
||||
- [ ] Kit / `@/components/reui` / `@evobgp/ui` — правильный слой
|
||||
- [ ] `pnpm --filter @evobgp/web run build`
|
||||
@@ -11,7 +11,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `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 for Agents
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ alwaysApply: false
|
||||
# Web UI — React + shadcn/ui + ReUI
|
||||
|
||||
**Источники правды:**
|
||||
- **ReUI PRO first:** MCP `user-reui` ([`reui-mcp.mdc`](reui-mcp.mdc)) — pages / KPI / lists / settings / shell
|
||||
- shadcn primitives: MCP `plugin-shadcn-shadcn` (secondary)
|
||||
- ReUI Base UI: https://reui.io/docs/components/base/<name> · [llms.txt](https://reui.io/llms.txt)
|
||||
- shadcn/ui React: https://ui.shadcn.com/docs/components
|
||||
- ReUI Base UI: https://reui.io/docs/components/base/<name>
|
||||
- ReUI llms.txt: https://reui.io/llms.txt
|
||||
- MCP `plugin-shadcn-shadcn` (registries: `@shadcn`, `@reui`) — перед любой UI-задачей
|
||||
|
||||
Общие правила Go/API: `.cursor/rules/engineering.mdc`. Стек ID: `.cursor/rules/context7-stack.mdc`.
|
||||
Иерархия: **ReUI PRO > shadcn**. Общие: `.cursor/rules/engineering.mdc`, `context7-stack.mdc`.
|
||||
|
||||
## Слои UI
|
||||
|
||||
@@ -22,16 +22,19 @@ alwaysApply: false
|
||||
|------|------|------------|
|
||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
|
||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
|
||||
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell |
|
||||
| Роуты | `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`.
|
||||
|
||||
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
||||
|
||||
## Правила
|
||||
|
||||
**WEB-01** | MUST | Перед новым UI — MCP `plugin-shadcn-shadcn`: `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`. Только после — JSX.
|
||||
*Rationale:* единый источник правды и API.
|
||||
*Проверка:* review; нет самописных примитивов, если есть registry item.
|
||||
**WEB-01** | MUST | Перед новым UI — сначала MCP **`user-reui`** (`search` → `get_block` / `compose_page`, `surface: "frame"`) + cite `previewUrl`/`docsUrl`. Primitives — MCP `plugin-shadcn-shadcn`. Только после — JSX.
|
||||
*Rationale:* ReUI PRO выше shadcn; единый Frame surface.
|
||||
*Проверка:* review; [`reui-mcp.mdc`](reui-mcp.mdc).
|
||||
|
||||
**WEB-02** | MUST | Отсутствующий shadcn-примитив — `pnpm dlx shadcn@latest add <component>` (из `apps/web`). ReUI — `pnpm dlx shadcn@latest add @reui/<name>`.
|
||||
*Проверка:* файлы в `packages/ui/src/components/` (для shadcn) или `apps/web/src/components/reui/` (для ReUI).
|
||||
@@ -45,8 +48,8 @@ alwaysApply: false
|
||||
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||
|
||||
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
|
||||
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
|
||||
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
|
||||
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
|
||||
|
||||
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||
@@ -70,7 +73,7 @@ alwaysApply: false
|
||||
|
||||
**WEB-14** | SHOULD | Нетривиальный UI — прочитать страницу компонента shadcn/ReUI (props, a11y).
|
||||
|
||||
**WEB-15** | MUST | Сомнения — MCP `plugin-shadcn-shadcn` + shadcn CLI docs + `pnpm --filter @evobgp/web run typecheck`.
|
||||
**WEB-15** | MUST | Сомнения — MCP `user-reui` + `plugin-shadcn-shadcn` + docs + `pnpm --filter @evobgp/web run typecheck`.
|
||||
|
||||
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
|
||||
|
||||
@@ -95,7 +98,8 @@ pnpm --filter @evobgp/web run build
|
||||
**WEB-21** | MUST | Data fetching — TanStack Query (`useQuery`, `useMutation`, `queryOptions`); query-key factories в `apps/web/src/queries/`. Mutations invalidate keys, не refetch вручную.
|
||||
*Проверка:* review `queries/*.ts`.
|
||||
|
||||
**WEB-22** | MUST | Legacy Svelte — в `web-legacy-svelte/` (archive). Не использовать импорты оттуда в новом коде; только как референс при миграции роутов.
|
||||
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
|
||||
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `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 for Agents
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -32,6 +32,34 @@ Common mistakes:
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||
|
||||
## event-calendar
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
## Summary
|
||||
|
||||
<!-- 1–3 bullets: what and why -->
|
||||
|
||||
## Checklist
|
||||
|
||||
### General
|
||||
|
||||
- [ ] Docs updated if behavior/API changed (DOC-02)
|
||||
- [ ] Conventional Commits (EN title / RU body)
|
||||
|
||||
### Backend (if Go / OpenAPI / migrations)
|
||||
|
||||
- [ ] `go test ./... -race -count=1` (or scoped packages) when touching jobs/pipeline/httpapi
|
||||
- [ ] OpenAPI lint + regenerate `apps/web/src/types/api.gen.ts` if `docs/openapi.yaml` changed
|
||||
- [ ] Migration pairs postgres+sqlite (DEP-03)
|
||||
|
||||
### Frontend (if `apps/web` / `packages/ui`) — WEB-19
|
||||
|
||||
- [ ] `pnpm --filter @evobgp/web run typecheck`
|
||||
- [ ] `pnpm --filter @evobgp/web run lint`
|
||||
- [ ] `pnpm --filter @evobgp/web run build`
|
||||
- [ ] `pnpm --filter @evobgp/web run test`
|
||||
- [ ] UI follows ReUI PRO + `docs/ui-design-contract.md` (surface `frame`, kit; cite previewUrl)
|
||||
|
||||
### KPI / Quick Actions
|
||||
|
||||
- [ ] If changing `kpi-stat-grid.tsx` / `quick-action-grid.tsx`, note sibling-app sync (vps / CFDM / fw / auth-portal)
|
||||
@@ -150,15 +150,23 @@ jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
openapi:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.openapi == 'true'
|
||||
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: Lint OpenAPI (Redocly)
|
||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
||||
- name: Check OpenAPI→TS codegen is fresh
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
chmod +x scripts/check-openapi-gen.sh
|
||||
sh scripts/check-openapi-gen.sh
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
web:
|
||||
@@ -172,12 +180,13 @@ jobs:
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: pnpm install, typecheck, lint, build
|
||||
- name: pnpm install, typecheck, lint, test, build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run test
|
||||
pnpm --filter @evobgp/web run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -21,9 +21,14 @@ Thumbs.db
|
||||
|
||||
# Env / secrets (local only)
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
|
||||
deploy/compose/runtime-logs/
|
||||
# Local MCP configs (may contain REUI license Bearer)
|
||||
.cursor/mcp.json
|
||||
.mcp.json
|
||||
.codegraph/daemon.pid
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
},
|
||||
"reui": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.reui.io/api/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
engine-strict=true
|
||||
store-dir=C:\Users\shats\AppData\Local\pnpm-test-store\store\v10
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `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 for Agents
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -32,6 +32,34 @@ Common mistakes:
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||
|
||||
## event-calendar
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||
|
||||
@@ -69,6 +69,8 @@ pnpm --filter @evobgp/web run build
|
||||
|
||||
Все три команды должны exit 0. CI job `web` не пропускает без этого.
|
||||
|
||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
|
||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte UI удалён (миграция завершена).
|
||||
|
||||
UI-задачи начинаются с MCP `plugin-shadcn-shadcn` (search → examples → add command), затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
|
||||
**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`.
|
||||
|
||||
UI-задачи начинаются с MCP `user-reui` (`surface: "frame"`) + `plugin-shadcn-shadcn`, затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# EvoBGP web (Vite) — переменные окружения.
|
||||
# Скопируйте в apps/web/.env.local (файл в .gitignore) и заполните.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ReUI Pro/Ultimate — ключ с https://reui.io/account (для `pnpm dlx shadcn add @reui/*`)
|
||||
# ---------------------------------------------------------------------------
|
||||
# REUI_LICENSE_KEY=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App Switcher — JSON с описанием шапки «Приложения» (fallback, когда portal
|
||||
# недоступен либо VITE_AUTH_ENABLED=false).
|
||||
# Схема: см. apps/web/src/lib/app-switcher-config.ts.
|
||||
# ---------------------------------------------------------------------------
|
||||
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[...]}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-portal SSO
|
||||
# ---------------------------------------------------------------------------
|
||||
# Включает JWT-гейт через auth-portal вместо локального evobgp_api_token.
|
||||
# Пример:
|
||||
# VITE_AUTH_ENABLED=true
|
||||
# VITE_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
#
|
||||
# Backend опционально может отдавать GET /v1/auth/config
|
||||
# ({ "required": true, "portal_url": "https://auth.shnt.top" }) —
|
||||
# ответ имеет приоритет над VITE_* и позволяет менять режим без пересборки.
|
||||
# VITE_AUTH_ENABLED=false
|
||||
# VITE_AUTH_PORTAL_URL=http://localhost:5175
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Smoke against local API (+ optional UI). Profile: compose microvps-full.
|
||||
*
|
||||
* Env:
|
||||
* - EVOBGP_E2E_API_URL (default http://127.0.0.1:8080)
|
||||
* - EVOBGP_E2E_BASE_URL (default http://127.0.0.1:5173)
|
||||
* - EVOBGP_E2E_TOKEN (default dev)
|
||||
*/
|
||||
const apiBase = process.env.EVOBGP_E2E_API_URL ?? 'http://127.0.0.1:8080'
|
||||
const uiBase = process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173'
|
||||
const token = process.env.EVOBGP_E2E_TOKEN ?? 'dev'
|
||||
|
||||
test.describe('EvoBGP smoke', () => {
|
||||
test('API health and modules list', async ({ request }) => {
|
||||
const health = await request.get(`${apiBase}/v1/health`)
|
||||
expect(health.ok()).toBeTruthy()
|
||||
|
||||
const mods = await request.get(`${apiBase}/v1/modules?limit=10`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect([200, 401]).toContain(mods.status())
|
||||
})
|
||||
|
||||
test('UI loads when available', async ({ page }) => {
|
||||
test.skip(!process.env.EVOBGP_E2E_UI, 'set EVOBGP_E2E_UI=1 to enable UI smoke')
|
||||
await page.goto(uiBase)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import globals from 'globals'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
|
||||
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsr generate && tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"openapi:gen": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.ts",
|
||||
"openapi:check": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.check.ts && diff -q src/types/api.gen.ts src/types/api.gen.check.ts && rm -f src/types/api.gen.check.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.0.0",
|
||||
@@ -40,22 +43,24 @@
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@tanstack/router-plugin": "^1.130.0",
|
||||
"@tanstack/router-cli": "^1.130.0",
|
||||
"@tanstack/router-plugin": "^1.130.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.0.0",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.0",
|
||||
"globals": "^15.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"happy-dom": "^18.0.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"tw-animate-css": "^1.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 60_000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
use: {
|
||||
baseURL: process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { Plus, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
||||
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
@@ -47,7 +47,7 @@ export function AccessApiKeysCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
<FrameDataGrid
|
||||
title="API-ключи"
|
||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
||||
actions={
|
||||
@@ -85,7 +85,7 @@ export function AccessApiKeysCard({
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</FrameDataGrid>
|
||||
|
||||
<ApiKeyCreateDialog
|
||||
open={createOpen}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export type AnalyticsKpiItem = {
|
||||
@@ -12,39 +12,42 @@ export type AnalyticsKpiItem = {
|
||||
}
|
||||
}
|
||||
|
||||
const TONE_CLASS = {
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
muted: 'text-muted-foreground',
|
||||
} as const
|
||||
|
||||
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
|
||||
if (direction === 'up') return <TrendingUp className="size-3" />
|
||||
if (direction === 'down') return <TrendingDown className="size-3" />
|
||||
return <Minus className="size-3" />
|
||||
function deltaBadgeVariant(
|
||||
tone?: 'success' | 'warning' | 'destructive' | 'muted',
|
||||
): 'success-light' | 'warning-light' | 'destructive-light' | 'outline' {
|
||||
if (tone === 'success') return 'success-light'
|
||||
if (tone === 'warning') return 'warning-light'
|
||||
if (tone === 'destructive') return 'destructive-light'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
|
||||
function toKpiItem(item: AnalyticsKpiItem): KpiStatItem {
|
||||
return {
|
||||
id: item.label,
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
footer: item.delta ? (
|
||||
<Badge variant={deltaBadgeVariant(item.delta.tone)} size="sm">
|
||||
{item.delta.label}
|
||||
</Badge>
|
||||
) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact KPI row inside analytics panels (stats-12 embedded tiles). */
|
||||
export function AnalyticsKpiRow({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: AnalyticsKpiItem[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="min-w-0 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
|
||||
{item.delta ? (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
TONE_CLASS[item.delta.tone ?? 'muted'],
|
||||
)}
|
||||
>
|
||||
<DeltaIcon direction={item.delta.direction} />
|
||||
{item.delta.label}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
items={items.map(toKpiItem)}
|
||||
embedded
|
||||
className={cn(className)}
|
||||
aria-label="Показатели"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,4 +10,3 @@ export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
|
||||
export { DashboardPlatformCard } from './dashboard-platform-card'
|
||||
export { MonitoringHealthCard } from './monitoring-health-card'
|
||||
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
||||
export { OperationsAnalyticsCard } from './operations-analytics-card'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card'
|
||||
import { readinessBreakdown } from '@/lib/metrics'
|
||||
import type { ReadyStatus } from '@/queries/monitoring'
|
||||
|
||||
@@ -13,21 +12,25 @@ export function MonitoringHealthCard({
|
||||
loading?: boolean
|
||||
}) {
|
||||
const slices = readinessBreakdown(ready, healthOk)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DonutBreakdownCard
|
||||
title="Доступность системы"
|
||||
description="Проверки живучести и готовности"
|
||||
slices={[]}
|
||||
centerLabel="Проверки"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
<DonutBreakdownCard
|
||||
title="Доступность системы"
|
||||
description="Проверки живучести и готовности"
|
||||
info="Диаграмма отражает результат GET /v1/health и проверок из GET /v1/ready."
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Проверки" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
description="GET /v1/health · GET /v1/ready"
|
||||
slices={slices}
|
||||
centerLabel="Проверки"
|
||||
badge={healthOk ? 'API OK' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card'
|
||||
import { SegmentedProgressCard } from '@/components/patterns/segmented-progress-card'
|
||||
import { peerSessionBreakdown } from '@/lib/metrics'
|
||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
@@ -16,51 +15,44 @@ export function NetworkOverviewAnalyticsCard({
|
||||
}) {
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
const slices = peerSessionBreakdown(peers)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
const speakersPct =
|
||||
net.speakersTotal > 0 ? Math.round((net.speakersOnline / net.speakersTotal) * 100) : 0
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DonutBreakdownCard
|
||||
title="Сводка BGP"
|
||||
description="Установленные сессии и спикеры"
|
||||
slices={[]}
|
||||
centerLabel="Пиры"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Сводка BGP"
|
||||
description="Установленные сессии, доступность спикеров и расхождения по live-данным"
|
||||
info="Снимок текущего состояния пиров и спикеров."
|
||||
>
|
||||
<AnalyticsKpiRow
|
||||
items={[
|
||||
{
|
||||
label: 'Пиры с установленной сессией',
|
||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
delta: {
|
||||
direction: net.peersMismatch > 0 ? 'down' : 'up',
|
||||
label: net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'сессии в норме',
|
||||
tone: net.peersMismatch > 0 ? 'warning' : 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Спикеры в сети',
|
||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
delta: {
|
||||
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
|
||||
label:
|
||||
net.speakersOnline < net.speakersTotal
|
||||
? `${net.speakersTotal - net.speakersOnline} не в сети`
|
||||
: 'все в сети',
|
||||
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Пиры всего',
|
||||
value: loading ? '—' : String(net.peersTotal),
|
||||
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
|
||||
},
|
||||
]}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<DonutBreakdownCard
|
||||
title="Сводка BGP"
|
||||
description="Распределение состояний пиров"
|
||||
slices={slices}
|
||||
centerLabel="Пиры"
|
||||
badge={net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'в норме'}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
<SegmentedProgressCard
|
||||
title="Спикеры"
|
||||
description="Доступность live-агентов"
|
||||
primary={{
|
||||
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
label: 'Online',
|
||||
percent: speakersPct,
|
||||
}}
|
||||
secondary={{
|
||||
value: net.speakersTotal - net.speakersOnline,
|
||||
label: 'Offline',
|
||||
percent: 100 - speakersPct,
|
||||
}}
|
||||
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { jobStatusBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, RevisionRow } from '@/types/api'
|
||||
|
||||
export function OperationsAnalyticsCard({
|
||||
jobs,
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
|
||||
const failed = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const slices = jobStatusBreakdown(jobs)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Операции и задачи"
|
||||
description="Статистика ревизий и фоновых jobs"
|
||||
info="Данные из GET /v1/jobs и /v1/revisions."
|
||||
>
|
||||
<AnalyticsKpiRow
|
||||
items={[
|
||||
{
|
||||
label: 'Ревизий',
|
||||
value: loading ? '—' : String(revisions.length),
|
||||
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: loading ? '—' : String(running),
|
||||
delta: {
|
||||
direction: running > 0 ? 'up' : 'neutral',
|
||||
label: running > 0 ? 'выполняются' : 'очередь пуста',
|
||||
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'С ошибкой',
|
||||
value: loading ? '—' : String(failed),
|
||||
delta: {
|
||||
direction: failed > 0 ? 'down' : 'up',
|
||||
label: failed > 0 ? 'требуют внимания' : 'в норме',
|
||||
tone: failed > 0 ? 'destructive' : 'success',
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
|
||||
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
getCurrentApp,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
|
||||
/** Sidebar app switcher — shared chrome etalon EvoBGP. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppSwitcher() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
const current = getCurrentApp(config)
|
||||
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<CurrentIcon className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{current.name}</span>
|
||||
{current.subtitle ? (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{current.subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||
</div>
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
||||
const isCurrent = app.id === CURRENT_APP_ID
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<DropdownMenuItem key={app.id} disabled>
|
||||
<Icon />
|
||||
{app.name}
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
nativeButton={false}
|
||||
render={<a href={app.url} />}
|
||||
>
|
||||
<Icon />
|
||||
{app.name}
|
||||
{app.shortcut ? (
|
||||
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -40,14 +40,12 @@ export function BadgeTabs({
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
orientation="horizontal"
|
||||
className={cn('w-full', className)}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={cn(
|
||||
'mb-4 w-full justify-start gap-6',
|
||||
listClassName,
|
||||
)}
|
||||
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
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="billing@acme.dev"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"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" />
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,223 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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="alex@acme.dev"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from "@evobgp/ui/components/field"
|
||||
|
||||
interface SettingRowProps {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
titleAddon?: ReactNode
|
||||
}
|
||||
|
||||
export function SettingRow({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
last,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
titleAddon,
|
||||
}: SettingRowProps) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-5 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
{titleAddon}
|
||||
</div>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||
>
|
||||
<div className="flex w-full justify-start @md/field-group:justify-end">
|
||||
{children}
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
interface SettingsCardProps {
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
footerClassName?: string
|
||||
}
|
||||
|
||||
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
|
||||
export function SettingsCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
contentClassName,
|
||||
footerClassName,
|
||||
}: SettingsCardProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
<FrameHeader className="gap-0 border-b px-5 py-3">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</FrameHeader>
|
||||
|
||||
<div className={cn('min-w-0', contentClassName)}>{children}</div>
|
||||
|
||||
{footer ? (
|
||||
<FrameFooter
|
||||
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
|
||||
>
|
||||
{footer}
|
||||
</FrameFooter>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
|
||||
interface SettingsFieldGroupProps {
|
||||
legend: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
fieldGroupClassName?: string
|
||||
}
|
||||
|
||||
export function SettingsFieldGroup({
|
||||
legend,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
fieldGroupClassName,
|
||||
}: SettingsFieldGroupProps) {
|
||||
return (
|
||||
<FieldSet className={cn("gap-0", className)}>
|
||||
<FieldLegend className="sr-only">{legend}</FieldLegend>
|
||||
{/* Description */}
|
||||
<FieldDescription className="sr-only">{description}</FieldDescription>
|
||||
{/* List */}
|
||||
<FieldGroup className={cn("gap-0", fieldGroupClassName)}>
|
||||
{children}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
function grain(i: number, j: number) {
|
||||
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
|
||||
return n - Math.floor(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Static dot field adapted from REUI card-17: theme-aware canvas dots,
|
||||
* repainted on resize and theme change.
|
||||
*/
|
||||
export function CardDotField({ className }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const GAP = 3
|
||||
const DOT = 1.5
|
||||
const BASE = 0.05
|
||||
const PEAK = 0.32
|
||||
|
||||
const draw = () => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
canvas.width = Math.round(rect.width * dpr)
|
||||
canvas.height = Math.round(rect.height * dpr)
|
||||
|
||||
ctx.fillStyle = getComputedStyle(canvas).color || 'rgb(115,115,115)'
|
||||
ctx.fillRect(0, 0, 1, 1)
|
||||
const px = ctx.getImageData(0, 0, 1, 1).data
|
||||
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
ctx.fillStyle = color
|
||||
|
||||
const cols = Math.ceil(rect.width / GAP) + 1
|
||||
const rows = Math.ceil(rect.height / GAP) + 1
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const x = i * GAP
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const q = grain(i, j)
|
||||
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
|
||||
let a = (BASE + (PEAK - BASE) * q * q) * amp
|
||||
if (a > 1) a = 1
|
||||
ctx.globalAlpha = a
|
||||
ctx.fillRect(x, j * GAP, DOT, DOT)
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => draw())
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
const themeObserver = new MutationObserver(() => draw())
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
themeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className={cn('pointer-events-none absolute inset-0 h-full w-full', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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"
|
||||
>
|
||||
{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">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
ListChecks,
|
||||
Network,
|
||||
ServerCog,
|
||||
Share2,
|
||||
} from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type KpiCard = KpiStatItem & { icon: ReactNode }
|
||||
|
||||
function buildKpis({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}): KpiCard[] {
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const network = aggregateNetworkMetrics(peers, speakers)
|
||||
const peersEnabled = network.peersEnabled
|
||||
const bgpPct =
|
||||
peersEnabled > 0 ? Math.round((network.peersEstablished / peersEnabled) * 100) : null
|
||||
const running = runningJobCount(jobs)
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
||||
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'modules',
|
||||
icon: <Boxes aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
||||
label: 'Модули активны',
|
||||
footer: (
|
||||
<Badge variant="primary-light" size="sm">
|
||||
{loading ? '…' : `${modules.length} всего`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
icon: <Network aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||
label: 'BGP готовность',
|
||||
footer: (
|
||||
<Badge
|
||||
variant={
|
||||
bgpPct !== null && bgpPct >= 90
|
||||
? 'success-light'
|
||||
: bgpPct !== null && bgpPct < 70
|
||||
? 'warning-light'
|
||||
: 'outline'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading || bgpPct === null
|
||||
? 'нет включённых пиров'
|
||||
: `${network.peersEstablished} установлено`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'peers',
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||
label: 'Пиры Established',
|
||||
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: 'Спикеры online',
|
||||
footer: (
|
||||
<Badge
|
||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'live-снимок'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
icon: <ListChecks aria-hidden />,
|
||||
iconClassName: 'text-focus',
|
||||
value: loading ? '—' : String(running),
|
||||
label: 'Активные задачи',
|
||||
footer: (
|
||||
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||
{loading ? '…' : `${jobs.length} в выборке`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'risks',
|
||||
icon: <AlertTriangle aria-hidden />,
|
||||
iconClassName: 'text-destructive',
|
||||
value: loading ? '—' : String(riskCount),
|
||||
label: 'Риски',
|
||||
variant: riskCount > 0 ? 'destructive' : 'default',
|
||||
footer: (
|
||||
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||
{loading
|
||||
? '…'
|
||||
: riskCount > 0
|
||||
? `${failedJobs} задач · ${network.peersMismatch} расхождений`
|
||||
: 'в норме'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function buildDashboardKpiCards({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
return buildKpis({ modules, peers, speakers, jobs, loading }).map((card) => ({
|
||||
...card,
|
||||
id: card.id ?? String(card.label),
|
||||
}))
|
||||
}
|
||||
|
||||
export function DashboardKpiGrid({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
return (
|
||||
<KpiStatGrid
|
||||
items={buildKpis({ modules, peers, speakers, jobs, loading })}
|
||||
aria-label="KPI обзора"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
|
||||
import { kpiGridClassName } from '@/lib/ui-surface'
|
||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
function syntheticSparkline(seed: number, points = 9): number[] {
|
||||
const base = Math.max(4, seed)
|
||||
return Array.from({ length: points }, (_, i) =>
|
||||
Math.round(base * (0.82 + (i / points) * 0.18 + Math.sin(i + seed) * 0.04)),
|
||||
)
|
||||
}
|
||||
|
||||
function buildMetrics({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}): KpiSparklineMetric[] {
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const network = aggregateNetworkMetrics(peers, speakers)
|
||||
const bgpPct =
|
||||
network.peersEnabled > 0
|
||||
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
|
||||
: 0
|
||||
const running = runningJobCount(jobs)
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'bgp',
|
||||
title: 'BGP готовность',
|
||||
label: 'Established / включённые',
|
||||
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: 'Online / всего',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
delta:
|
||||
loading || network.speakersTotal === 0
|
||||
? '…'
|
||||
: network.speakersOnline === network.speakersTotal
|
||||
? 'все online'
|
||||
: 'частично',
|
||||
deltaVariant:
|
||||
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
||||
detail: loading ? '' : 'live-снимок',
|
||||
tone: network.speakersOnline === network.speakersTotal ? 'success' : 'warning',
|
||||
sparkline: syntheticSparkline(network.speakersOnline || 2),
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
title: 'Задачи',
|
||||
label: 'Активные / ошибки',
|
||||
value: loading ? '—' : String(running),
|
||||
delta: loading ? '…' : failedJobs > 0 ? `${failedJobs} ошибок` : 'без сбоев',
|
||||
deltaVariant: failedJobs > 0 ? 'destructive-light' : 'success-light',
|
||||
detail: loading ? '' : `${jobs.length} в выборке`,
|
||||
tone: failedJobs > 0 ? 'danger' : running > 0 ? 'info' : 'success',
|
||||
sparkline: syntheticSparkline(running + failedJobs || 1),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function DashboardKpiSparklineRow({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const metrics = useMemo(
|
||||
() => buildMetrics({ modules, peers, speakers, jobs, loading }),
|
||||
[modules, peers, speakers, jobs, loading],
|
||||
)
|
||||
|
||||
return (
|
||||
<section aria-label="KPI обзора" className={kpiGridClassName}>
|
||||
{metrics.map((metric) => (
|
||||
<KpiSparklineCard key={metric.id} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
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, ModeBadge } from '@/components/category-badge'
|
||||
import { DataGridMutedCell, 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 { 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' | 'last_refreshed_at'
|
||||
type EnabledFilter = 'all' | 'enabled' | 'disabled'
|
||||
|
||||
const sortLabels: Record<ModuleSort, string> = {
|
||||
name: 'Название',
|
||||
type: 'Тип',
|
||||
priority: 'Приоритет',
|
||||
last_refreshed_at: 'Обновлено',
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
|
||||
|
||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
||||
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }]
|
||||
}
|
||||
|
||||
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" className="max-w-[240px]" />
|
||||
</div>
|
||||
),
|
||||
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>
|
||||
),
|
||||
meta: { headerTitle: 'Приоритет' },
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.last_refreshed_at
|
||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
sortingFn: (a, b) =>
|
||||
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
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="Поиск, сортировка и быстрый переход к настройке"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
||||
<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: 'fixed',
|
||||
}}
|
||||
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<InputGroup className="w-full min-w-40 sm:max-w-xs">
|
||||
<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
|
||||
sizes={[10, 15, 20]}
|
||||
info="{from}–{to} из {count}"
|
||||
className="py-0"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center text-sm">0 модулей</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataGrid>
|
||||
</DashboardFramePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { SegmentedProgressCard } from '@/components/patterns/segmented-progress-card'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
capacityUtilization,
|
||||
peerCapacityBars,
|
||||
speakerCapacityBars,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type CapacityMode = 'peers' | 'speakers'
|
||||
|
||||
export function DashboardNetworkHealth({
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<CapacityMode>('peers')
|
||||
|
||||
const bars = useMemo(
|
||||
() => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)),
|
||||
[mode, peers, speakers],
|
||||
)
|
||||
const utilization = capacityUtilization(bars)
|
||||
const queued = runningJobCount(jobs)
|
||||
|
||||
const established =
|
||||
mode === 'peers'
|
||||
? peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length
|
||||
: speakers.filter((s) => s.live?.agent_ok).length
|
||||
const total =
|
||||
mode === 'peers' ? peers.filter((p) => p.enabled !== false).length : speakers.length
|
||||
const offline = Math.max(0, total - established)
|
||||
|
||||
return (
|
||||
<SegmentedProgressCard
|
||||
title="Загрузка BGP"
|
||||
description="Утилизация сессий по пирам и спикерам"
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'peers', label: 'Пиры' },
|
||||
{ value: 'speakers', label: 'Спикеры' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
primary={{
|
||||
value: loading ? '—' : `${utilization}%`,
|
||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры online',
|
||||
percent: loading ? 0 : utilization,
|
||||
badge: (
|
||||
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
|
||||
{loading ? '…' : `${established}/${total}`}
|
||||
</Badge>
|
||||
),
|
||||
}}
|
||||
secondary={{
|
||||
value: loading ? '—' : offline,
|
||||
label: mode === 'peers' ? 'Не Established' : 'Offline',
|
||||
percent: total > 0 ? Math.round((offline / total) * 100) : 0,
|
||||
}}
|
||||
footer={
|
||||
loading
|
||||
? 'Загрузка…'
|
||||
: `Активных задач: ${queued} · сегментов в графике: ${bars.length}`
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, ModuleRow } from '@/types/api'
|
||||
|
||||
type FlowMode = 'jobs' | 'modules'
|
||||
|
||||
/** chart-13 inspired donut breakdown (Card surface). */
|
||||
export function DashboardOperationsBreakdown({
|
||||
jobs,
|
||||
modules,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
modules: ModuleRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<FlowMode>('jobs')
|
||||
|
||||
const slices = useMemo(
|
||||
() => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)),
|
||||
[mode, jobs, modules],
|
||||
)
|
||||
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Поток операций"
|
||||
description="Распределение задач и типов модулей"
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'jobs', label: 'Задачи' },
|
||||
{ value: 'modules', label: 'Модули' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="p-4">
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
||||
{slices.length > 0 ? (
|
||||
<ul className="mt-4 flex min-w-0 flex-col">
|
||||
{slices.map((slice, index) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<li key={slice.key}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
||||
style={{ backgroundColor: slice.color }}
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">{slice.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
|
||||
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
export function DashboardQuickActions() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
|
||||
<Plus className="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
|
||||
<Tags className="size-4" />
|
||||
Добавить BGP-сообщество
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'overview' }} />}>
|
||||
<Network className="size-4" />
|
||||
Сеть
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'peers' }} />}>
|
||||
<Share2 className="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
render={<Link to="/operations" search={{ tab: 'revisions' }} />}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Деплой
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}>
|
||||
<Gauge className="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Gauge, Network, Play, Plus, Search, Share2, Tags } from 'lucide-react'
|
||||
|
||||
import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit'
|
||||
|
||||
/** iconClassName: semantic text only (shared chrome with CFDM). @see https://reui.io/preview/base/stats-12 */
|
||||
const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'lookup',
|
||||
title: 'Проверка IP/домена',
|
||||
description: 'Membership в списках и community (entry + snapshot).',
|
||||
to: '/lookup',
|
||||
icon: <Search aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
{
|
||||
id: 'new-module',
|
||||
title: 'Создать модуль',
|
||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||
to: '/modules/new',
|
||||
icon: <Plus aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
{
|
||||
id: 'communities',
|
||||
title: 'BGP-сообщества',
|
||||
description: 'Справочник communities для политик экспорта.',
|
||||
to: '/directories',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
title: 'Сеть',
|
||||
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
||||
to: '/network',
|
||||
search: { tab: 'overview' },
|
||||
icon: <Network aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
},
|
||||
{
|
||||
id: 'add-peer',
|
||||
title: 'Добавить пира',
|
||||
description: 'Настройка BGP-соседа и шаблонов сессии.',
|
||||
to: '/network',
|
||||
search: { tab: 'peers' },
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
},
|
||||
{
|
||||
id: 'deploy',
|
||||
title: 'Деплой',
|
||||
description: 'Ревизии конфигурации и применение на нодах.',
|
||||
to: '/operations',
|
||||
search: { tab: 'revisions' },
|
||||
icon: <Play aria-hidden />,
|
||||
iconClassName: 'text-muted-foreground',
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
title: 'Мониторинг',
|
||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||
to: '/monitoring',
|
||||
search: { tab: 'system' },
|
||||
icon: <Gauge aria-hidden />,
|
||||
iconClassName: 'text-destructive',
|
||||
},
|
||||
]
|
||||
|
||||
export function DashboardQuickLinks() {
|
||||
return (
|
||||
<QuickActionGrid
|
||||
actions={ACTIONS}
|
||||
description="Частые переходы к настройке и деплою"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -4,10 +4,15 @@ import type { Table } from '@tanstack/react-table'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { DataGridToolbar } from '@/components/data-grid-toolbar'
|
||||
import { PanelCard, panelCardContentFlushClassName, panelCardFooterClassName, panelCardInsetClassName } from '@/components/panel-card'
|
||||
import {
|
||||
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,
|
||||
@@ -52,33 +57,37 @@ export function DataGridShell<TData extends object>({
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
{showPagination ? (
|
||||
<div className={cn(panelCardFooterClassName, panelCardInsetClassName)}>
|
||||
<FrameFooter className={cn(panelCardFooterClassName)}>
|
||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
||||
</div>
|
||||
</FrameFooter>
|
||||
) : null}
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataGridCardProps {
|
||||
title?: string
|
||||
description?: string
|
||||
/** @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
|
||||
}
|
||||
|
||||
export function DataGridCard({ title, description, actions, children, className }: DataGridCardProps) {
|
||||
}) {
|
||||
return (
|
||||
<PanelCard
|
||||
<FrameDataGrid
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
className={className}
|
||||
contentClassName={panelCardContentFlushClassName}
|
||||
>
|
||||
{children}
|
||||
</PanelCard>
|
||||
<div className={panelCardContentFlushClassName}>{children}</div>
|
||||
</FrameDataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,13 @@ 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 row for data grids (ReUI c-input-group-37 pattern). */
|
||||
/** Search + optional ReUI Filters row for Frame data grids. */
|
||||
export function DataGridToolbar({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
@@ -58,19 +59,19 @@ export function DataGridToolbar({
|
||||
filters
|
||||
) : (
|
||||
<InputGroupButton
|
||||
aria-label="Фильтры"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="pointer-events-none gap-1.5 opacity-0"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
disabled
|
||||
title="Передайте filters={<Filters … />} из @/components/reui/filters"
|
||||
>
|
||||
<ListFilterIcon className="size-3.5" />
|
||||
<ListFilterIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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="alex.chen@example.com"
|
||||
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,195 +0,0 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { FirewallClient } from '@/types/api'
|
||||
|
||||
function formatPacketCount(value?: number | null): string | null {
|
||||
if (value == null || value <= 0) return null
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export interface FirewallClientsGridProps {
|
||||
clients: FirewallClient[]
|
||||
isLoading?: boolean
|
||||
onApprove: (id: string) => void
|
||||
onReject: (id: string) => void
|
||||
approvePending?: boolean
|
||||
rejectPending?: boolean
|
||||
emptyTitle?: string
|
||||
}
|
||||
|
||||
export function FirewallClientsGrid({
|
||||
clients,
|
||||
isLoading = false,
|
||||
onApprove,
|
||||
onReject,
|
||||
approvePending = false,
|
||||
rejectPending = false,
|
||||
emptyTitle = 'Нет клиентов',
|
||||
}: FirewallClientsGridProps) {
|
||||
const columns = useMemo<ColumnDef<FirewallClient>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.name}
|
||||
subtitle={row.original.hostname || row.original.token_prefix}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'last_seen_at',
|
||||
accessorFn: (row) => row.last_seen_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последняя активность" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
|
||||
),
|
||||
sortingFn: (a, b) => {
|
||||
const av = a.original.last_seen_at ?? ''
|
||||
const bv = b.original.last_seen_at ?? ''
|
||||
return av.localeCompare(bv)
|
||||
},
|
||||
meta: { headerTitle: 'Последняя активность' },
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
enableSorting: false,
|
||||
header: 'Применение',
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{c.last_apply_status ?? '—'}
|
||||
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Применение' },
|
||||
},
|
||||
{
|
||||
id: 'packets',
|
||||
enableSorting: false,
|
||||
header: 'Пакеты',
|
||||
cell: ({ row }) => {
|
||||
const dropped = formatPacketCount(row.original.last_apply_packets_dropped)
|
||||
const accepted = formatPacketCount(row.original.last_apply_packets_accepted)
|
||||
if (!dropped && !accepted) {
|
||||
return <span className="text-muted-foreground text-xs">—</span>
|
||||
}
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{dropped ? <span className="text-destructive">↓{dropped}</span> : null}
|
||||
{dropped && accepted ? ' · ' : null}
|
||||
{accepted ? <span className="text-success">↑{accepted}</span> : null}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Пакеты' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
{c.status === 'pending' ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={approvePending}
|
||||
onClick={() => onApprove(c.id)}
|
||||
>
|
||||
Одобрить
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={rejectPending}
|
||||
>
|
||||
Отклонить
|
||||
</Button>
|
||||
}
|
||||
title="Отклонить запрос?"
|
||||
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
|
||||
confirmLabel="Отклонить"
|
||||
destructive
|
||||
onConfirm={() => onReject(c.id)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{c.status === 'approved' ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={rejectPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить клиент?"
|
||||
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={() => onReject(c.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[approvePending, onApprove, onReject, rejectPending],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: clients,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyTitle}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск клиентов…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { useCreateFirewallRule } from '@/queries/firewall'
|
||||
import type { BgpCommunity } from '@/types/api'
|
||||
|
||||
const FIREWALL_ACTION_ITEMS = [
|
||||
{ value: 'block', label: 'block' },
|
||||
{ value: 'accept', label: 'accept' },
|
||||
] as const
|
||||
|
||||
interface FirewallRuleCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
communities: BgpCommunity[]
|
||||
}
|
||||
|
||||
export function FirewallRuleCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
communities,
|
||||
}: FirewallRuleCreateDialogProps) {
|
||||
const createMutation = useCreateFirewallRule()
|
||||
const [action, setAction] = useState<'block' | 'accept'>('block')
|
||||
const [communityId, setCommunityId] = useState<string | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setAction('block')
|
||||
setCommunityId(null)
|
||||
setComment('')
|
||||
}, [open])
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
scope: 'tenant',
|
||||
action,
|
||||
community_id: communityId,
|
||||
comment: comment.trim(),
|
||||
})
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast handled in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новое правило"
|
||||
className="sm:max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" onClick={save} loading={createMutation.isPending}>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
id="fw-rule-action"
|
||||
label="Действие"
|
||||
items={[...FIREWALL_ACTION_ITEMS]}
|
||||
value={action}
|
||||
placeholder="Выберите действие"
|
||||
onValueChange={(v) => v && setAction(v as 'block' | 'accept')}
|
||||
/>
|
||||
<CommunitySelect
|
||||
id="fw-rule-community"
|
||||
label="Community"
|
||||
value={communityId}
|
||||
onValueChange={setCommunityId}
|
||||
communities={communities}
|
||||
nullable
|
||||
placeholder="Все communities"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="fw-rule-comment">Комментарий</Label>
|
||||
<Input
|
||||
id="fw-rule-comment"
|
||||
placeholder="Комментарий"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { communityLabel } from '@/lib/modules/helpers'
|
||||
import type { BgpCommunity, FirewallRule } from '@/types/api'
|
||||
|
||||
export interface FirewallRulesGridProps {
|
||||
rules: FirewallRule[]
|
||||
communities: BgpCommunity[]
|
||||
isLoading?: boolean
|
||||
onDelete: (id: string) => void
|
||||
deletePending?: boolean
|
||||
emptyTitle?: string
|
||||
}
|
||||
|
||||
export function FirewallRulesGrid({
|
||||
rules,
|
||||
communities,
|
||||
isLoading = false,
|
||||
onDelete,
|
||||
deletePending = false,
|
||||
emptyTitle = 'Нет правил — blocklist пуст (default accept).',
|
||||
}: FirewallRulesGridProps) {
|
||||
const columns = useMemo<ColumnDef<FirewallRule>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
|
||||
cell: ({ row }) => row.original.priority,
|
||||
meta: { headerTitle: '#' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge status={row.original.action} label={row.original.action} />
|
||||
),
|
||||
meta: { headerTitle: 'Действие' },
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
enableSorting: false,
|
||||
header: 'Community',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">
|
||||
{row.original.community_id
|
||||
? communityLabel(row.original.community_id, communities)
|
||||
: 'Все'}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Community' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'comment',
|
||||
enableSorting: false,
|
||||
header: 'Комментарий',
|
||||
cell: ({ row }) => row.original.comment || '—',
|
||||
meta: { headerTitle: 'Комментарий' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={deletePending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить правило?"
|
||||
description={
|
||||
r.comment
|
||||
? `Правило #${r.priority} (${r.action}): ${r.comment}`
|
||||
: `Правило #${r.priority} (${r.action}) будет удалено.`
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={() => onDelete(r.id)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[communities, deletePending, onDelete],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: rules,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyTitle}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск правил…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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'
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
BookText,
|
||||
KeyRound,
|
||||
ServerCog,
|
||||
Shield,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -36,16 +36,26 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from '@evobgp/ui/components/breadcrumb'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
|
||||
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { ComponentType, CSSProperties, ReactNode } from 'react'
|
||||
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
||||
import { NavUser } from '@/components/layout/nav-user'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
description?: string
|
||||
search?: Record<string, string>
|
||||
/** Explicit permission override; when omitted derived from `to`. */
|
||||
permission?: string
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
@@ -56,119 +66,166 @@ interface NavGroup {
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Обзор',
|
||||
items: [{ to: '/dashboard', label: 'Панель', icon: LayoutDashboard }],
|
||||
items: [
|
||||
{
|
||||
to: '/dashboard',
|
||||
label: 'Панель',
|
||||
icon: LayoutDashboard,
|
||||
description: 'KPI, модули и активность',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Маршрутизация',
|
||||
items: [
|
||||
{ to: '/modules', label: 'Модули', icon: Boxes },
|
||||
{ to: '/network', label: 'Сеть', icon: Network },
|
||||
{ to: '/directories', label: 'Справочники', icon: BookText },
|
||||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
||||
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
||||
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
||||
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Операции',
|
||||
items: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog },
|
||||
{ to: '/firewall', label: 'Файрвол', icon: Shield },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Система',
|
||||
items: [
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound },
|
||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog },
|
||||
{ to: '/settings', label: 'Настройки UI', icon: Settings },
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
||||
|
||||
/** Nav visible for the current claim (or all items when portal auth is off). */
|
||||
function useVisibleNavGroups(): NavGroup[] {
|
||||
if (!isAuthEnabled()) return NAV_GROUPS
|
||||
return NAV_GROUPS.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => {
|
||||
const perm = item.permission ?? permissionForPath(item.to)
|
||||
return !perm || can(perm)
|
||||
}),
|
||||
})).filter((group) => group.items.length > 0)
|
||||
}
|
||||
|
||||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||
)
|
||||
|
||||
const PARENT_ROUTE: Record<string, string> = {}
|
||||
const PARENT_ROUTE: Record<string, string> = {
|
||||
'/modules/new': '/modules',
|
||||
}
|
||||
|
||||
const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
|
||||
id: item.to,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
to: item.to,
|
||||
search: item.search,
|
||||
keywords: [item.to.replace(/^\//, '')],
|
||||
}))
|
||||
|
||||
/**
|
||||
* Shared ops chrome etalon for CFDM / vps-tracker.
|
||||
* @see https://reui.io/preview/base/app-shell-12
|
||||
* @see docs/ui-design-contract.md — Shared App Shell chrome
|
||||
*/
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const visibleGroups = useVisibleNavGroups()
|
||||
const activeItem =
|
||||
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
||||
ALL_NAV_ITEMS[0]
|
||||
const parentTo = PARENT_ROUTE[activeItem.to]
|
||||
const parentTo = PARENT_ROUTE[pathname] ?? PARENT_ROUTE[activeItem.to]
|
||||
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
|
||||
|
||||
const pageTitle =
|
||||
pathname.startsWith('/modules/') && pathname !== '/modules/new'
|
||||
? 'Модуль'
|
||||
: (ROUTE_LABELS[activeItem.to] ?? activeItem.label)
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
|
||||
B
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-semibold">EvoBGP</span>
|
||||
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{parentLabel && parentTo ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
</>
|
||||
) : null}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? activeItem.label}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { LayoutGridIcon } from 'lucide-react'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
||||
|
||||
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppsMenu() {
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="Приложения" />
|
||||
}
|
||||
>
|
||||
<LayoutGridIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-72"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||
</DropdownMenuLabel>
|
||||
<div className="grid grid-cols-3 gap-1 p-1">
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
||||
const isCurrent = app.id === CURRENT_APP_ID
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
disabled
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
nativeButton={false}
|
||||
render={<a href={app.url} />}
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{isAuthEnabled() ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={authPortalUrl()} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настроить на портале
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Kbd } from '@evobgp/ui/components/kbd'
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
|
||||
export type CommandPaletteItem = {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
to: string
|
||||
search?: Record<string, string>
|
||||
keywords?: string[]
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
items: CommandPaletteItem[]
|
||||
className?: string
|
||||
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
|
||||
hotkeyOnly?: boolean
|
||||
}
|
||||
|
||||
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return items
|
||||
return items.filter((item) => {
|
||||
const haystack = [item.label, item.description, ...(item.keywords ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return haystack.includes(q)
|
||||
})
|
||||
}, [items, query])
|
||||
|
||||
function go(item: CommandPaletteItem) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
void navigate({ to: item.to, search: item.search })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hotkeyOnly ? (
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupContent className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Поиск…
|
||||
</Button>
|
||||
<Search
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
|
||||
/>
|
||||
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
|
||||
⌘K
|
||||
</Kbd>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
if (!next) setQuery('')
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Быстрый переход</DialogTitle>
|
||||
<DialogDescription>Навигация по разделам EvoBGP</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md gap-0 px-0 py-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3">
|
||||
<div className="flex items-center gap-3 border-b px-4 py-3">
|
||||
<Search aria-hidden className="size-4 shrink-0 opacity-60" />
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-9 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Раздел или действие…"
|
||||
aria-label="Поиск разделов"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filtered[0]) go(filtered[0])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ul className="max-h-72 overflow-y-auto p-2" role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="text-muted-foreground px-2 py-6 text-center text-sm">Ничего не найдено</li>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
className="hover:bg-accent flex w-full flex-col items-start gap-0.5 rounded-md px-3 py-2 text-left text-sm transition-colors"
|
||||
onClick={() => go(item)}
|
||||
>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
{item.description ? (
|
||||
<span className="text-muted-foreground text-xs">{item.description}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import {
|
||||
ChevronsUpDownIcon,
|
||||
ExternalLinkIcon,
|
||||
LogOutIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
PaletteIcon,
|
||||
SettingsIcon,
|
||||
SunIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
|
||||
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
import {
|
||||
authPortalUrl,
|
||||
clearPortalToken,
|
||||
getClaims,
|
||||
isAuthEnabled,
|
||||
redirectToPortalLogout,
|
||||
resetPortalHandoff,
|
||||
} from '@/lib/auth'
|
||||
|
||||
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
||||
|
||||
const THEMES = [
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Светлая',
|
||||
icon: <SunIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Тёмная',
|
||||
icon: <MoonIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: 'system',
|
||||
label: 'Системная',
|
||||
icon: <MonitorIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
] as const
|
||||
|
||||
function ThemeSegmentedToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const currentTheme = mounted ? (theme ?? 'system') : 'system'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Тема"
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{THEMES.map(({ value, label, icon }) => {
|
||||
const isActive = currentTheme === value
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(source: string): string {
|
||||
const base = source.trim()
|
||||
if (!base) return '?'
|
||||
const parts = base.split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||
}
|
||||
return base.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const authOn = isAuthEnabled()
|
||||
const claims = getClaims()
|
||||
|
||||
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
|
||||
const email =
|
||||
claims?.email?.trim() || (authOn ? '' : 'локальный API-токен')
|
||||
const fallback = initials(name || email)
|
||||
|
||||
function handleSignOut() {
|
||||
if (authOn) {
|
||||
clearPortalToken()
|
||||
resetPortalHandoff()
|
||||
redirectToPortalLogout()
|
||||
return
|
||||
}
|
||||
setApiToken(null)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||
window.location.assign('/settings?tab=connection&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: 'connection' }} />}
|
||||
>
|
||||
<SettingsIcon aria-hidden />
|
||||
Настройки UI
|
||||
</DropdownMenuItem>
|
||||
{authOn ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={authPortalUrl()} />}
|
||||
>
|
||||
<ExternalLinkIcon aria-hidden />
|
||||
Открыть Auth Portal
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<PaletteIcon aria-hidden />
|
||||
Тема
|
||||
<div className="ml-auto">
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOutIcon aria-hidden />
|
||||
{authOn ? 'Выйти' : 'Сбросить токен'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Activity, Bird, HeartPulse, ListChecks, Network } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@evobgp/ui/components/popover'
|
||||
import { Progress } from '@evobgp/ui/components/progress'
|
||||
|
||||
import { monitoringHealthQueryOptions } from '@/queries/monitoring'
|
||||
import { networkBirdQueryOptions } from '@/queries/network'
|
||||
import { aggregateNetworkMetrics, overviewJobsQueryOptions, overviewPeersQueryOptions } from '@/queries/overview'
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
unit: string
|
||||
percent: number
|
||||
icon: ReactNode
|
||||
tone: 'success' | 'warning' | 'destructive' | 'info'
|
||||
alert: boolean
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric['tone']) {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
return 'var(--color-success)'
|
||||
case 'warning':
|
||||
return 'var(--color-warning)'
|
||||
case 'destructive':
|
||||
return 'var(--color-destructive)'
|
||||
default:
|
||||
return 'var(--color-info)'
|
||||
}
|
||||
}
|
||||
|
||||
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
const color = toneColor(metric.tone)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item
|
||||
className="flex size-5 shrink-0 items-center justify-center p-0"
|
||||
style={{ backgroundColor: `${color}18` }}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
|
||||
{metric.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
||||
{metric.value}
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={metric.percent}
|
||||
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
||||
style={{ '--bar-color': color } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live system monitor popover (app-shell-7 pattern, EvoBGP API data). */
|
||||
export function SystemMonitorPopover() {
|
||||
const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 })
|
||||
const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 })
|
||||
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
|
||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||
|
||||
const peers = peersQ.data?.items ?? []
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const network = aggregateNetworkMetrics(peers, [])
|
||||
const bgpPct =
|
||||
network.peersEnabled > 0
|
||||
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
|
||||
: 0
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const runningJobs = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
const healthOk = healthQ.data?.ok ?? false
|
||||
const birdRatio =
|
||||
birdQ.data && birdQ.data.bgp_sessions_total > 0
|
||||
? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100)
|
||||
: null
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'api',
|
||||
label: 'API health',
|
||||
value: healthOk ? 'OK' : '—',
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { LookupMatch } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Lookup matches grid — data-grid-filtering-2 pattern.
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function LookupMatchesGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: LookupMatch[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const columns = useMemo<ColumnDef<LookupMatch>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'layer',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Слой" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.layer === 'entry' ? 'info-light' : 'primary-light'}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.layer}
|
||||
</Badge>
|
||||
),
|
||||
meta: { headerTitle: 'Слой' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'module_name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.module_name}
|
||||
subtitle={row.original.module_type}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'matched_value',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.matched_value}
|
||||
subtitle={
|
||||
row.original.resolved_ip
|
||||
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
|
||||
: row.original.match_kind
|
||||
}
|
||||
accent="mono"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Совпадение' },
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
accessorFn: (row) => row.community_title || row.community || '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Community" />,
|
||||
cell: ({ row }) => {
|
||||
const title = row.original.community_title?.trim()
|
||||
const value = row.original.community?.trim()
|
||||
if (!title && !value) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>
|
||||
}
|
||||
return (
|
||||
<DataGridPrimaryCell
|
||||
title={title || value || '—'}
|
||||
subtitle={title && value && title !== value ? value : undefined}
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Community' },
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
enableSorting: false,
|
||||
header: 'Источник',
|
||||
cell: ({ row }) =>
|
||||
row.original.source ? (
|
||||
<CategoryBadge>{row.original.source}</CategoryBadge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
),
|
||||
meta: { headerTitle: 'Источник' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
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 (
|
||||
<FrameDataGrid
|
||||
title="Совпадения"
|
||||
description="Entries и snapshots · клик по строке открывает модуль"
|
||||
>
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет совпадений"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Фильтр совпадений…"
|
||||
onRowClick={(row) =>
|
||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
||||
}
|
||||
/>
|
||||
</FrameDataGrid>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
/**
|
||||
* Lookup search form — Frame + InputGroup (form-7 pattern).
|
||||
* @see https://reui.io/preview/base/form-7
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function LookupSearchForm({
|
||||
initialQuery = '',
|
||||
isPending = false,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialQuery?: string
|
||||
isPending?: boolean
|
||||
onSubmit: (q: string) => void
|
||||
}) {
|
||||
const [value, setValue] = useState(initialQuery)
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const q = value.trim()
|
||||
if (!q) return
|
||||
onSubmit(q)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Проверка списка</FrameTitle>
|
||||
<FrameDescription>
|
||||
IP или FQDN — поиск в entries и материализованных snapshots с community.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<Field className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor="lookup-q">IP или домен</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Search aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="lookup-q"
|
||||
name="q"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="8.8.8.8 или example.com"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Button type="submit" disabled={isPending || !value.trim()}>
|
||||
Проверить
|
||||
</Button>
|
||||
</form>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Globe, Layers, ListChecks, Radar } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import type { LookupResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Lookup summary KPI — stats-12 via KpiStatGrid.
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function LookupSummaryKpi({ data }: { data: LookupResponse }) {
|
||||
const entryCount = data.matches.filter((m) => m.layer === 'entry').length
|
||||
const snapshotCount = data.matches.filter((m) => m.layer === 'snapshot').length
|
||||
const resolvedCount = data.resolved_ips?.length ?? 0
|
||||
|
||||
const items: KpiStatItem[] = [
|
||||
{
|
||||
id: 'matched',
|
||||
label: 'Результат',
|
||||
value: data.matched ? 'Найдено' : 'Не найдено',
|
||||
hint: data.normalized,
|
||||
icon: <Radar aria-hidden />,
|
||||
iconClassName: data.matched
|
||||
? 'bg-success text-success-foreground [&_svg]:text-success-foreground'
|
||||
: 'bg-muted text-muted-foreground [&_svg]:text-muted-foreground',
|
||||
variant: data.matched ? 'default' : 'warning',
|
||||
},
|
||||
{
|
||||
id: 'entry',
|
||||
label: 'Слой entry',
|
||||
value: entryCount,
|
||||
hint: 'сырые списки',
|
||||
icon: <ListChecks aria-hidden />,
|
||||
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
|
||||
},
|
||||
{
|
||||
id: 'snapshot',
|
||||
label: 'Слой snapshot',
|
||||
value: snapshotCount,
|
||||
hint: 'материализация',
|
||||
icon: <Layers aria-hidden />,
|
||||
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
|
||||
},
|
||||
]
|
||||
|
||||
if (data.query_kind === 'domain') {
|
||||
items.push({
|
||||
id: 'resolved',
|
||||
label: 'DNS IP',
|
||||
value: resolvedCount,
|
||||
hint: resolvedCount > 0 ? data.resolved_ips?.slice(0, 3).join(', ') : 'нет A/AAAA',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<KpiStatGrid
|
||||
items={items}
|
||||
aria-label={`Запрос: ${data.query_kind} · ${data.query}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
|
||||
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Сменить тему</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>Светлая</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>Тёмная</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>Системная</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { toast } from 'sonner'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
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'
|
||||
@@ -128,7 +128,7 @@ export function ModuleEntriesSection({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
<FrameDataGrid
|
||||
title={meta.title}
|
||||
description={meta.description}
|
||||
actions={
|
||||
@@ -166,7 +166,7 @@ export function ModuleEntriesSection({
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</FrameDataGrid>
|
||||
|
||||
{mod.type === 'DOMAINS' ? (
|
||||
<ModuleDomainEntryDialog
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import {
|
||||
ArrowDownUp,
|
||||
Network,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Timer,
|
||||
} from 'lucide-react'
|
||||
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
|
||||
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||
import {
|
||||
communityLabel,
|
||||
@@ -15,6 +9,7 @@ import {
|
||||
moduleDohProfileIds,
|
||||
} from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
||||
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
||||
|
||||
interface ModuleKpiCardsProps {
|
||||
@@ -33,52 +28,65 @@ export function ModuleKpiCards({
|
||||
loading = false,
|
||||
}: ModuleKpiCardsProps) {
|
||||
if (loading || !mod) {
|
||||
return <SectionCardsSkeleton count={5} />
|
||||
return <KpiStatGridSkeleton count={4} />
|
||||
}
|
||||
|
||||
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||
const dohIds = moduleDohProfileIds(mod)
|
||||
|
||||
const items: SectionCardItem[] = [
|
||||
const items: KpiStatItem[] = [
|
||||
{
|
||||
label: 'Приоритет',
|
||||
id: 'priority',
|
||||
icon: <Gauge aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
value: String(mod.priority ?? 0),
|
||||
hint: 'порядок в сборке ревизии',
|
||||
icon: <ArrowDownUp className="size-3.5" />,
|
||||
label: 'Приоритет',
|
||||
footer: (
|
||||
<Badge variant={mod.enabled !== false ? 'success-light' : 'outline'} size="sm">
|
||||
{mod.enabled !== false ? 'активен' : 'выключен'} · {mod.type}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Интервал',
|
||||
id: 'interval',
|
||||
icon: <Clock aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
value: moduleIntervalLabel(mod),
|
||||
hint: 'refresh_interval_sec / cron',
|
||||
icon: <Timer className="size-3.5" />,
|
||||
label: 'Интервал обновления',
|
||||
footer: (
|
||||
<Badge variant="primary-light" size="sm">
|
||||
{formatDateTime(mod.last_refreshed_at) || 'никогда'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'DoH',
|
||||
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
|
||||
hint:
|
||||
mod.type === 'DOMAINS'
|
||||
? dohIds.length
|
||||
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
|
||||
: 'Системный DNS'
|
||||
: 'не применимо',
|
||||
icon: <Network className="size-3.5" />,
|
||||
id: 'prefixes',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
|
||||
label: mod.type === 'AS_PREFIXES' ? 'Префиксы AS' : 'Записи модуля',
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{asEntries.length} AS · в модуле
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Community по умолч.',
|
||||
value: communityLabel(mod.default_community_id, communities),
|
||||
hint: 'для записей без своего community',
|
||||
icon: <ShieldCheck className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
label: 'Последнее обновление',
|
||||
value: formatDateTime(mod.last_refreshed_at),
|
||||
hint:
|
||||
mod.type === 'AS_PREFIXES'
|
||||
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
|
||||
: 'время последнего refresh',
|
||||
icon: <RefreshCw className="size-3.5" />,
|
||||
id: 'policy',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
||||
label: communityLabel(mod.default_community_id, communities),
|
||||
footer: (
|
||||
<Badge variant="info-light" size="sm">
|
||||
{dohPolicyRu(mod.doh_resolver_policy)}
|
||||
{dohIds.length > 0
|
||||
? ` · ${dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')}`
|
||||
: ' · без DoH'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return <SectionCards items={items} />
|
||||
return <KpiStatGrid items={items} aria-label="KPI модуля" />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { CheckIcon, SearchIcon, XIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
createFilter,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||
import {
|
||||
useApproveDiscoveredPeerMutation,
|
||||
useRejectDiscoveredPeerMutation,
|
||||
} from '@/queries/network'
|
||||
import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Pending BGP peer discoveries — approve / reject.
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/preview/base/components/c-empty-1
|
||||
*/
|
||||
|
||||
function speakerLabel(s: SpeakerRow): string {
|
||||
if (s.role === 'master') {
|
||||
const host = s.agent_domain ?? s.endpoint
|
||||
return host ? `CP · ${host}` : 'CP (master)'
|
||||
}
|
||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||
}
|
||||
|
||||
function createDefaultFilters(): Filter[] {
|
||||
return [createFilter('neighbor', 'contains', [''])]
|
||||
}
|
||||
|
||||
const filterFields: FilterFieldConfig[] = [
|
||||
{
|
||||
key: 'neighbor',
|
||||
label: 'Сосед',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-48',
|
||||
placeholder: 'IP или Neighbor ID…',
|
||||
},
|
||||
]
|
||||
|
||||
interface NetworkDiscoveredPeersCardProps {
|
||||
items: PeerDiscoveryRow[]
|
||||
speakers: SpeakerRow[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function NetworkDiscoveredPeersCard({
|
||||
items,
|
||||
speakers,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: NetworkDiscoveredPeersCardProps) {
|
||||
const approveMutation = useApproveDiscoveredPeerMutation()
|
||||
const rejectMutation = useRejectDiscoveredPeerMutation()
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
|
||||
const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null)
|
||||
const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [speakerId, setSpeakerId] = useState<string | null>(null)
|
||||
|
||||
const speakerItems = useMemo(
|
||||
() => [
|
||||
{ value: '', label: 'Авто (с ноды обнаружения)' },
|
||||
...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })),
|
||||
],
|
||||
[speakers],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<PeerDiscoveryRow, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'neighbor_id',
|
||||
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Neighbor ID" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.neighbor_id || '—'}
|
||||
subtitle={row.original.neighbor}
|
||||
accent="mono"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Neighbor ID' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'remote_asn',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.remote_asn || '—'}</span>
|
||||
),
|
||||
meta: { headerTitle: 'ASN' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'session_state',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Состояние" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
status={row.original.session_state ?? '—'}
|
||||
label={bgpSessionStateRu(row.original.session_state)}
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setApproveTarget(row.original)
|
||||
setName('')
|
||||
setSpeakerId(row.original.speaker_id ?? null)
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
Одобрить
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setRejectTarget(row.original)}
|
||||
>
|
||||
<XIcon />
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
function getFilterFieldValue(item: PeerDiscoveryRow, field: string): unknown {
|
||||
if (field === 'neighbor') {
|
||||
return `${item.neighbor_id ?? ''} ${item.neighbor}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
if (!approveTarget) return
|
||||
await approveMutation.mutateAsync({
|
||||
id: approveTarget.id,
|
||||
body: {
|
||||
name: name.trim() || undefined,
|
||||
bgp_speaker_id: speakerId || null,
|
||||
},
|
||||
})
|
||||
setApproveTarget(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResourcePage
|
||||
title="На одобрение"
|
||||
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultFilters())}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error instanceof Error ? error : null}
|
||||
onRetry={onRetry}
|
||||
emptyState={{
|
||||
title: 'Нет ожидающих пиров',
|
||||
description:
|
||||
'Включите peer discovery в параметрах BIRD и задайте CIDR-диапазоны. Новые сессии появятся здесь.',
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormDrawer
|
||||
open={!!approveTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setApproveTarget(null)
|
||||
}}
|
||||
title="Одобрить пира"
|
||||
description={
|
||||
approveTarget
|
||||
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
||||
: undefined
|
||||
}
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => setApproveTarget(null)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton
|
||||
type="button"
|
||||
loading={approveMutation.isPending}
|
||||
onClick={() => void confirmApprove()}
|
||||
>
|
||||
Одобрить
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="disc-name">Имя (опционально)</Label>
|
||||
<Input
|
||||
id="disc-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="client-edge-1"
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="disc-speaker"
|
||||
label="Спикер"
|
||||
items={speakerItems}
|
||||
value={speakerId ?? ''}
|
||||
onValueChange={(v) => setSpeakerId(v || null)}
|
||||
placeholder="Авто"
|
||||
/>
|
||||
</FormDrawer>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!rejectTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRejectTarget(null)
|
||||
}}
|
||||
title="Отклонить пира?"
|
||||
description={
|
||||
rejectTarget
|
||||
? `${rejectTarget.neighbor_id || rejectTarget.neighbor} больше не будет появляться в списке.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Отклонить"
|
||||
destructive
|
||||
confirmLoading={rejectMutation.isPending}
|
||||
onConfirm={() => {
|
||||
if (!rejectTarget) return
|
||||
void rejectMutation.mutateAsync(rejectTarget.id).then(() => setRejectTarget(null))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { AlertTriangle, Network, ServerCog, Share2 } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { KpiStatGrid, type KpiStatCardData } from '@/components/reui-kit'
|
||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||
import type { BirdStatus, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Network page KPI strip.
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function NetworkKpi({
|
||||
peers,
|
||||
speakers,
|
||||
bird,
|
||||
loading,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
bird?: BirdStatus
|
||||
loading?: boolean
|
||||
}) {
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
const birdHealthy = bird?.healthy
|
||||
const birdSessions =
|
||||
bird != null ? `${bird.bgp_established} / ${bird.bgp_sessions_total}` : '—'
|
||||
|
||||
const cards: KpiStatCardData[] = [
|
||||
{
|
||||
id: 'peers-established',
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
label: 'Пиры Established',
|
||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{loading ? '…' : `${net.peersTotal} в каталоге`}
|
||||
</Badge>
|
||||
),
|
||||
to: '/network',
|
||||
search: { tab: 'peers' },
|
||||
},
|
||||
{
|
||||
id: 'speakers-online',
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Спикеры online',
|
||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
footer: (
|
||||
<Badge
|
||||
variant={
|
||||
net.speakersOnline === net.speakersTotal && net.speakersTotal > 0
|
||||
? 'success-light'
|
||||
: 'warning-light'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'live'}
|
||||
</Badge>
|
||||
),
|
||||
to: '/network',
|
||||
search: { tab: 'speakers' },
|
||||
},
|
||||
{
|
||||
id: 'mismatches',
|
||||
icon: <AlertTriangle aria-hidden />,
|
||||
iconClassName: net.peersMismatch > 0 ? 'text-warning' : 'text-muted-foreground',
|
||||
label: 'Расхождения',
|
||||
value: loading ? '—' : String(net.peersMismatch),
|
||||
variant: net.peersMismatch > 0 ? 'warning' : 'default',
|
||||
footer: (
|
||||
<Badge variant={net.peersMismatch > 0 ? 'warning-light' : 'outline'} size="sm">
|
||||
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'}
|
||||
</Badge>
|
||||
),
|
||||
to: '/network',
|
||||
search: { tab: 'peers' },
|
||||
},
|
||||
{
|
||||
id: 'bird',
|
||||
icon: <Network aria-hidden />,
|
||||
iconClassName:
|
||||
birdHealthy === false ? 'text-destructive' : 'text-primary',
|
||||
label: 'BIRD',
|
||||
value: loading ? '—' : birdSessions,
|
||||
variant: birdHealthy === false ? 'destructive' : 'default',
|
||||
footer: (
|
||||
<Badge
|
||||
variant={
|
||||
birdHealthy === true
|
||||
? 'success-light'
|
||||
: birdHealthy === false
|
||||
? 'destructive-light'
|
||||
: 'outline'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading
|
||||
? '…'
|
||||
: birdHealthy === true
|
||||
? 'в норме'
|
||||
: birdHealthy === false
|
||||
? 'проблема'
|
||||
: 'н/д'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section aria-label="Ключевые метрики сети">
|
||||
<KpiStatGrid cards={cards} isLoading={loading} skeletonCount={4} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,77 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Plus, SearchIcon, ActivityIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
|
||||
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import {
|
||||
getPeerFilterFieldValue,
|
||||
peerColumns,
|
||||
peerTabFilter,
|
||||
} from '@/components/network/network-peers-grid'
|
||||
import {
|
||||
createFilter,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* BGP peers list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
|
||||
const PEER_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'established', label: 'Established' },
|
||||
{ id: 'pending', label: 'Ожидание' },
|
||||
{ id: 'disabled', label: 'Выключены' },
|
||||
]
|
||||
|
||||
const SESSION_STATE_OPTIONS = [
|
||||
{ value: 'Established', label: 'Established' },
|
||||
{ value: 'Idle', label: 'Idle' },
|
||||
{ value: 'Active', label: 'Active' },
|
||||
{ value: 'Connect', label: 'Connect' },
|
||||
{ value: 'OpenSent', label: 'OpenSent' },
|
||||
{ value: 'OpenConfirm', label: 'OpenConfirm' },
|
||||
]
|
||||
|
||||
function createDefaultPeerFilters(): Filter[] {
|
||||
return [createFilter('name', 'contains', [''])]
|
||||
}
|
||||
|
||||
const peerFilterFields: FilterFieldConfig[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Имя',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-48',
|
||||
placeholder: 'Поиск по имени…',
|
||||
},
|
||||
{
|
||||
key: 'neighbor',
|
||||
label: 'Сосед',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-48',
|
||||
placeholder: 'Адрес соседа…',
|
||||
},
|
||||
{
|
||||
key: 'session_state',
|
||||
label: 'Состояние',
|
||||
icon: <ActivityIcon className="size-3.5" aria-hidden />,
|
||||
type: 'select',
|
||||
searchable: false,
|
||||
className: 'w-[160px]',
|
||||
options: SESSION_STATE_OPTIONS,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
|
||||
},
|
||||
]
|
||||
|
||||
interface NetworkPeersCardProps {
|
||||
items: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
@@ -28,41 +90,46 @@ export function NetworkPeersCard({
|
||||
onRetry,
|
||||
}: NetworkPeersCardProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
|
||||
|
||||
const addButton = useMemo(
|
||||
() => (
|
||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||
<Plus />
|
||||
Добавить пира
|
||||
</Button>
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
<ResourcePage
|
||||
title="Пиры"
|
||||
description="BGP-соседи и привязка к спикерам"
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||
<Plus />
|
||||
Добавить пира
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет пиров"
|
||||
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<PeerFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
speakers={speakers}
|
||||
tabs={PEER_TABS}
|
||||
tabFilter={peerTabFilter}
|
||||
filterFields={peerFilterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultPeerFilters())}
|
||||
getFilterFieldValue={getPeerFilterFieldValue}
|
||||
columns={peerColumns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error instanceof Error ? error : null}
|
||||
onRetry={onRetry}
|
||||
primaryAction={addButton}
|
||||
emptyState={{
|
||||
title: 'Нет пиров',
|
||||
description: 'Добавьте первого BGP-соседа.',
|
||||
action: addButton,
|
||||
}}
|
||||
/>
|
||||
|
||||
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,90 +1,77 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||
import type { PeerRow } from '@/types/api'
|
||||
|
||||
export function NetworkPeersGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: PeerRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<PeerRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.neighbor,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.name ?? row.original.neighbor}
|
||||
subtitle={row.original.name ? row.original.neighbor : undefined}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'neighbor',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Адрес соседа' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'remote_asn',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
|
||||
),
|
||||
meta: { headerTitle: 'ASN' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'session_state',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusBadge
|
||||
status={row.original.session_state ?? '—'}
|
||||
label={bgpSessionStateRu(row.original.session_state)}
|
||||
/>
|
||||
{row.original.session_mismatch ? (
|
||||
<CategoryBadge tone="warning">расхождение</CategoryBadge>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.neighbor,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.name ?? row.original.neighbor}
|
||||
subtitle={row.original.name ? row.original.neighbor : undefined}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'neighbor',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Адрес соседа' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'remote_asn',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
|
||||
),
|
||||
meta: { headerTitle: 'ASN' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'session_state',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusBadge
|
||||
status={row.original.session_state ?? '—'}
|
||||
label={bgpSessionStateRu(row.original.session_state)}
|
||||
/>
|
||||
{row.original.session_mismatch ? (
|
||||
<CategoryBadge tone="warning">расхождение</CategoryBadge>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
]
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return item.name ?? item.neighbor
|
||||
case 'neighbor':
|
||||
return item.neighbor
|
||||
case 'session_state':
|
||||
return item.session_state
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет пиров"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск пиров…"
|
||||
/>
|
||||
)
|
||||
export function peerTabFilter(item: PeerRow, tabId: string): boolean {
|
||||
if (tabId === 'disabled') return item.enabled === false
|
||||
const enabled = item.enabled !== false
|
||||
if (tabId === 'established') return enabled && item.session_state === 'Established'
|
||||
if (tabId === 'pending') return enabled && item.session_state !== 'Established'
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,15 +1,72 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Plus, SearchIcon, TagIcon, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import {
|
||||
getSpeakerFilterFieldValue,
|
||||
speakerColumns,
|
||||
speakerTabFilter,
|
||||
} from '@/components/network/network-speakers-grid'
|
||||
import {
|
||||
createFilter,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
||||
import { useDeleteSpeakerMutation } from '@/queries/network'
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* BGP speakers list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
|
||||
function speakerDeleteLabel(s: SpeakerRow): string {
|
||||
return s.agent_domain ?? s.endpoint ?? s.id
|
||||
}
|
||||
|
||||
const SPEAKER_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'online', label: 'Online' },
|
||||
{ id: 'offline', label: 'Offline' },
|
||||
]
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'primary', label: 'primary' },
|
||||
{ value: 'secondary', label: 'secondary' },
|
||||
{ value: 'speaker', label: 'speaker' },
|
||||
]
|
||||
|
||||
function createDefaultSpeakerFilters(): Filter[] {
|
||||
return [createFilter('endpoint', 'contains', [''])]
|
||||
}
|
||||
|
||||
const speakerFilterFields: FilterFieldConfig[] = [
|
||||
{
|
||||
key: 'endpoint',
|
||||
label: 'Конечная точка',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'endpoint…',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Роль',
|
||||
icon: <TagIcon className="size-3.5" aria-hidden />,
|
||||
type: 'select',
|
||||
searchable: false,
|
||||
className: 'w-[140px]',
|
||||
options: ROLE_OPTIONS,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, ROLE_OPTIONS),
|
||||
},
|
||||
]
|
||||
|
||||
interface NetworkSpeakersCardProps {
|
||||
items: SpeakerRow[]
|
||||
isLoading: boolean
|
||||
@@ -25,38 +82,97 @@ export function NetworkSpeakersCard({
|
||||
error,
|
||||
onRetry,
|
||||
}: NetworkSpeakersCardProps) {
|
||||
const deleteMutation = useDeleteSpeakerMutation()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<SpeakerRow | null>(null)
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
|
||||
|
||||
const addButton = useMemo(
|
||||
() => (
|
||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||
<Plus />
|
||||
Добавить спикера
|
||||
</Button>
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<SpeakerRow, unknown>[]>(
|
||||
() => [
|
||||
...speakerColumns,
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
aria-label="Удалить спикера"
|
||||
title="Удалить"
|
||||
onClick={() => setDeleteTarget(row.original)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
<ResourcePage
|
||||
title="Спикеры"
|
||||
description="BIRD-агенты на нодах tenant"
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||
<Plus />
|
||||
Добавить спикера
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет спикеров"
|
||||
emptyDescription="Добавьте первого BIRD-агента на ноде."
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
tabs={SPEAKER_TABS}
|
||||
tabFilter={speakerTabFilter}
|
||||
filterFields={speakerFilterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
|
||||
getFilterFieldValue={getSpeakerFilterFieldValue}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error instanceof Error ? error : null}
|
||||
onRetry={onRetry}
|
||||
primaryAction={addButton}
|
||||
emptyState={{
|
||||
title: 'Нет спикеров',
|
||||
description: 'Добавьте первого BIRD-агента на ноде.',
|
||||
action: addButton,
|
||||
}}
|
||||
/>
|
||||
|
||||
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null)
|
||||
}}
|
||||
title="Удалить спикера?"
|
||||
description={
|
||||
deleteTarget
|
||||
? `${speakerDeleteLabel(deleteTarget)} будет удалён. Привязки пиров к этому спикеру сбросятся.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
confirmLoadingLabel="Удаление…"
|
||||
destructive
|
||||
confirmLoading={deleteMutation.isPending}
|
||||
onConfirm={() => {
|
||||
if (!deleteTarget) return
|
||||
void deleteMutation.mutateAsync(deleteTarget.id).then(() => setDeleteTarget(null))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,87 +1,78 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { speakerOnlineLabel } from '@/lib/ui-labels'
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
export function NetworkSpeakersGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: SpeakerRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'endpoint',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Конечная точка' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
enableSorting: false,
|
||||
header: 'Агент',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
|
||||
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
|
||||
return <Badge variant="outline" size="sm" radius="full">—</Badge>
|
||||
},
|
||||
meta: { headerTitle: 'Агент' },
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
enableSorting: false,
|
||||
header: 'BGP',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (!live) return '—'
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'BGP' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'endpoint',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Конечная точка' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
enableSorting: false,
|
||||
header: 'Агент',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (live?.agent_ok === true) {
|
||||
return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
|
||||
}
|
||||
if (live?.agent_ok === false) {
|
||||
return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" size="sm" radius="full">
|
||||
—
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Агент' },
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
enableSorting: false,
|
||||
header: 'BGP',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (!live) return '—'
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'BGP' },
|
||||
},
|
||||
]
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
|
||||
switch (field) {
|
||||
case 'endpoint':
|
||||
return item.endpoint
|
||||
case 'role':
|
||||
return item.role
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет спикеров"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск спикеров…"
|
||||
/>
|
||||
)
|
||||
export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean {
|
||||
if (tabId === 'online') return item.live?.agent_ok === true
|
||||
if (tabId === 'offline') return item.live?.agent_ok !== true
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import type { JobRow } from '@/types/api'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
type JobTab = 'all' | 'active' | 'failed' | 'succeeded'
|
||||
|
||||
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||
if (tab === 'all') return items
|
||||
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({
|
||||
jobs,
|
||||
nameById,
|
||||
qc,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
nameById: Map<string, string>
|
||||
qc: QueryClient
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<JobTab>('all')
|
||||
const counts = useMemo(() => tabCounts(jobs), [jobs])
|
||||
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
|
||||
|
||||
return (
|
||||
<FrameDataGrid title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
|
||||
<div className="px-5 pt-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
|
||||
<TabsList variant="line" className="w-full justify-start gap-6">
|
||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger>
|
||||
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
|
||||
<TabsTrigger value="failed">Ошибки ({counts.failed})</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<QueryState
|
||||
data={filtered}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={filtered.length === 0}
|
||||
emptyTitle="Нет задач в выборке"
|
||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(items) => (
|
||||
<OperationsJobsGrid
|
||||
items={items}
|
||||
nameById={nameById}
|
||||
qc={qc}
|
||||
isLoading={isLoading && items.length > 0}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</FrameDataGrid>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,24 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@evobgp/ui/components/card'
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
/** REUI c-data-grid-19: card shell with default spacing and flush grid body. */
|
||||
export const panelCardClassName = 'gap-0 has-data-[slot=card-footer]:pb-0'
|
||||
/**
|
||||
* 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'
|
||||
export const panelCardContentFlushClassName = 'px-0'
|
||||
/** Horizontal inset for toolbar / pagination rows (matches edgeCell px-5). */
|
||||
/** 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'
|
||||
|
||||
@@ -45,22 +48,45 @@ export function PanelCard({
|
||||
size = 'default',
|
||||
}: PanelCardProps) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
const spacing = size === 'sm' ? 'sm' : 'default'
|
||||
|
||||
return (
|
||||
<Card size={size} className={cn(panelCardClassName, className)}>
|
||||
{hasHeader ? (
|
||||
<CardHeader className={cn(panelCardHeaderClassName, headerClassName)}>
|
||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
{actions ? <CardAction>{actions}</CardAction> : null}
|
||||
</CardHeader>
|
||||
) : null}
|
||||
{children != null && children !== false ? (
|
||||
<CardContent className={contentClassName}>{children}</CardContent>
|
||||
) : null}
|
||||
{footer ? (
|
||||
<CardFooter className={cn(panelCardFooterClassName, footerClassName)}>{footer}</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
<Frame
|
||||
dense
|
||||
spacing={spacing}
|
||||
className={cn(panelCardClassName, 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Cell, Pie, PieChart } from 'recharts'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
ChartContainer,
|
||||
type ChartConfig,
|
||||
} from '@evobgp/ui/components/chart'
|
||||
import type { BreakdownSlice } from '@/lib/metrics'
|
||||
|
||||
/** chart-27 / chart-13 inspired donut in PanelCard. */
|
||||
export function DonutBreakdownCard({
|
||||
title,
|
||||
description,
|
||||
slices,
|
||||
centerLabel,
|
||||
badge,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
slices: BreakdownSlice[]
|
||||
centerLabel: string
|
||||
badge?: string
|
||||
}) {
|
||||
const total = slices.reduce((sum, s) => sum + s.count, 0)
|
||||
const chartConfig = slices.reduce<ChartConfig>((acc, slice) => {
|
||||
acc[slice.key] = { label: slice.label, color: slice.color }
|
||||
return acc
|
||||
}, {})
|
||||
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
|
||||
|
||||
return (
|
||||
<PanelCard title={title} description={description} className="h-full">
|
||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center">
|
||||
{total === 0 ? (
|
||||
<p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative mx-auto size-36 shrink-0">
|
||||
<ChartContainer config={chartConfig} className="aspect-square size-36">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="share"
|
||||
nameKey="label"
|
||||
innerRadius={48}
|
||||
outerRadius={64}
|
||||
strokeWidth={2}
|
||||
stroke="var(--color-card)"
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.key} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-muted-foreground text-xs">{centerLabel}</span>
|
||||
<span className="text-lg font-semibold tabular-nums">{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-2">
|
||||
{slices.map((slice) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<li key={slice.key} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: slice.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">{slice.label}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums">
|
||||
{slice.count} ({pct}%)
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{badge ? (
|
||||
<Badge variant="success-light" className="absolute top-4 right-4 hidden sm:flex">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
import { IconStack } from '@/components/reui/icon-stack'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@evobgp/ui/components/empty'
|
||||
|
||||
/** empty-state-11 pattern adapted to Card surface. */
|
||||
export function IllustratedEmptyState({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-muted min-h-[240px] p-0 shadow-none">
|
||||
<CardContent className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
||||
{action ? <div className="w-full self-end">{action}</div> : null}
|
||||
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
||||
<EmptyHeader className="items-center gap-5 text-center">
|
||||
<EmptyMedia className="mb-0">
|
||||
<IconStack aria-hidden>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { DonutBreakdownCard } from './donut-breakdown-card'
|
||||
export { IllustratedEmptyState } from './illustrated-empty-state'
|
||||
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
||||
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
export { PanelCorners } from './panel-corners'
|
||||
export { ProjectsEmptyState } from './projects-empty-state'
|
||||
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
||||
import { PanelCorners } from '@/components/patterns/panel-corners'
|
||||
import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
|
||||
export type KpiSparklineMetric = {
|
||||
id: string
|
||||
title: string
|
||||
label: string
|
||||
value: string
|
||||
delta: string
|
||||
deltaVariant: ComponentProps<typeof Badge>['variant']
|
||||
detail: string
|
||||
tone: MetricTone
|
||||
sparkline: readonly number[]
|
||||
}
|
||||
|
||||
function Sparkline({ values, color }: { values: readonly number[]; color: string }) {
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const spread = Math.max(1, max - min)
|
||||
const points = values
|
||||
.map((value, index) => {
|
||||
const x = (index / (values.length - 1)) * 72
|
||||
const y = 28 - ((value - min) / spread) * 22
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 72 32" className="h-9 w-24 shrink-0 opacity-95" role="img" aria-label="Тренд">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="miter"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** KPI card with sparkline (dashboard-5 / chart-15 pattern, Card surface). */
|
||||
export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
|
||||
const tone = toneStyles[metric.tone]
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
||||
<PanelCorners />
|
||||
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm leading-4 font-semibold">{metric.title}</span>
|
||||
<span className="text-muted-foreground truncate text-xs leading-4">{metric.label}</span>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="min-w-0 space-y-2.5">
|
||||
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight tabular-nums">
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={metric.deltaVariant} radius="full" size="sm">
|
||||
{metric.delta}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">{metric.detail}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline values={metric.sparkline} color={tone.stroke} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type MetricTone = 'danger' | 'success' | 'warning' | 'info'
|
||||
|
||||
export const toneStyles: Record<MetricTone, { stroke: string }> = {
|
||||
danger: { stroke: 'var(--color-destructive)' },
|
||||
success: { stroke: 'var(--color-success)' },
|
||||
warning: { stroke: 'var(--color-warning)' },
|
||||
info: { stroke: 'var(--color-info)' },
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Corner accents from ReUI dashboard-5 block. */
|
||||
export function PanelCorners() {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="border-foreground/65 absolute top-0 left-0 size-2 border-t border-l"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="border-foreground/65 absolute right-0 bottom-0 size-2 border-r border-b"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Boxes, Plus } from 'lucide-react'
|
||||
|
||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
/** empty-state-3 pattern for first module. */
|
||||
export function ProjectsEmptyState() {
|
||||
return (
|
||||
<IllustratedEmptyState
|
||||
icon={Boxes}
|
||||
title="Создайте первый модуль"
|
||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||
action={
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Новый модуль
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Progress } from '@evobgp/ui/components/progress'
|
||||
|
||||
export type SegmentStat = {
|
||||
value: string | number
|
||||
label: string
|
||||
percent: number
|
||||
badge?: ReactNode
|
||||
}
|
||||
|
||||
/** stats-4 pattern: dual metrics + progress + segmented meter (Card surface). */
|
||||
export function SegmentedProgressCard({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
primary,
|
||||
secondary,
|
||||
segments = 30,
|
||||
footer,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
primary: SegmentStat
|
||||
secondary: SegmentStat
|
||||
segments?: number
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
const filled = Math.round((secondary.percent / 100) * segments)
|
||||
|
||||
return (
|
||||
<PanelCard title={title} description={description} actions={actions} className="h-full">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex items-stretch gap-x-6">
|
||||
<div className="flex flex-1 flex-col items-start gap-1">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<span className="text-foreground text-2xl font-bold tabular-nums">{primary.value}</span>
|
||||
{primary.badge}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm font-medium">{primary.label}</span>
|
||||
<div className="mt-1 w-full">
|
||||
<Progress value={primary.percent} className="**:data-[slot=progress-track]:h-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-muted-foreground/10 flex flex-1 flex-col items-start gap-1 border-s ps-6">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<span className="text-foreground text-2xl font-bold tabular-nums">{secondary.value}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm font-medium">{secondary.label}</span>
|
||||
<div className="mt-1 flex w-full gap-0.5">
|
||||
{Array.from({ length: segments }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-2.5 w-0.5 flex-1 rounded-md',
|
||||
i < filled ? 'bg-success' : 'bg-muted',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{footer ? <div className="text-muted-foreground text-xs">{footer}</div> : null}
|
||||
</div>
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface QueryStateProps<T> {
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
emptyContent?: ReactNode
|
||||
onRetry?: () => void
|
||||
skeleton?: ReactNode
|
||||
children: (data: T) => ReactNode
|
||||
@@ -27,6 +28,7 @@ export function QueryState<T>({
|
||||
emptyTitle = 'Нет данных',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
emptyContent,
|
||||
onRetry,
|
||||
skeleton,
|
||||
children,
|
||||
@@ -52,6 +54,7 @@ export function QueryState<T>({
|
||||
)
|
||||
}
|
||||
if (empty || data == null) {
|
||||
if (emptyContent) return <>{emptyContent}</>
|
||||
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
}
|
||||
return <>{children(data)}</>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export interface DetailMetricCard {
|
||||
id: string
|
||||
icon: ReactNode
|
||||
label: string
|
||||
description: string
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
interface DetailPanelProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DetailPanelRoot({ children, className }: DetailPanelProps) {
|
||||
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||
}
|
||||
|
||||
interface DetailPanelHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
function DetailPanelHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
}: DetailPanelHeaderProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
{description ? (
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
{children ? <FramePanel className="flex flex-col gap-4">{children}</FramePanel> : null}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
|
||||
return (
|
||||
<div className="@container w-full">
|
||||
<div className="grid gap-4 @2xl:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<Frame key={card.id} spacing="sm">
|
||||
<FrameHeader className="px-1! py-1!">
|
||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||
{card.icon}
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{card.description}
|
||||
</p>
|
||||
{card.footer}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailPanelSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title?: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
{title ? (
|
||||
<header className="px-1">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export const DetailPanel = Object.assign(DetailPanelRoot, {
|
||||
Header: DetailPanelHeader,
|
||||
Metrics: DetailPanelMetrics,
|
||||
Section: DetailPanelSection,
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
|
||||
export function getActiveFilters(filters: Filter[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { values } = filter
|
||||
if (!values || values.length === 0) return false
|
||||
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => value === null || value === undefined)) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function applyFiltersToData<T>(
|
||||
data: T[],
|
||||
filters: Filter[],
|
||||
getFieldValue: (item: T, field: string) => unknown,
|
||||
): T[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
|
||||
for (const filter of active) {
|
||||
const { field, operator, values } = filter
|
||||
result = result.filter((item) => {
|
||||
const raw = getFieldValue(item, field)
|
||||
const fieldValue = raw != null ? raw : ''
|
||||
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
return values.includes(fieldValue)
|
||||
case 'is_not':
|
||||
return !values.includes(fieldValue)
|
||||
case 'is_any_of':
|
||||
return values.some((value) => fieldValue === value)
|
||||
case 'is_not_any_of':
|
||||
return !values.some((value) => fieldValue === value)
|
||||
case 'contains': {
|
||||
const tokens = values
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
|
||||
)
|
||||
}
|
||||
case 'not_contains':
|
||||
return !values.some((value) =>
|
||||
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
|
||||
)
|
||||
case 'starts_with':
|
||||
return values.some((value) =>
|
||||
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
|
||||
)
|
||||
case 'ends_with':
|
||||
return values.some((value) =>
|
||||
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
|
||||
)
|
||||
case 'empty':
|
||||
return fieldValue === '' || fieldValue == null
|
||||
case 'not_empty':
|
||||
return fieldValue !== '' && fieldValue != null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function renderSelectedCount(values: unknown[]) {
|
||||
if (values.length === 0) return 'Выберите…'
|
||||
if (values.length > 1) return `${values.length} выбрано`
|
||||
return null
|
||||
}
|
||||
|
||||
export function renderSingleSelectedLabel(
|
||||
values: unknown[],
|
||||
options: { value: string; label: string }[],
|
||||
) {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
const option = options.find((item) => item.value === values[0])
|
||||
return option?.label ?? String(values[0])
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
/**
|
||||
* Frame shell for list/grid sections (replaces DataGridCard Card-named API).
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function FrameDataGrid({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3 border-b">
|
||||
<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}
|
||||
<div className="p-0">{children}</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user