Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
738d2e2256 | ||
|
|
2e3e1493f5 | ||
|
|
fd2fd8298d | ||
|
|
df7cd99060 | ||
|
|
c273cea067 | ||
|
|
1f969e6cac | ||
|
|
c1132cbe19 | ||
|
|
b28ad88b22 | ||
|
|
b871d62de6 | ||
|
|
4d83b8d673 | ||
|
|
2820cff988 | ||
|
|
54b7ea3bd5 | ||
|
|
57bcfcd9e1 | ||
|
|
1317a73e9a | ||
|
|
039d2f3dd9 | ||
|
|
54a0b5b966 | ||
|
|
1639ba40f3 | ||
|
|
26a96bc824 | ||
|
|
4fc5c96e63 | ||
|
|
3859983ae5 | ||
|
|
aa1779170a | ||
|
|
bbcf9d76d9 | ||
|
|
55eb2a6c89 |
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `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
|
# 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
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# 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.
|
**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:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the 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
|
## kanban
|
||||||
|
|
||||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
**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 *)
|
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
|
# 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
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# 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.
|
**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:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the 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
|
## kanban
|
||||||
|
|
||||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
**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,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": {
|
"reui": {
|
||||||
"url": "https://mcp.reui.io/api/mcp?style=base-nova",
|
"url": "https://mcp.reui.io",
|
||||||
"headers": {
|
"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 *)
|
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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ alwaysApply: false
|
|||||||
# Web UI — React + shadcn/ui + ReUI
|
# 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
|
- 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
|
## Слои UI
|
||||||
|
|
||||||
@@ -22,16 +22,19 @@ alwaysApply: false
|
|||||||
|------|------|------------|
|
|------|------|------------|
|
||||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, 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) |
|
| Роуты | `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>`.
|
Тема: `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.
|
**WEB-01** | MUST | Перед новым UI — сначала MCP **`user-reui`** (`search` → `get_block` / `compose_page`, `surface: "frame"`) + cite `previewUrl`/`docsUrl`. Primitives — MCP `plugin-shadcn-shadcn`. Только после — JSX.
|
||||||
*Rationale:* единый источник правды и API.
|
*Rationale:* ReUI PRO выше shadcn; единый Frame surface.
|
||||||
*Проверка:* review; нет самописных примитивов, если есть registry item.
|
*Проверка:* 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>`.
|
**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).
|
*Проверка:* файлы в `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` обёртки.
|
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||||
|
|
||||||
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `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/data-grid` или `@evobgp/ui/components/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`.
|
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||||
@@ -70,7 +73,7 @@ alwaysApply: false
|
|||||||
|
|
||||||
**WEB-14** | SHOULD | Нетривиальный UI — прочитать страницу компонента shadcn/ReUI (props, a11y).
|
**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`.
|
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `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
|
# 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
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# 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.
|
**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:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the 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
|
## kanban
|
||||||
|
|
||||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
|||||||
@@ -21,9 +21,14 @@ Thumbs.db
|
|||||||
|
|
||||||
# Env / secrets (local only)
|
# Env / secrets (local only)
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.*.example
|
!.env.*.example
|
||||||
|
|
||||||
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
|
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `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
|
# 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
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# 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.
|
**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:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the 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
|
## kanban
|
||||||
|
|
||||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
|||||||
@@ -71,4 +71,6 @@ pnpm --filter @evobgp/web run build
|
|||||||
|
|
||||||
Стек: 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 — в `web-legacy-svelte/` (архив, только референс при миграции).
|
||||||
|
|
||||||
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
|
||||||
@@ -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'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
export type AnalyticsKpiItem = {
|
export type AnalyticsKpiItem = {
|
||||||
@@ -12,39 +12,42 @@ export type AnalyticsKpiItem = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TONE_CLASS = {
|
function deltaBadgeVariant(
|
||||||
success: 'text-success',
|
tone?: 'success' | 'warning' | 'destructive' | 'muted',
|
||||||
warning: 'text-warning',
|
): 'success-light' | 'warning-light' | 'destructive-light' | 'outline' {
|
||||||
destructive: 'text-destructive',
|
if (tone === 'success') return 'success-light'
|
||||||
muted: 'text-muted-foreground',
|
if (tone === 'warning') return 'warning-light'
|
||||||
} as const
|
if (tone === 'destructive') return 'destructive-light'
|
||||||
|
return 'outline'
|
||||||
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" />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
|
<KpiStatGrid
|
||||||
{items.map((item) => (
|
items={items.map(toKpiItem)}
|
||||||
<div key={item.label} className="min-w-0 space-y-1">
|
embedded
|
||||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
className={cn(className)}
|
||||||
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
|
aria-label="Показатели"
|
||||||
{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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
value={value}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onValueChange}
|
||||||
|
orientation="horizontal"
|
||||||
className={cn('w-full', className)}
|
className={cn('w-full', className)}
|
||||||
>
|
>
|
||||||
<TabsList
|
<TabsList
|
||||||
variant="line"
|
variant="line"
|
||||||
className={cn(
|
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
|
||||||
'mb-4 w-full justify-start gap-6',
|
|
||||||
listClassName,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
<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="[email protected]"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Invoice profile"
|
||||||
|
description="Business details used on receipts and tax forms."
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">Invoice profile</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Company identity used for invoices and tax documents.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup className="gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-company-name">
|
||||||
|
Company name
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="settings-7-company-name"
|
||||||
|
defaultValue="Acme Labs"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-billing-country">
|
||||||
|
Country
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={country} onValueChange={handleCountryChange}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="settings-7-billing-country"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(COUNTRIES, country)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{COUNTRIES.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Tax ID"
|
||||||
|
description="Optional number used for VAT or company invoices."
|
||||||
|
labelFor="settings-7-tax-id"
|
||||||
|
>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>VAT</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-7-tax-id"
|
||||||
|
defaultValue="US-2048-ACME"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Card on file"
|
||||||
|
description="Used for monthly renewals."
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
Visa ending in 4242
|
||||||
|
</span>
|
||||||
|
<Button variant="outline">Update</Button>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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="[email protected]"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Username"
|
||||||
|
description="Visible in mentions and links."
|
||||||
|
labelFor="settings-7-username"
|
||||||
|
>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>@</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-7-username"
|
||||||
|
defaultValue="alexmorgan"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Profile details"
|
||||||
|
description="Public details shared across the workspace."
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">Profile details</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Public profile and workspace defaults.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup className="gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-role">Role</FieldLabel>
|
||||||
|
<Select value={role} onValueChange={handleRoleChange}>
|
||||||
|
<SelectTrigger id="settings-7-role" className="w-full">
|
||||||
|
<SelectValue>{getOptionLabel(ROLES, role)}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{ROLES.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-timezone">
|
||||||
|
Time zone
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={timezone} onValueChange={handleTimezoneChange}>
|
||||||
|
<SelectTrigger id="settings-7-timezone" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(TIMEZONES, timezone)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{TIMEZONES.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-website">Website</FieldLabel>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>https://</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-7-website"
|
||||||
|
defaultValue="alexmorgan.dev"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Bio"
|
||||||
|
description="Short profile summary."
|
||||||
|
labelFor="settings-7-bio"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
id="settings-7-bio"
|
||||||
|
defaultValue="Building developer tools at Acme. Previously at Vercel and Stripe."
|
||||||
|
rows={4}
|
||||||
|
className="min-h-24 resize-none"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,158 @@
|
|||||||
|
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 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 обзора"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import { ChevronRight } from 'lucide-react'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
|
||||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
|
||||||
|
|
||||||
export type QuickLinkCardProps = {
|
|
||||||
icon: ReactNode
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
to: string
|
|
||||||
search?: Record<string, string>
|
|
||||||
iconClass: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardQuickLinkCard({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
to,
|
|
||||||
search,
|
|
||||||
iconClass,
|
|
||||||
}: QuickLinkCardProps) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={to}
|
|
||||||
search={search}
|
|
||||||
className="group block h-full rounded-[inherit] focus-visible:outline-none"
|
|
||||||
aria-label={`${label}: ${description}`}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
'relative isolate h-full overflow-hidden transition-colors',
|
|
||||||
'hover:border-foreground/20',
|
|
||||||
'group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
|
|
||||||
<CardContent className="relative z-10 flex h-full flex-col gap-7.5 p-5">
|
|
||||||
<Item
|
|
||||||
className={cn(
|
|
||||||
'border-background flex size-11 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5',
|
|
||||||
iconClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
|
||||||
{icon}
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
<div className="mt-auto flex flex-col gap-3">
|
|
||||||
<span className="text-foreground block text-sm leading-tight font-medium">{label}</span>
|
|
||||||
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
|
|
||||||
<span className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 group-hover:underline">
|
|
||||||
Перейти
|
|
||||||
<ChevronRight
|
|
||||||
aria-hidden
|
|
||||||
className="size-2.5 shrink-0 transition-transform group-hover:translate-x-0.5"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,78 +1,76 @@
|
|||||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
import { Gauge, Network, Play, Plus, Search, Share2, Tags } from 'lucide-react'
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
import { DashboardQuickLinkCard } from '@/components/dashboard/dashboard-quick-link-card'
|
import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit'
|
||||||
import { PanelCard } from '@/components/panel-card'
|
|
||||||
|
|
||||||
type QuickLink = {
|
/** iconClassName: semantic text only (shared chrome with CFDM). @see https://reui.io/preview/base/stats-12 */
|
||||||
icon: ReactNode
|
const ACTIONS: QuickActionItem[] = [
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
to: string
|
|
||||||
search?: Record<string, string>
|
|
||||||
iconClass: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const LINKS: QuickLink[] = [
|
|
||||||
{
|
{
|
||||||
icon: <Plus aria-hidden />,
|
id: 'lookup',
|
||||||
label: 'Создать модуль',
|
title: 'Проверка IP/домена',
|
||||||
|
description: 'Membership в списках и community (entry + snapshot).',
|
||||||
|
to: '/lookup',
|
||||||
|
icon: <Search aria-hidden />,
|
||||||
|
iconClassName: 'text-primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'new-module',
|
||||||
|
title: 'Создать модуль',
|
||||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||||
to: '/modules/new',
|
to: '/modules/new',
|
||||||
iconClass: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
|
icon: <Plus aria-hidden />,
|
||||||
|
iconClassName: 'text-primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Tags aria-hidden />,
|
id: 'communities',
|
||||||
label: 'BGP-сообщества',
|
title: 'BGP-сообщества',
|
||||||
description: 'Справочник communities для политик экспорта.',
|
description: 'Справочник communities для политик экспорта.',
|
||||||
to: '/directories',
|
to: '/directories',
|
||||||
iconClass: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
|
icon: <Tags aria-hidden />,
|
||||||
|
iconClassName: 'text-info',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Network aria-hidden />,
|
id: 'network',
|
||||||
label: 'Сеть',
|
title: 'Сеть',
|
||||||
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
||||||
to: '/network',
|
to: '/network',
|
||||||
search: { tab: 'overview' },
|
search: { tab: 'overview' },
|
||||||
iconClass: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
|
icon: <Network aria-hidden />,
|
||||||
|
iconClassName: 'text-success',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Share2 aria-hidden />,
|
id: 'add-peer',
|
||||||
label: 'Добавить пира',
|
title: 'Добавить пира',
|
||||||
description: 'Настройка BGP-соседа и шаблонов сессии.',
|
description: 'Настройка BGP-соседа и шаблонов сессии.',
|
||||||
to: '/network',
|
to: '/network',
|
||||||
search: { tab: 'peers' },
|
search: { tab: 'peers' },
|
||||||
iconClass: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
|
icon: <Share2 aria-hidden />,
|
||||||
|
iconClassName: 'text-warning',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Play aria-hidden />,
|
id: 'deploy',
|
||||||
label: 'Деплой',
|
title: 'Деплой',
|
||||||
description: 'Ревизии конфигурации и применение на нодах.',
|
description: 'Ревизии конфигурации и применение на нодах.',
|
||||||
to: '/operations',
|
to: '/operations',
|
||||||
search: { tab: 'revisions' },
|
search: { tab: 'revisions' },
|
||||||
iconClass: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
|
icon: <Play aria-hidden />,
|
||||||
|
iconClassName: 'text-muted-foreground',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Gauge aria-hidden />,
|
id: 'monitoring',
|
||||||
label: 'Мониторинг',
|
title: 'Мониторинг',
|
||||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||||
to: '/monitoring',
|
to: '/monitoring',
|
||||||
search: { tab: 'system' },
|
search: { tab: 'system' },
|
||||||
iconClass: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
|
icon: <Gauge aria-hidden />,
|
||||||
|
iconClassName: 'text-destructive',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function DashboardQuickLinks() {
|
export function DashboardQuickLinks() {
|
||||||
return (
|
return (
|
||||||
<PanelCard
|
<QuickActionGrid
|
||||||
title="Быстрые действия"
|
actions={ACTIONS}
|
||||||
description="Частые переходы к настройке и деплою"
|
description="Частые переходы к настройке и деплою"
|
||||||
className="@container w-full"
|
/>
|
||||||
contentClassName="grid gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3"
|
|
||||||
>
|
|
||||||
{LINKS.map((link) => (
|
|
||||||
<DashboardQuickLinkCard key={link.label} {...link} />
|
|
||||||
))}
|
|
||||||
</PanelCard>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import type { Table } from '@tanstack/react-table'
|
|||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
import { DataGridToolbar } from '@/components/data-grid-toolbar'
|
import { DataGridToolbar } from '@/components/data-grid-toolbar'
|
||||||
import { PanelCard, panelCardContentFlushClassName, panelCardFooterClassName, panelCardInsetClassName } from '@/components/panel-card'
|
import {
|
||||||
|
PanelCard,
|
||||||
|
panelCardContentFlushClassName,
|
||||||
|
panelCardFooterClassName,
|
||||||
|
} from '@/components/panel-card'
|
||||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
|
import { FrameFooter } from '@/components/reui/frame'
|
||||||
import {
|
import {
|
||||||
DATA_GRID_MESSAGES_RU,
|
DATA_GRID_MESSAGES_RU,
|
||||||
DATA_GRID_PAGINATION_RU,
|
DATA_GRID_PAGINATION_RU,
|
||||||
@@ -52,9 +57,9 @@ export function DataGridShell<TData extends object>({
|
|||||||
<DataGridTable />
|
<DataGridTable />
|
||||||
</DataGridContainer>
|
</DataGridContainer>
|
||||||
{showPagination ? (
|
{showPagination ? (
|
||||||
<div className={cn(panelCardFooterClassName, panelCardInsetClassName)}>
|
<FrameFooter className={cn(panelCardFooterClassName)}>
|
||||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
||||||
</div>
|
</FrameFooter>
|
||||||
) : null}
|
) : null}
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ interface DataGridToolbarProps {
|
|||||||
searchValue: string
|
searchValue: string
|
||||||
onSearchChange: (value: string) => void
|
onSearchChange: (value: string) => void
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string
|
||||||
|
/** ReUI Filters trigger or custom filter controls. Preview: https://reui.io/preview/base/data-grid-filtering-2 */
|
||||||
filters?: ReactNode
|
filters?: ReactNode
|
||||||
actions?: ReactNode
|
actions?: ReactNode
|
||||||
className?: string
|
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({
|
export function DataGridToolbar({
|
||||||
searchValue,
|
searchValue,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
@@ -58,19 +59,19 @@ export function DataGridToolbar({
|
|||||||
filters
|
filters
|
||||||
) : (
|
) : (
|
||||||
<InputGroupButton
|
<InputGroupButton
|
||||||
|
aria-label="Фильтры"
|
||||||
|
size="icon-xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
disabled
|
||||||
className="pointer-events-none gap-1.5 opacity-0"
|
title="Передайте filters={<Filters … />} из @/components/reui/filters"
|
||||||
aria-hidden="true"
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<ListFilterIcon className="size-3.5" />
|
<ListFilterIcon aria-hidden="true" />
|
||||||
</InputGroupButton>
|
</InputGroupButton>
|
||||||
)}
|
)}
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</Field>
|
</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>
|
</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="[email protected]"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="pt-3">
|
||||||
|
<Button size="sm">Save changes</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="password">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Password</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Change your password here.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-current" className="text-sm">
|
||||||
|
Current password
|
||||||
|
</Label>
|
||||||
|
<Input id="underline-current" type="password" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-new" className="text-sm">
|
||||||
|
New password
|
||||||
|
</Label>
|
||||||
|
<Input id="underline-new" type="password" className="h-9" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="pt-3">
|
||||||
|
<Button size="sm">Update password</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="settings">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Settings</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Manage your preferences.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-theme" className="text-sm">
|
||||||
|
Theme
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="underline-theme"
|
||||||
|
defaultValue="Light"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-language" className="text-sm">
|
||||||
|
Language
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="underline-language"
|
||||||
|
defaultValue="English"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="pt-3">
|
||||||
|
<Button size="sm">Save settings</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,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,
|
BookText,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
Shield,
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -41,9 +41,12 @@ import { TooltipProvider } from '@evobgp/ui/components/tooltip'
|
|||||||
import { Link, useRouterState } from '@tanstack/react-router'
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
import type { ComponentType, CSSProperties, ReactNode } from 'react'
|
import type { ComponentType, CSSProperties, ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { AppSwitcher } from '@/components/app-switcher'
|
||||||
|
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||||
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
||||||
|
import { NavUser } from '@/components/layout/nav-user'
|
||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { ModeToggle } from '@/components/mode-toggle'
|
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string
|
to: string
|
||||||
@@ -51,6 +54,8 @@ interface NavItem {
|
|||||||
icon: ComponentType<{ className?: string }>
|
icon: ComponentType<{ className?: string }>
|
||||||
description?: string
|
description?: string
|
||||||
search?: Record<string, string>
|
search?: Record<string, string>
|
||||||
|
/** Explicit permission override; when omitted derived from `to`. */
|
||||||
|
permission?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavGroup {
|
interface NavGroup {
|
||||||
@@ -74,6 +79,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: 'Маршрутизация',
|
label: 'Маршрутизация',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
{ 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: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
||||||
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
|
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
|
||||||
],
|
],
|
||||||
@@ -82,7 +88,6 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: 'Операции',
|
label: 'Операции',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
||||||
{ to: '/firewall', label: 'Файрвол', icon: Shield, description: 'Клиенты и правила' },
|
|
||||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
||||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
||||||
],
|
],
|
||||||
@@ -92,13 +97,25 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
||||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение' },
|
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
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(
|
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||||
)
|
)
|
||||||
@@ -116,8 +133,14 @@ const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
|
|||||||
keywords: [item.to.replace(/^\//, '')],
|
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 }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const visibleGroups = useVisibleNavGroups()
|
||||||
const activeItem =
|
const activeItem =
|
||||||
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
||||||
ALL_NAV_ITEMS[0]
|
ALL_NAV_ITEMS[0]
|
||||||
@@ -134,29 +157,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<SidebarProvider
|
<SidebarProvider
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
'--sidebar-width': '260px',
|
'--sidebar-width': '240px',
|
||||||
'--sidebar-width-icon': '62px',
|
|
||||||
'--header-height': '56px',
|
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader className="gap-2">
|
<SidebarHeader>
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
<AppSwitcher />
|
||||||
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
|
|
||||||
B
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
|
||||||
<span className="truncate text-sm font-semibold">EvoBGP</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
|
||||||
<CommandPalette items={COMMAND_ITEMS} />
|
|
||||||
</div>
|
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{NAV_GROUPS.map((group) => (
|
{visibleGroups.map((group) => (
|
||||||
<SidebarGroup key={group.label}>
|
<SidebarGroup key={group.label}>
|
||||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
@@ -182,11 +192,13 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
))}
|
))}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter />
|
<SidebarFooter>
|
||||||
|
<NavUser />
|
||||||
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="sticky top-0 z-10 flex h-(--header-height) shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
<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 />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
@@ -204,12 +216,15 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<AppsMenu />
|
||||||
<SystemMonitorPopover />
|
<SystemMonitorPopover />
|
||||||
<ModeToggle />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
<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>
|
</SidebarInset>
|
||||||
|
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</TooltipProvider>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,9 +29,11 @@ export type CommandPaletteItem = {
|
|||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
items: CommandPaletteItem[]
|
items: CommandPaletteItem[]
|
||||||
className?: string
|
className?: string
|
||||||
|
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
|
||||||
|
hotkeyOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette({ items }: CommandPaletteProps) {
|
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const searchInputId = useId()
|
const searchInputId = useId()
|
||||||
@@ -68,25 +70,27 @@ export function CommandPalette({ items }: CommandPaletteProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarGroup className="p-0">
|
{!hotkeyOnly ? (
|
||||||
<SidebarGroupContent className="relative">
|
<SidebarGroup className="p-0">
|
||||||
<Button
|
<SidebarGroupContent className="relative">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
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"
|
variant="outline"
|
||||||
onClick={() => setOpen(true)}
|
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
|
</Button>
|
||||||
aria-hidden
|
<Search
|
||||||
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
|
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 className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
|
||||||
</Kbd>
|
⌘K
|
||||||
</SidebarGroupContent>
|
</Kbd>
|
||||||
</SidebarGroup>
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -181,7 +181,7 @@ export function SystemMonitorPopover() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent align="end" sideOffset={8} className="w-80 gap-0! space-y-0! p-0!">
|
<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">
|
<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-foreground text-xs font-medium">Монитор EvoBGP</span>
|
||||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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 { DataGridCard, 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 (
|
||||||
|
<DataGridCard
|
||||||
|
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 } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DataGridCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
|
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
|
||||||
import { kpiGridClassName } from '@/lib/ui-surface'
|
|
||||||
|
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||||
import {
|
import {
|
||||||
communityLabel,
|
communityLabel,
|
||||||
@@ -7,7 +9,7 @@ import {
|
|||||||
moduleDohProfileIds,
|
moduleDohProfileIds,
|
||||||
} from '@/lib/modules/helpers'
|
} from '@/lib/modules/helpers'
|
||||||
import { dohPolicyRu } from '@/lib/ui-labels'
|
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
||||||
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
||||||
|
|
||||||
interface ModuleKpiCardsProps {
|
interface ModuleKpiCardsProps {
|
||||||
@@ -18,12 +20,6 @@ interface ModuleKpiCardsProps {
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function syntheticSparkline(seed: number): number[] {
|
|
||||||
return Array.from({ length: 9 }, (_, i) =>
|
|
||||||
Math.round(seed * (0.8 + (i / 9) * 0.2 + Math.sin(i) * 0.05)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModuleKpiCards({
|
export function ModuleKpiCards({
|
||||||
mod,
|
mod,
|
||||||
communities,
|
communities,
|
||||||
@@ -32,67 +28,65 @@ export function ModuleKpiCards({
|
|||||||
loading = false,
|
loading = false,
|
||||||
}: ModuleKpiCardsProps) {
|
}: ModuleKpiCardsProps) {
|
||||||
if (loading || !mod) {
|
if (loading || !mod) {
|
||||||
return <SectionCardsSkeleton count={4} />
|
return <KpiStatGridSkeleton count={4} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||||
const dohIds = moduleDohProfileIds(mod)
|
const dohIds = moduleDohProfileIds(mod)
|
||||||
|
|
||||||
const metrics: KpiSparklineMetric[] = [
|
const items: KpiStatItem[] = [
|
||||||
{
|
{
|
||||||
id: 'priority',
|
id: 'priority',
|
||||||
title: 'Приоритет',
|
icon: <Gauge aria-hidden />,
|
||||||
label: 'Порядок в ревизии',
|
iconClassName: 'text-info',
|
||||||
value: String(mod.priority ?? 0),
|
value: String(mod.priority ?? 0),
|
||||||
delta: mod.enabled !== false ? 'активен' : 'выключен',
|
label: 'Приоритет',
|
||||||
deltaVariant: mod.enabled !== false ? 'success-light' : 'outline',
|
footer: (
|
||||||
detail: mod.type,
|
<Badge variant={mod.enabled !== false ? 'success-light' : 'outline'} size="sm">
|
||||||
tone: 'info',
|
{mod.enabled !== false ? 'активен' : 'выключен'} · {mod.type}
|
||||||
sparkline: syntheticSparkline(mod.priority ?? 1),
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'interval',
|
id: 'interval',
|
||||||
title: 'Интервал',
|
icon: <Clock aria-hidden />,
|
||||||
label: 'Обновление',
|
iconClassName: 'text-warning',
|
||||||
value: moduleIntervalLabel(mod),
|
value: moduleIntervalLabel(mod),
|
||||||
delta: formatDateTime(mod.last_refreshed_at) || 'никогда',
|
label: 'Интервал обновления',
|
||||||
deltaVariant: 'primary-light',
|
footer: (
|
||||||
detail: 'last refresh',
|
<Badge variant="primary-light" size="sm">
|
||||||
tone: 'warning',
|
{formatDateTime(mod.last_refreshed_at) || 'никогда'}
|
||||||
sparkline: syntheticSparkline(12),
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'prefixes',
|
id: 'prefixes',
|
||||||
title: 'Префиксы',
|
icon: <Tags aria-hidden />,
|
||||||
label: mod.type === 'AS_PREFIXES' ? 'AS entries' : 'Записи',
|
iconClassName: 'text-success',
|
||||||
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
|
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
|
||||||
delta: `${asEntries.length} AS`,
|
label: mod.type === 'AS_PREFIXES' ? 'Префиксы AS' : 'Записи модуля',
|
||||||
deltaVariant: 'success-light',
|
footer: (
|
||||||
detail: 'в модуле',
|
<Badge variant="success-light" size="sm">
|
||||||
tone: 'success',
|
{asEntries.length} AS · в модуле
|
||||||
sparkline: syntheticSparkline(asPrefixTotal || asEntries.length || 1),
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'policy',
|
id: 'policy',
|
||||||
title: 'DoH / BGP',
|
icon: <Globe aria-hidden />,
|
||||||
label: communityLabel(mod.default_community_id, communities),
|
iconClassName: 'text-primary',
|
||||||
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
||||||
delta: dohPolicyRu(mod.doh_resolver_policy),
|
label: communityLabel(mod.default_community_id, communities),
|
||||||
deltaVariant: 'info-light',
|
footer: (
|
||||||
detail:
|
<Badge variant="info-light" size="sm">
|
||||||
dohIds.length > 0
|
{dohPolicyRu(mod.doh_resolver_policy)}
|
||||||
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')
|
{dohIds.length > 0
|
||||||
: 'без DoH',
|
? ` · ${dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')}`
|
||||||
tone: 'info',
|
: ' · без DoH'}
|
||||||
sparkline: syntheticSparkline(dohIds.length || 2),
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return <KpiStatGrid items={items} aria-label="KPI модуля" />
|
||||||
<section aria-label="KPI модуля" className={kpiGridClassName}>
|
|
||||||
{metrics.map((metric) => (
|
|
||||||
<KpiSparklineCard key={metric.id} metric={metric} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,17 +1,76 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus, SearchIcon, ActivityIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
|
||||||
|
|
||||||
import { DataGridCard } from '@/components/data-grid-shell'
|
|
||||||
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
|
|
||||||
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
|
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
|
||||||
import { QueryState } from '@/components/query-state'
|
import {
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
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'
|
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
type PeerTab = 'all' | 'established' | 'pending' | 'disabled'
|
/**
|
||||||
|
* 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 {
|
interface NetworkPeersCardProps {
|
||||||
items: PeerRow[]
|
items: PeerRow[]
|
||||||
@@ -22,24 +81,6 @@ interface NetworkPeersCardProps {
|
|||||||
onRetry: () => void
|
onRetry: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterPeers(items: PeerRow[], tab: PeerTab): PeerRow[] {
|
|
||||||
if (tab === 'all') return items
|
|
||||||
if (tab === 'disabled') return items.filter((p) => p.enabled === false)
|
|
||||||
const enabled = items.filter((p) => p.enabled !== false)
|
|
||||||
if (tab === 'established') return enabled.filter((p) => p.session_state === 'Established')
|
|
||||||
return enabled.filter((p) => p.session_state !== 'Established')
|
|
||||||
}
|
|
||||||
|
|
||||||
function tabCounts(items: PeerRow[]) {
|
|
||||||
const enabled = items.filter((p) => p.enabled !== false)
|
|
||||||
return {
|
|
||||||
all: items.length,
|
|
||||||
established: enabled.filter((p) => p.session_state === 'Established').length,
|
|
||||||
pending: enabled.filter((p) => p.session_state !== 'Established').length,
|
|
||||||
disabled: items.length - enabled.length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NetworkPeersCard({
|
export function NetworkPeersCard({
|
||||||
items,
|
items,
|
||||||
speakers,
|
speakers,
|
||||||
@@ -49,48 +90,44 @@ export function NetworkPeersCard({
|
|||||||
onRetry,
|
onRetry,
|
||||||
}: NetworkPeersCardProps) {
|
}: NetworkPeersCardProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [tab, setTab] = useState<PeerTab>('all')
|
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
|
||||||
const counts = useMemo(() => tabCounts(items), [items])
|
|
||||||
const filtered = useMemo(() => filterPeers(items, tab), [items, tab])
|
const addButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||||
|
<Plus />
|
||||||
|
Добавить пира
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataGridCard
|
<ResourcePage
|
||||||
title="Пиры"
|
title="Пиры"
|
||||||
description="BGP-соседи и привязка к спикерам"
|
description="BGP-соседи и привязка к спикерам"
|
||||||
actions={
|
tabs={PEER_TABS}
|
||||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
tabFilter={peerTabFilter}
|
||||||
<Plus />
|
filterFields={peerFilterFields}
|
||||||
Добавить пира
|
filters={filters}
|
||||||
</Button>
|
onFiltersChange={setFilters}
|
||||||
}
|
onClearFilters={() => setFilters(createDefaultPeerFilters())}
|
||||||
>
|
getFilterFieldValue={getPeerFilterFieldValue}
|
||||||
<div className="border-b px-5 py-3">
|
columns={peerColumns}
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)}>
|
data={items}
|
||||||
<TabsList>
|
getRowId={(row) => row.id}
|
||||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
isLoading={isLoading}
|
||||||
<TabsTrigger value="established">Established ({counts.established})</TabsTrigger>
|
isError={isError}
|
||||||
<TabsTrigger value="pending">Ожидание ({counts.pending})</TabsTrigger>
|
error={error instanceof Error ? error : null}
|
||||||
<TabsTrigger value="disabled">Выключены ({counts.disabled})</TabsTrigger>
|
onRetry={onRetry}
|
||||||
</TabsList>
|
primaryAction={addButton}
|
||||||
</Tabs>
|
emptyState={{
|
||||||
</div>
|
title: 'Нет пиров',
|
||||||
<QueryState
|
description: 'Добавьте первого BGP-соседа.',
|
||||||
data={filtered}
|
action: addButton,
|
||||||
isLoading={isLoading}
|
}}
|
||||||
isError={isError}
|
/>
|
||||||
error={error}
|
|
||||||
empty={filtered.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} />
|
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,90 +1,77 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||||
import type { PeerRow } from '@/types/api'
|
import type { PeerRow } from '@/types/api'
|
||||||
|
|
||||||
export function NetworkPeersGrid({
|
export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
|
||||||
items,
|
{
|
||||||
isLoading = false,
|
id: 'name',
|
||||||
}: {
|
accessorFn: (row) => row.name ?? row.neighbor,
|
||||||
items: PeerRow[]
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
isLoading?: boolean
|
cell: ({ row }) => (
|
||||||
}) {
|
<DataGridPrimaryCell
|
||||||
const columns = useMemo<ColumnDef<PeerRow>[]>(
|
title={row.original.name ?? row.original.neighbor}
|
||||||
() => [
|
subtitle={row.original.name ? row.original.neighbor : undefined}
|
||||||
{
|
accent="primary"
|
||||||
id: 'name',
|
/>
|
||||||
accessorFn: (row) => row.name ?? row.neighbor,
|
),
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
meta: { headerTitle: 'Имя' },
|
||||||
cell: ({ row }) => (
|
},
|
||||||
<DataGridPrimaryCell
|
{
|
||||||
title={row.original.name ?? row.original.neighbor}
|
accessorKey: 'neighbor',
|
||||||
subtitle={row.original.name ? row.original.neighbor : undefined}
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
||||||
accent="primary"
|
cell: ({ row }) => (
|
||||||
/>
|
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Имя' },
|
meta: { headerTitle: 'Адрес соседа' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'neighbor',
|
accessorKey: 'remote_asn',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Адрес соседа' },
|
meta: { headerTitle: 'ASN' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'remote_asn',
|
accessorKey: 'session_state',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
|
<div className="flex items-center gap-1">
|
||||||
),
|
<StatusBadge
|
||||||
meta: { headerTitle: 'ASN' },
|
status={row.original.session_state ?? '—'}
|
||||||
},
|
label={bgpSessionStateRu(row.original.session_state)}
|
||||||
{
|
/>
|
||||||
accessorKey: 'session_state',
|
{row.original.session_mismatch ? (
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
<CategoryBadge tone="warning">расхождение</CategoryBadge>
|
||||||
cell: ({ row }) => (
|
) : null}
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
<StatusBadge
|
),
|
||||||
status={row.original.session_state ?? '—'}
|
meta: { headerTitle: 'Состояние' },
|
||||||
label={bgpSessionStateRu(row.original.session_state)}
|
},
|
||||||
/>
|
]
|
||||||
{row.original.session_mismatch ? (
|
|
||||||
<CategoryBadge tone="warning">расхождение</CategoryBadge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Состояние' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
|
||||||
data: items,
|
switch (field) {
|
||||||
columns,
|
case 'name':
|
||||||
getSearchText: (row) =>
|
return item.name ?? item.neighbor
|
||||||
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
|
case 'neighbor':
|
||||||
getRowId: (row) => row.id,
|
return item.neighbor
|
||||||
})
|
case 'session_state':
|
||||||
|
return item.session_state
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
export function peerTabFilter(item: PeerRow, tabId: string): boolean {
|
||||||
<DataGridSection
|
if (tabId === 'disabled') return item.enabled === false
|
||||||
table={table}
|
const enabled = item.enabled !== false
|
||||||
recordCount={filteredCount}
|
if (tabId === 'established') return enabled && item.session_state === 'Established'
|
||||||
isLoading={isLoading}
|
if (tabId === 'pending') return enabled && item.session_state !== 'Established'
|
||||||
emptyMessage="Нет пиров"
|
return true
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск пиров…"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,65 @@
|
|||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus, SearchIcon, TagIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { DataGridCard } from '@/components/data-grid-shell'
|
|
||||||
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
|
|
||||||
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
|
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
|
||||||
import { QueryState } from '@/components/query-state'
|
import {
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
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 type { SpeakerRow } from '@/types/api'
|
import type { SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BGP speakers list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||||
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 {
|
interface NetworkSpeakersCardProps {
|
||||||
items: SpeakerRow[]
|
items: SpeakerRow[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
@@ -26,35 +76,44 @@ export function NetworkSpeakersCard({
|
|||||||
onRetry,
|
onRetry,
|
||||||
}: NetworkSpeakersCardProps) {
|
}: NetworkSpeakersCardProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
|
||||||
|
|
||||||
|
const addButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||||
|
<Plus />
|
||||||
|
Добавить спикера
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataGridCard
|
<ResourcePage
|
||||||
title="Спикеры"
|
title="Спикеры"
|
||||||
description="BIRD-агенты на нодах tenant"
|
description="BIRD-агенты на нодах tenant"
|
||||||
actions={
|
tabs={SPEAKER_TABS}
|
||||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
tabFilter={speakerTabFilter}
|
||||||
<Plus />
|
filterFields={speakerFilterFields}
|
||||||
Добавить спикера
|
filters={filters}
|
||||||
</Button>
|
onFiltersChange={setFilters}
|
||||||
}
|
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
|
||||||
>
|
getFilterFieldValue={getSpeakerFilterFieldValue}
|
||||||
<QueryState
|
columns={speakerColumns}
|
||||||
data={items}
|
data={items}
|
||||||
isLoading={isLoading}
|
getRowId={(row) => row.id}
|
||||||
isError={isError}
|
isLoading={isLoading}
|
||||||
error={error}
|
isError={isError}
|
||||||
empty={items.length === 0}
|
error={error instanceof Error ? error : null}
|
||||||
emptyTitle="Нет спикеров"
|
onRetry={onRetry}
|
||||||
emptyDescription="Добавьте первого BIRD-агента на ноде."
|
primaryAction={addButton}
|
||||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
emptyState={{
|
||||||
onRetry={onRetry}
|
title: 'Нет спикеров',
|
||||||
>
|
description: 'Добавьте первого BIRD-агента на ноде.',
|
||||||
{(data) => (
|
action: addButton,
|
||||||
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
|
}}
|
||||||
)}
|
/>
|
||||||
</QueryState>
|
|
||||||
</DataGridCard>
|
|
||||||
|
|
||||||
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,87 +1,78 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import { speakerOnlineLabel } from '@/lib/ui-labels'
|
import { speakerOnlineLabel } from '@/lib/ui-labels'
|
||||||
import type { SpeakerRow } from '@/types/api'
|
import type { SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
export function NetworkSpeakersGrid({
|
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||||
items,
|
{
|
||||||
isLoading = false,
|
accessorKey: 'endpoint',
|
||||||
}: {
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
||||||
items: SpeakerRow[]
|
cell: ({ row }) => (
|
||||||
isLoading?: boolean
|
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
||||||
}) {
|
),
|
||||||
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
|
meta: { headerTitle: 'Конечная точка' },
|
||||||
() => [
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'endpoint',
|
accessorKey: 'role',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
||||||
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
meta: { headerTitle: 'Роль' },
|
||||||
),
|
},
|
||||||
meta: { headerTitle: 'Конечная точка' },
|
{
|
||||||
},
|
id: 'agent',
|
||||||
{
|
enableSorting: false,
|
||||||
accessorKey: 'role',
|
header: 'Агент',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
const live = row.original.live
|
||||||
meta: { headerTitle: 'Роль' },
|
if (live?.agent_ok === true) {
|
||||||
},
|
return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
|
||||||
{
|
}
|
||||||
id: 'agent',
|
if (live?.agent_ok === false) {
|
||||||
enableSorting: false,
|
return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
|
||||||
header: 'Агент',
|
}
|
||||||
cell: ({ row }) => {
|
return (
|
||||||
const live = row.original.live
|
<Badge variant="outline" size="sm" radius="full">
|
||||||
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
|
—
|
||||||
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
|
</Badge>
|
||||||
return <Badge variant="outline" size="sm" radius="full">—</Badge>
|
)
|
||||||
},
|
},
|
||||||
meta: { headerTitle: 'Агент' },
|
meta: { headerTitle: 'Агент' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bgp',
|
id: 'bgp',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: 'BGP',
|
header: 'BGP',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const live = row.original.live
|
const live = row.original.live
|
||||||
if (!live) return '—'
|
if (!live) return '—'
|
||||||
return (
|
return (
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
meta: { headerTitle: 'BGP' },
|
meta: { headerTitle: 'BGP' },
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
|
||||||
data: items,
|
switch (field) {
|
||||||
columns,
|
case 'endpoint':
|
||||||
getSearchText: (row) =>
|
return item.endpoint
|
||||||
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
|
case 'role':
|
||||||
getRowId: (row) => row.id,
|
return item.role
|
||||||
})
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean {
|
||||||
<DataGridSection
|
if (tabId === 'online') return item.live?.agent_ok === true
|
||||||
table={table}
|
if (tabId === 'offline') return item.live?.agent_ok !== true
|
||||||
recordCount={filteredCount}
|
return true
|
||||||
isLoading={isLoading}
|
|
||||||
emptyMessage="Нет спикеров"
|
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск спикеров…"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ export function OperationsJobsCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridCard title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
|
<DataGridCard title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
|
||||||
<div className="border-b px-5 py-3">
|
<div className="px-5 pt-3">
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)}>
|
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
|
||||||
<TabsList>
|
<TabsList variant="line" className="w-full justify-start gap-6">
|
||||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||||
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger>
|
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger>
|
||||||
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
|
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Frame,
|
||||||
CardAction,
|
FrameDescription,
|
||||||
CardContent,
|
FrameFooter,
|
||||||
CardDescription,
|
FrameHeader,
|
||||||
CardFooter,
|
FramePanel,
|
||||||
CardHeader,
|
FrameTitle,
|
||||||
CardTitle,
|
} from '@/components/reui/frame'
|
||||||
} from '@evobgp/ui/components/card'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
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 panelCardHeaderClassName = 'border-b'
|
||||||
export const panelCardContentFlushClassName = 'px-0'
|
/** Flush body inside FramePanel (toolbar / data-grid). */
|
||||||
/** Horizontal inset for toolbar / pagination rows (matches edgeCell px-5). */
|
export const panelCardContentFlushClassName = 'p-0'
|
||||||
|
/** Horizontal inset for toolbar / pagination rows. */
|
||||||
export const panelCardInsetClassName = 'px-5 py-3'
|
export const panelCardInsetClassName = 'px-5 py-3'
|
||||||
export const panelCardFooterClassName = 'border-t bg-transparent'
|
export const panelCardFooterClassName = 'border-t bg-transparent'
|
||||||
|
|
||||||
@@ -45,22 +48,45 @@ export function PanelCard({
|
|||||||
size = 'default',
|
size = 'default',
|
||||||
}: PanelCardProps) {
|
}: PanelCardProps) {
|
||||||
const hasHeader = Boolean(title || description || actions)
|
const hasHeader = Boolean(title || description || actions)
|
||||||
|
const spacing = size === 'sm' ? 'sm' : 'default'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card size={size} className={cn(panelCardClassName, className)}>
|
<Frame
|
||||||
{hasHeader ? (
|
dense
|
||||||
<CardHeader className={cn(panelCardHeaderClassName, headerClassName)}>
|
spacing={spacing}
|
||||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
className={cn(panelCardClassName, className)}
|
||||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
>
|
||||||
{actions ? <CardAction>{actions}</CardAction> : null}
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
</CardHeader>
|
{hasHeader ? (
|
||||||
) : null}
|
<FrameHeader
|
||||||
{children != null && children !== false ? (
|
className={cn(
|
||||||
<CardContent className={contentClassName}>{children}</CardContent>
|
panelCardHeaderClassName,
|
||||||
) : null}
|
'flex-row items-start justify-between gap-3',
|
||||||
{footer ? (
|
headerClassName,
|
||||||
<CardFooter className={cn(panelCardFooterClassName, footerClassName)}>{footer}</CardFooter>
|
)}
|
||||||
) : null}
|
>
|
||||||
</Card>
|
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||||
|
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||||
|
{description ? (
|
||||||
|
<FrameDescription>{description}</FrameDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</FrameHeader>
|
||||||
|
) : null}
|
||||||
|
{children != null && children !== false ? (
|
||||||
|
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
|
||||||
|
) : null}
|
||||||
|
{footer ? (
|
||||||
|
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
|
||||||
|
{footer}
|
||||||
|
</FrameFooter>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { DonutBreakdownCard } from './donut-breakdown-card'
|
export { DonutBreakdownCard } from './donut-breakdown-card'
|
||||||
export { IllustratedEmptyState } from './illustrated-empty-state'
|
export { IllustratedEmptyState } from './illustrated-empty-state'
|
||||||
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
||||||
|
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||||
export { PanelCorners } from './panel-corners'
|
export { PanelCorners } from './panel-corners'
|
||||||
export { ProjectsEmptyState } from './projects-empty-state'
|
export { ProjectsEmptyState } from './projects-empty-state'
|
||||||
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
||||||
|
|||||||
@@ -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,26 @@
|
|||||||
|
export {
|
||||||
|
applyFiltersToData,
|
||||||
|
getActiveFilters,
|
||||||
|
renderSelectedCount,
|
||||||
|
renderSingleSelectedLabel,
|
||||||
|
} from './filter-utils'
|
||||||
|
export {
|
||||||
|
ResourcePage,
|
||||||
|
type ResourcePageProps,
|
||||||
|
type ResourcePageTab,
|
||||||
|
} from './resource-page'
|
||||||
|
export {
|
||||||
|
KpiStatGrid,
|
||||||
|
KpiStatCard,
|
||||||
|
KpiStatCardTile,
|
||||||
|
kpiStatItemKey,
|
||||||
|
type KpiStatItem,
|
||||||
|
type KpiStatCardData,
|
||||||
|
type KpiStatCard as KpiStatCardType,
|
||||||
|
type KpiStatVariant,
|
||||||
|
type OpsKpiCard,
|
||||||
|
} from './kpi-stat-grid'
|
||||||
|
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||||
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
|
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
|
||||||
|
export function kpiCols(count: number): string {
|
||||||
|
if (count <= 1) return 'grid-cols-1'
|
||||||
|
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||||
|
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||||
|
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||||
|
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||||
|
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||||
|
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import type { KeyboardEvent, ReactNode } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { kpiCols } from './kpi-cols'
|
||||||
|
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||||
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
|
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export type KpiStatItem = {
|
||||||
|
id?: string
|
||||||
|
label: ReactNode
|
||||||
|
value: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
to?: string
|
||||||
|
search?: Record<string, unknown>
|
||||||
|
onSelect?: () => void
|
||||||
|
onClick?: () => void
|
||||||
|
selected?: boolean
|
||||||
|
active?: boolean
|
||||||
|
icon?: ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
variant?: KpiStatVariant
|
||||||
|
footer?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CFDM-compatible card shape (id required). */
|
||||||
|
export type KpiStatCardData = KpiStatItem & { id: string }
|
||||||
|
|
||||||
|
/** @deprecated Use KpiStatCardData */
|
||||||
|
export type OpsKpiCard = KpiStatCardData
|
||||||
|
|
||||||
|
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
|
||||||
|
export type KpiStatCard = KpiStatCardData
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
|
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||||
|
default: 'text-foreground',
|
||||||
|
warning: 'text-warning',
|
||||||
|
destructive: 'text-destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
onActivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
|
||||||
|
return item.onClick ?? item.onSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(item: KpiStatItem): boolean {
|
||||||
|
return Boolean(item.selected ?? item.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFooter(item: KpiStatItem): ReactNode {
|
||||||
|
if (item.footer) return item.footer
|
||||||
|
if (typeof item.hint === 'string') {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{item.hint}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.hint) return item.hint
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||||
|
const footer = resolveFooter(item)
|
||||||
|
const valueVariant = item.variant ?? 'default'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
|
{item.icon ? (
|
||||||
|
<Item
|
||||||
|
className={cn(
|
||||||
|
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||||
|
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
{item.icon}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||||
|
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-2xl leading-none font-bold tabular-nums',
|
||||||
|
VALUE_VARIANT_CLASS[valueVariant],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelClassName(item: KpiStatItem, className?: string) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const clickable = Boolean(item.to || onActivate)
|
||||||
|
const selected = isSelected(item)
|
||||||
|
|
||||||
|
return cn(
|
||||||
|
'relative isolate flex h-full flex-col',
|
||||||
|
clickable &&
|
||||||
|
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||||
|
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single KPI tile — used for embedded / standalone contexts. */
|
||||||
|
export function KpiStatCardTile({
|
||||||
|
item,
|
||||||
|
embedded = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
item: KpiStatItem
|
||||||
|
embedded?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const panelClass = panelClassName(item, className)
|
||||||
|
|
||||||
|
let panel: ReactNode
|
||||||
|
|
||||||
|
if (item.to) {
|
||||||
|
panel = (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
} else if (onActivate) {
|
||||||
|
panel = (
|
||||||
|
<FramePanel
|
||||||
|
className={panelClass}
|
||||||
|
onClick={onActivate}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
panel = (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Frame className="h-full">{panel}</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
|
||||||
|
export function KpiStatCard({
|
||||||
|
item,
|
||||||
|
embedded = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
item: KpiStatItem
|
||||||
|
embedded?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<Frame className="@container w-full">
|
||||||
|
<div className={cn('grid gap-2', kpiCols(count))}>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<FramePanel key={index} className="flex items-start gap-3">
|
||||||
|
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiStatGridProps {
|
||||||
|
/** EvoBGP primary API */
|
||||||
|
items?: KpiStatItem[]
|
||||||
|
/** CFDM-compatible API */
|
||||||
|
cards?: KpiStatCardData[]
|
||||||
|
isLoading?: boolean
|
||||||
|
emptyMessage?: ReactNode
|
||||||
|
emptyIcon?: ReactNode
|
||||||
|
className?: string
|
||||||
|
skeletonCount?: number
|
||||||
|
/** Wrap each tile in its own Frame (analytics panels). */
|
||||||
|
embedded?: boolean
|
||||||
|
'aria-label'?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const panelClass = panelClassName(item)
|
||||||
|
|
||||||
|
if (item.to) {
|
||||||
|
return (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onActivate) {
|
||||||
|
return (
|
||||||
|
<FramePanel
|
||||||
|
className={panelClass}
|
||||||
|
onClick={onActivate}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
|
||||||
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function KpiStatGrid({
|
||||||
|
items,
|
||||||
|
cards,
|
||||||
|
isLoading = false,
|
||||||
|
emptyMessage,
|
||||||
|
emptyIcon,
|
||||||
|
className,
|
||||||
|
skeletonCount = 4,
|
||||||
|
embedded = false,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: KpiStatGridProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <KpiStatGridSkeleton count={skeletonCount} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = items ?? cards ?? []
|
||||||
|
|
||||||
|
if (list.length === 0 && (emptyMessage || emptyIcon)) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn('w-full', className)}>
|
||||||
|
<FramePanel className="flex items-center gap-3 p-4">
|
||||||
|
{emptyIcon}
|
||||||
|
{emptyMessage ? (
|
||||||
|
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||||
|
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||||
|
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||||
|
if (item.id) return item.id
|
||||||
|
if (typeof item.label === 'string') return item.label
|
||||||
|
return `kpi-${index}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
|
||||||
|
|
||||||
|
export type OpsKpiCard = KpiStatCardData
|
||||||
|
|
||||||
|
interface OpsDashboardProps {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
kpiCards: OpsKpiCard[]
|
||||||
|
charts: ReactNode
|
||||||
|
queue: ReactNode
|
||||||
|
queueTitle?: string
|
||||||
|
queueDescription?: string
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootClassName =
|
||||||
|
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
||||||
|
|
||||||
|
function OpsDashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className={rootClassName}>
|
||||||
|
<header className="px-1">
|
||||||
|
<Skeleton className="h-7 w-56" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||||
|
</header>
|
||||||
|
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||||
|
<div className="grid gap-2 @3xl:grid-cols-2">
|
||||||
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-48 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpsDashboard({
|
||||||
|
title = 'Панель управления',
|
||||||
|
description = 'Обзор модулей, сети, задач и состояния плоскости управления',
|
||||||
|
kpiCards,
|
||||||
|
charts,
|
||||||
|
queue,
|
||||||
|
queueTitle = 'Требуют внимания',
|
||||||
|
queueDescription = 'Проблемы BGP, сбои задач и офлайн-спикеры',
|
||||||
|
isLoading = false,
|
||||||
|
}: OpsDashboardProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <OpsDashboardSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={rootClassName}>
|
||||||
|
<PageHeader title={title} description={description} />
|
||||||
|
|
||||||
|
<section aria-label="Ключевые метрики">
|
||||||
|
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-label="Аналитика"
|
||||||
|
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||||
|
>
|
||||||
|
{charts}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label={queueTitle}>
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>{queueTitle}</FrameTitle>
|
||||||
|
<FrameDescription>{queueDescription}</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel>{queue}</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { kpiCols } from './kpi-cols'
|
||||||
|
|
||||||
|
export interface QuickActionItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
to: string
|
||||||
|
search?: Record<string, unknown>
|
||||||
|
icon?: ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickActionGridProps {
|
||||||
|
actions: QuickActionItem[]
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
|
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
|
{action.icon ? (
|
||||||
|
<Item
|
||||||
|
className={cn(
|
||||||
|
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||||
|
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
{action.icon}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||||
|
<Badge variant="outline" size="sm" className="shrink-0">
|
||||||
|
Перейти
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||||
|
{action.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||||
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function QuickActionGrid({
|
||||||
|
actions,
|
||||||
|
title = 'Быстрые действия',
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
}: QuickActionGridProps) {
|
||||||
|
if (actions.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
|
||||||
|
{(title || description) && (
|
||||||
|
<FrameHeader>
|
||||||
|
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||||
|
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||||
|
</FrameHeader>
|
||||||
|
)}
|
||||||
|
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<FramePanel
|
||||||
|
key={action.id}
|
||||||
|
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={action.to}
|
||||||
|
search={action.search}
|
||||||
|
className="focus-visible:outline-none"
|
||||||
|
aria-label={`${action.title}: ${action.description}`}
|
||||||
|
>
|
||||||
|
<QuickActionBody action={action} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
type PaginationState,
|
||||||
|
type RowSelectionState,
|
||||||
|
type SortingState,
|
||||||
|
} from '@tanstack/react-table'
|
||||||
|
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||||
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||||
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
|
import {
|
||||||
|
Filters,
|
||||||
|
type Filter,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
} from '@/components/reui/filters'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Separator } from '@evobgp/ui/components/separator'
|
||||||
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from '@/components/reui/alert'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { applyFiltersToData } from './filter-utils'
|
||||||
|
|
||||||
|
export interface ResourcePageTab {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourcePageProps<T extends object> {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
tabs?: ResourcePageTab[]
|
||||||
|
activeTab?: string
|
||||||
|
onTabChange?: (tabId: string) => void
|
||||||
|
tabFilter?: (item: T, tabId: string) => boolean
|
||||||
|
filterFields: FilterFieldConfig[]
|
||||||
|
filters: Filter[]
|
||||||
|
onFiltersChange: (filters: Filter[]) => void
|
||||||
|
onClearFilters?: () => void
|
||||||
|
getFilterFieldValue: (item: T, field: string) => unknown
|
||||||
|
columns: ColumnDef<T, unknown>[]
|
||||||
|
data: T[]
|
||||||
|
getRowId: (row: T) => string
|
||||||
|
isLoading?: boolean
|
||||||
|
isError?: boolean
|
||||||
|
error?: Error | null
|
||||||
|
onRetry?: () => void
|
||||||
|
primaryAction?: ReactNode
|
||||||
|
emptyState?: { title: string; description?: string; action?: ReactNode }
|
||||||
|
pageSize?: number
|
||||||
|
enableRowSelection?: boolean
|
||||||
|
selectionToolbar?: (ctx: {
|
||||||
|
selectedIds: string[]
|
||||||
|
selectedCount: number
|
||||||
|
clearSelection: () => void
|
||||||
|
}) => ReactNode
|
||||||
|
toolbarExtra?: ReactNode
|
||||||
|
hideHeader?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourcePageSkeleton() {
|
||||||
|
return (
|
||||||
|
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-72" />
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
<Skeleton className="h-9 w-full max-w-md" />
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourcePage<T extends object>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
tabs,
|
||||||
|
activeTab: controlledTab,
|
||||||
|
onTabChange,
|
||||||
|
tabFilter,
|
||||||
|
filterFields,
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
onClearFilters,
|
||||||
|
getFilterFieldValue,
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
getRowId,
|
||||||
|
isLoading = false,
|
||||||
|
isError = false,
|
||||||
|
error = null,
|
||||||
|
onRetry,
|
||||||
|
primaryAction,
|
||||||
|
emptyState,
|
||||||
|
pageSize = 10,
|
||||||
|
enableRowSelection = false,
|
||||||
|
selectionToolbar,
|
||||||
|
toolbarExtra,
|
||||||
|
hideHeader = false,
|
||||||
|
}: ResourcePageProps<T>) {
|
||||||
|
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||||
|
const activeTab = controlledTab ?? internalTab
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetPagination = useCallback(() => {
|
||||||
|
setPagination((current) =>
|
||||||
|
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
let result = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||||
|
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
||||||
|
result = result.filter((item) => tabFilter(item, activeTab))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
|
||||||
|
|
||||||
|
const tabCounts = useMemo(() => {
|
||||||
|
if (!tabs?.length || !tabFilter) return {}
|
||||||
|
const base = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (const tab of tabs) {
|
||||||
|
counts[tab.id] =
|
||||||
|
tab.id === 'all'
|
||||||
|
? base.length
|
||||||
|
: base.filter((item) => tabFilter(item, tab.id)).length
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
|
||||||
|
|
||||||
|
const selectedIds = useMemo(
|
||||||
|
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||||
|
[rowSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedCount = selectedIds.length
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setRowSelection({})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredData,
|
||||||
|
columns,
|
||||||
|
getRowId,
|
||||||
|
state: { sorting, rowSelection, pagination },
|
||||||
|
enableRowSelection,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (onTabChange) onTabChange(value)
|
||||||
|
else setInternalTab(value)
|
||||||
|
resetPagination()
|
||||||
|
},
|
||||||
|
[onTabChange, resetPagination],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFiltersChange = useCallback(
|
||||||
|
(next: Filter[]) => {
|
||||||
|
onFiltersChange(next)
|
||||||
|
resetPagination()
|
||||||
|
},
|
||||||
|
[onFiltersChange, resetPagination],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
onClearFilters?.()
|
||||||
|
resetPagination()
|
||||||
|
}, [onClearFilters, resetPagination])
|
||||||
|
|
||||||
|
const countedTabs = useMemo(
|
||||||
|
() =>
|
||||||
|
(tabs ?? []).map((tab) => ({
|
||||||
|
id: tab.id,
|
||||||
|
label: tab.label,
|
||||||
|
count: tabCounts[tab.id] ?? tab.count ?? 0,
|
||||||
|
})),
|
||||||
|
[tabs, tabCounts],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <ResourcePageSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlertIcon />
|
||||||
|
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||||
|
<AlertDescription className="flex flex-col gap-2">
|
||||||
|
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
|
||||||
|
{onRetry ? (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0 && emptyState) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={emptyState.title}
|
||||||
|
description={emptyState.description}
|
||||||
|
action={emptyState.action}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{selectionToolbar && selectedCount > 0
|
||||||
|
? selectionToolbar({
|
||||||
|
selectedIds,
|
||||||
|
selectedCount,
|
||||||
|
clearSelection,
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
<DataGrid
|
||||||
|
table={table}
|
||||||
|
recordCount={filteredData.length}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
>
|
||||||
|
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||||
|
{!hideHeader ? (
|
||||||
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
|
<FrameTitle className="text-balance">{title}</FrameTitle>
|
||||||
|
{description ? (
|
||||||
|
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||||
|
<span>{description}</span>
|
||||||
|
<span
|
||||||
|
className="bg-input size-1 shrink-0 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{filteredData.length} записей
|
||||||
|
</span>
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="bg-input size-1 shrink-0 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{selectedCount} выбрано</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</FrameDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{primaryAction ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
{primaryAction}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</FrameHeader>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FramePanel className="p-0 shadow-none!">
|
||||||
|
{countedTabs.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||||
|
<CountedLineTabs
|
||||||
|
tabs={countedTabs}
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={handleTabChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||||
|
<Filters
|
||||||
|
filters={filters}
|
||||||
|
fields={filterFields}
|
||||||
|
onChange={handleFiltersChange}
|
||||||
|
size="default"
|
||||||
|
trigger={
|
||||||
|
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||||
|
<FilterIcon className="size-4" aria-hidden="true" />
|
||||||
|
Фильтры
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
{toolbarExtra}
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<Badge size="sm" variant="secondary">
|
||||||
|
{selectedCount} выбрано
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{onClearFilters ? (
|
||||||
|
<Button type="button" variant="outline" onClick={handleClear}>
|
||||||
|
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<DataGridScrollArea>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGridScrollArea>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<FrameFooter>
|
||||||
|
<DataGridPagination
|
||||||
|
sizes={[5, 10, 20, 50]}
|
||||||
|
rowsPerPageLabel="Строк на странице"
|
||||||
|
info="{from} - {to} of {count}"
|
||||||
|
previousPageLabel="Предыдущая"
|
||||||
|
nextPageLabel="Следующая"
|
||||||
|
/>
|
||||||
|
</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</DataGrid>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { Separator } from '@evobgp/ui/components/separator'
|
||||||
|
|
||||||
|
export interface SettingsTabConfig {
|
||||||
|
id: string
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default nav for EvoBGP settings routes (`/settings`, `/tenant-settings`). */
|
||||||
|
const DEFAULT_TABS: SettingsTabConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'ui',
|
||||||
|
to: '/settings',
|
||||||
|
label: 'Настройки UI',
|
||||||
|
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tenant',
|
||||||
|
to: '/tenant-settings',
|
||||||
|
label: 'Настройки BIRD',
|
||||||
|
icon: <ServerCogIcon className="size-4" aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface SettingsShellProps {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
tabs?: SettingsTabConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsShell({
|
||||||
|
title = 'Настройки',
|
||||||
|
description = 'Подключение UI и параметры плоскости управления',
|
||||||
|
tabs = DEFAULT_TABS,
|
||||||
|
}: SettingsShellProps) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<PageHeader title={title} description={description} />
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex gap-5',
|
||||||
|
isMobile ? 'flex-col' : 'flex-row items-start',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tabs.length > 1 ? (
|
||||||
|
<nav
|
||||||
|
aria-label="Разделы настроек"
|
||||||
|
className={cn(
|
||||||
|
'flex gap-1',
|
||||||
|
isMobile
|
||||||
|
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
||||||
|
: 'w-44 shrink-0 flex-col',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = pathname.startsWith(tab.to)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.id}
|
||||||
|
to={tab.to}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||||
|
isMobile && 'shrink-0',
|
||||||
|
!isMobile && 'w-full',
|
||||||
|
isActive
|
||||||
|
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
[
|
||||||
|
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
|
||||||
|
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
|
||||||
|
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
|
||||||
|
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
|
||||||
|
"rounded-lg",
|
||||||
|
"px-3",
|
||||||
|
"py-2.5",
|
||||||
|
"has-[>svg]:gap-x-2.5",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
|
||||||
|
info: "border-info/30 bg-info/4 [&>svg]:text-info",
|
||||||
|
success: "border-success/30 bg-success/4 [&>svg]:text-success",
|
||||||
|
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
|
||||||
|
invert:
|
||||||
|
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn(
|
||||||
|
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
@@ -1,15 +1,69 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { format, isSameDay, parseISO } from 'date-fns'
|
import { format, isSameDay, parseISO } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
|
import { CalendarDays, Clock } from 'lucide-react'
|
||||||
|
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||||
|
import { PanelCard, panelCardContentFlushClassName } from '@/components/panel-card'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Calendar } from '@evobgp/ui/components/calendar'
|
import { Item } from '@evobgp/ui/components/item'
|
||||||
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@evobgp/ui/components/select'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { jobKindRu } from '@/lib/ui-labels'
|
import { jobKindRu } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
/** schedule-1 inspired agenda: calendar + day job list. */
|
import { ScheduleCalendarView } from './schedule-calendar-view'
|
||||||
|
|
||||||
|
type JobFilter = 'all' | 'refresh' | 'failed'
|
||||||
|
|
||||||
|
const FILTER_ITEMS: { value: JobFilter; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'Все задачи' },
|
||||||
|
{ value: 'refresh', label: 'Обновление' },
|
||||||
|
{ value: 'failed', label: 'С ошибкой' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function jobTimestamp(job: JobRow): string | undefined {
|
||||||
|
return job.created_at ?? job.started_at ?? job.finished_at ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
|
||||||
|
if (filter === 'refresh') return job.kind === 'module_refresh'
|
||||||
|
if (filter === 'failed')
|
||||||
|
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduleJobCard({ job }: { job: JobRow }) {
|
||||||
|
const ts = jobTimestamp(job)
|
||||||
|
const timeLabel = ts
|
||||||
|
? format(parseISO(ts), 'd MMM · HH:mm', { locale: ru })
|
||||||
|
: '—'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item variant="outline" size="xs" className="flex items-start gap-3 py-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<p className="text-foreground text-sm leading-tight font-medium">{jobKindRu(job.kind)}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||||
|
<Clock className="size-3 shrink-0" aria-hidden />
|
||||||
|
{timeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground font-mono text-xs">{job.job_id}</p>
|
||||||
|
</div>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** schedule-1 layout: calendar column + day job list. */
|
||||||
export function ScheduleAgendaPanel({
|
export function ScheduleAgendaPanel({
|
||||||
jobs,
|
jobs,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -18,25 +72,12 @@ export function ScheduleAgendaPanel({
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [date, setDate] = useState<Date>(new Date())
|
const [date, setDate] = useState<Date>(new Date())
|
||||||
|
const [filter, setFilter] = useState<JobFilter>('all')
|
||||||
const dayJobs = useMemo(
|
|
||||||
() =>
|
|
||||||
jobs.filter((job) => {
|
|
||||||
const raw = job.created_at ?? job.started_at ?? job.finished_at
|
|
||||||
if (!raw) return false
|
|
||||||
try {
|
|
||||||
return isSameDay(parseISO(raw), date)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[jobs, date],
|
|
||||||
)
|
|
||||||
|
|
||||||
const markedDays = useMemo(() => {
|
const markedDays = useMemo(() => {
|
||||||
const days = new Set<string>()
|
const days = new Set<string>()
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
const raw = job.created_at ?? job.started_at ?? job.finished_at
|
const raw = jobTimestamp(job)
|
||||||
if (!raw) continue
|
if (!raw) continue
|
||||||
try {
|
try {
|
||||||
days.add(format(parseISO(raw), 'yyyy-MM-dd'))
|
days.add(format(parseISO(raw), 'yyyy-MM-dd'))
|
||||||
@@ -47,44 +88,85 @@ export function ScheduleAgendaPanel({
|
|||||||
return days
|
return days
|
||||||
}, [jobs])
|
}, [jobs])
|
||||||
|
|
||||||
|
const dayJobs = useMemo(
|
||||||
|
() =>
|
||||||
|
jobs.filter((job) => {
|
||||||
|
const raw = jobTimestamp(job)
|
||||||
|
if (!raw) return false
|
||||||
|
try {
|
||||||
|
return isSameDay(parseISO(raw), date) && matchesFilter(job, filter)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[jobs, date, filter],
|
||||||
|
)
|
||||||
|
|
||||||
|
const headingLabel = format(date, 'EEEE, d MMMM', { locale: ru })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Календарь задач"
|
title="Календарь задач"
|
||||||
description="Задачи refresh и apply по дням (schedule-1 pattern)"
|
description="Задачи refresh и apply по дням"
|
||||||
className="h-full"
|
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,280px)_1fr]">
|
<div className="flex flex-col lg:flex-row">
|
||||||
<Calendar
|
<div className="border-border shrink-0 border-b p-5 lg:w-[370px] lg:border-r lg:border-b-0">
|
||||||
mode="single"
|
<ScheduleCalendarView
|
||||||
selected={date}
|
selected={date}
|
||||||
onSelect={(d) => d && setDate(d)}
|
onSelect={(d) => d && setDate(d)}
|
||||||
locale={ru}
|
datesWithEvents={markedDays}
|
||||||
modifiers={{
|
/>
|
||||||
hasJob: (d) => markedDays.has(format(d, 'yyyy-MM-dd')),
|
</div>
|
||||||
}}
|
|
||||||
modifiersClassNames={{ hasJob: 'font-bold underline' }}
|
<div className="flex min-w-0 flex-1 flex-col gap-4 p-5">
|
||||||
/>
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||||
<ScrollArea className="h-64 lg:h-auto">
|
<div className="min-w-0">
|
||||||
{isLoading ? (
|
<h2 className="text-foreground text-sm font-semibold capitalize">{headingLabel}</h2>
|
||||||
<p className="text-muted-foreground text-sm">Загрузка…</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
) : dayJobs.length === 0 ? (
|
{isLoading
|
||||||
<p className="text-muted-foreground text-sm">
|
? 'Загрузка…'
|
||||||
Нет задач за {format(date, 'd MMMM yyyy', { locale: ru })}
|
: dayJobs.length > 0
|
||||||
</p>
|
? `${dayJobs.length} ${dayJobs.length === 1 ? 'задача' : dayJobs.length < 5 ? 'задачи' : 'задач'}`
|
||||||
) : (
|
: 'Нет задач за выбранный день'}
|
||||||
<ul className="space-y-2 pr-3">
|
</p>
|
||||||
{dayJobs.map((job) => (
|
</div>
|
||||||
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
|
<Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<SelectTrigger size="sm" className="w-full sm:w-44">
|
||||||
<span className="font-medium">{jobKindRu(job.kind)}</span>
|
<SelectValue />
|
||||||
<StatusBadge status={job.status} />
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
<p className="text-muted-foreground mt-1 text-xs">{job.job_id}</p>
|
{FILTER_ITEMS.map((item) => (
|
||||||
</li>
|
<SelectItem key={item.value} value={item.value}>
|
||||||
))}
|
{item.label}
|
||||||
</ul>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
</ScrollArea>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-[280px]">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Загрузка задач…</p>
|
||||||
|
) : dayJobs.length === 0 ? (
|
||||||
|
<IllustratedEmptyState
|
||||||
|
icon={CalendarDays}
|
||||||
|
title="Нет задач"
|
||||||
|
description={`За ${format(date, 'd MMMM yyyy', { locale: ru })} задачи не найдены. Выберите другой день или измените фильтр.`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="max-h-[320px] pr-3">
|
||||||
|
<ul className="space-y-2.5">
|
||||||
|
{dayJobs.map((job) => (
|
||||||
|
<li key={job.job_id}>
|
||||||
|
<ScheduleJobCard job={job} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useState, type ComponentPropsWithoutRef } from 'react'
|
||||||
|
import { DayButton } from 'react-day-picker'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { ru } from 'date-fns/locale'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Calendar, CalendarDayButton } from '@evobgp/ui/components/calendar'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@evobgp/ui/components/select'
|
||||||
|
|
||||||
|
const MONTHS_RU = Array.from({ length: 12 }, (_, i) =>
|
||||||
|
format(new Date(2024, i, 1), 'LLLL', { locale: ru }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear()
|
||||||
|
const YEARS = Array.from({ length: 11 }, (_, i) => CURRENT_YEAR - 5 + i)
|
||||||
|
|
||||||
|
const TODAY_WEEKDAY = format(new Date(), 'EEEEEE', { locale: ru }).toUpperCase()
|
||||||
|
|
||||||
|
export function ScheduleCalendarView({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
datesWithEvents = new Set<string>(),
|
||||||
|
}: {
|
||||||
|
selected: Date | undefined
|
||||||
|
onSelect: (date: Date | undefined) => void
|
||||||
|
datesWithEvents?: Set<string>
|
||||||
|
}) {
|
||||||
|
const [month, setMonth] = useState<Date>(selected ?? new Date())
|
||||||
|
|
||||||
|
const stepMonth = (delta: number) =>
|
||||||
|
setMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + delta, 1))
|
||||||
|
|
||||||
|
const handleMonthSelect = (value: string) => {
|
||||||
|
const i = MONTHS_RU.indexOf(value)
|
||||||
|
if (i >= 0) setMonth(new Date(month.getFullYear(), i, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleYearSelect = (value: string) => {
|
||||||
|
const y = parseInt(value, 10)
|
||||||
|
if (!Number.isNaN(y)) setMonth(new Date(y, month.getMonth(), 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 select-none">
|
||||||
|
<div className="flex w-full grow items-center justify-between gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="size-7 shrink-0 p-0"
|
||||||
|
onClick={() => stepMonth(-1)}
|
||||||
|
aria-label="Предыдущий месяц"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={MONTHS_RU[month.getMonth()]}
|
||||||
|
onValueChange={(value) => value && handleMonthSelect(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger size="sm" className="min-w-0 flex-1 capitalize">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MONTHS_RU.map((m) => (
|
||||||
|
<SelectItem key={m} value={m} className="capitalize">
|
||||||
|
{m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={String(month.getFullYear())}
|
||||||
|
onValueChange={(value) => value && handleYearSelect(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger size="sm" className="w-22 shrink-0">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{YEARS.map((y) => (
|
||||||
|
<SelectItem key={y} value={String(y)}>
|
||||||
|
{y}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="size-7 shrink-0 p-0"
|
||||||
|
onClick={() => stepMonth(1)}
|
||||||
|
aria-label="Следующий месяц"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={selected}
|
||||||
|
onSelect={onSelect}
|
||||||
|
month={month}
|
||||||
|
onMonthChange={setMonth}
|
||||||
|
locale={ru}
|
||||||
|
showOutsideDays
|
||||||
|
hideNavigation
|
||||||
|
className="w-full bg-transparent p-0 md:[--cell-size:--spacing(11)]"
|
||||||
|
formatters={{
|
||||||
|
formatWeekdayName: (date) =>
|
||||||
|
date.toLocaleString('ru-RU', { weekday: 'short' }).replace('.', '').toUpperCase(),
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
month_caption: 'hidden',
|
||||||
|
nav: 'hidden',
|
||||||
|
weekdays: 'flex gap-1',
|
||||||
|
weekday:
|
||||||
|
'flex-1 flex items-center justify-center h-6 text-[0.65rem] font-medium text-muted-foreground',
|
||||||
|
week: 'flex gap-1 mt-1',
|
||||||
|
day: 'flex-1 aspect-square p-0',
|
||||||
|
day_button: cn(
|
||||||
|
'bg-muted/50 hover:bg-muted rounded-md',
|
||||||
|
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[selected-single=true]:hover:bg-primary data-[selected-single=true]:hover:text-primary-foreground!',
|
||||||
|
),
|
||||||
|
outside: 'opacity-60',
|
||||||
|
disabled: 'opacity-60',
|
||||||
|
today: cn('bg-accent text-foreground rounded-md'),
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Weekday: ({ children, className: cls, ...props }: ComponentPropsWithoutRef<'th'>) => {
|
||||||
|
const isToday = children === TODAY_WEEKDAY
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className={cn(
|
||||||
|
'flex h-6! flex-1 items-center justify-center rounded-md text-xs font-medium',
|
||||||
|
isToday ? 'bg-accent text-foreground!' : 'text-muted-foreground',
|
||||||
|
cls,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: ({
|
||||||
|
children,
|
||||||
|
modifiers,
|
||||||
|
day,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) => {
|
||||||
|
const dateKey = format(day.date, 'yyyy-MM-dd')
|
||||||
|
const hasEvents = !modifiers.outside && datesWithEvents.has(dateKey)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CalendarDayButton day={day} modifiers={modifiers} {...props}>
|
||||||
|
{hasEvents ? (
|
||||||
|
<span
|
||||||
|
className="bg-primary text-primary-foreground in-data-[selected-single=true]:bg-primary-foreground! size-1 rounded-full"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="size-1" aria-hidden />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</CalendarDayButton>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { DataGridCard } from '@/components/data-grid-shell'
|
||||||
|
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 { ScheduleJobsGrid } from './schedule-jobs-grid'
|
||||||
|
|
||||||
|
type JobTab = 'all' | 'refresh' | 'failed'
|
||||||
|
|
||||||
|
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||||
|
if (tab === 'refresh') return items.filter((j) => j.kind === 'module_refresh')
|
||||||
|
if (tab === 'failed')
|
||||||
|
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabCounts(items: JobRow[]) {
|
||||||
|
return {
|
||||||
|
all: items.length,
|
||||||
|
refresh: items.filter((j) => j.kind === 'module_refresh').length,
|
||||||
|
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
|
.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jobs data-grid with status tabs (data-grid-filtering pattern). */
|
||||||
|
export function ScheduleJobsCard({
|
||||||
|
jobs,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
jobs: JobRow[]
|
||||||
|
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 (
|
||||||
|
<DataGridCard title="Задачи" description="Последние задачи из API">
|
||||||
|
<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="refresh">Обновление ({counts.refresh})</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) => (
|
||||||
|
<ScheduleJobsGrid
|
||||||
|
items={items}
|
||||||
|
isLoading={isLoading && items.length > 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</DataGridCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ export function ScheduleJobsGrid({
|
|||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет задач"
|
emptyMessage="Нет задач"
|
||||||
|
showPagination={items.length > 10}
|
||||||
searchValue={globalFilter}
|
searchValue={globalFilter}
|
||||||
onSearchChange={setGlobalFilter}
|
onSearchChange={setGlobalFilter}
|
||||||
searchPlaceholder="Поиск задач…"
|
searchPlaceholder="Поиск задач…"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactElement, ReactNode } from 'react'
|
import type { ReactElement, ReactNode } from 'react'
|
||||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { TruncatedText } from '@/components/truncated-text'
|
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||||
|
|
||||||
export interface SectionCardItem {
|
export interface SectionCardItem {
|
||||||
label: ReactNode
|
label: ReactNode
|
||||||
@@ -14,97 +14,48 @@ export interface SectionCardItem {
|
|||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
const VARIANT_ICON_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||||
default: '',
|
default: 'text-muted-foreground',
|
||||||
warning: 'border-warning/50',
|
warning: 'text-warning',
|
||||||
destructive: 'border-destructive/50',
|
|
||||||
}
|
|
||||||
|
|
||||||
function sectionGridClass(count: number): string {
|
|
||||||
if (count <= 1) return 'grid-cols-1'
|
|
||||||
if (count === 2) return 'sm:grid-cols-2'
|
|
||||||
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
|
|
||||||
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4'
|
|
||||||
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5'
|
|
||||||
if (count === 6) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6'
|
|
||||||
return 'sm:grid-cols-2 lg:grid-cols-3'
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALUE_VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
|
||||||
default: '',
|
|
||||||
warning: 'text-warning-foreground',
|
|
||||||
destructive: 'text-destructive',
|
destructive: 'text-destructive',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hintToFooter(hint: ReactNode) {
|
||||||
|
if (typeof hint === 'string') {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{hint}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return hint
|
||||||
|
}
|
||||||
|
|
||||||
|
function toKpiStatItem(item: SectionCardItem, index: number): KpiStatItem {
|
||||||
|
const variant = item.variant ?? 'default'
|
||||||
|
const footer =
|
||||||
|
item.badge ?? (item.hint ? hintToFooter(item.hint) : undefined)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof item.label === 'string' ? item.label : `section-${index}`,
|
||||||
|
icon: item.icon,
|
||||||
|
iconClassName: VARIANT_ICON_CLASS[variant],
|
||||||
|
value: item.value,
|
||||||
|
label: item.label,
|
||||||
|
variant,
|
||||||
|
footer,
|
||||||
|
active: item.active,
|
||||||
|
onClick: item.onClick,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KPI row for route sections — ReUI stats-12 / dashboard PRO pattern. */
|
||||||
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
|
<KpiStatGrid
|
||||||
{items.map((item, idx) => {
|
items={items.map(toKpiStatItem)}
|
||||||
const clickable = Boolean(item.onClick)
|
className={className}
|
||||||
const content = (
|
aria-label="Показатели"
|
||||||
<CardContent className="flex items-start gap-3 px-4 py-3">
|
/>
|
||||||
{item.icon ? (
|
|
||||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
{typeof item.label === 'string' ? (
|
|
||||||
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
|
|
||||||
) : (
|
|
||||||
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
|
|
||||||
)}
|
|
||||||
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1 text-lg font-semibold tabular-nums',
|
|
||||||
VALUE_VARIANT_CLASS[item.variant ?? 'default'],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</span>
|
|
||||||
{item.hint ? (
|
|
||||||
typeof item.hint === 'string' ? (
|
|
||||||
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
|
|
||||||
) : (
|
|
||||||
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={typeof item.label === 'string' ? item.label : idx}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
'gap-0',
|
|
||||||
VARIANT_CLASS[item.variant ?? 'default'],
|
|
||||||
item.active && 'border-primary ring-1 ring-primary/30',
|
|
||||||
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
|
|
||||||
)}
|
|
||||||
onClick={item.onClick}
|
|
||||||
role={clickable ? 'button' : undefined}
|
|
||||||
tabIndex={clickable ? 0 : undefined}
|
|
||||||
onKeyDown={
|
|
||||||
clickable
|
|
||||||
? (e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault()
|
|
||||||
item.onClick?.()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Moon, Sun, SunMoon } from 'lucide-react'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||||
|
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||||
|
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from '@evobgp/ui/components/toggle-group'
|
||||||
|
import { Switch } from '@evobgp/ui/components/switch'
|
||||||
|
import { apiMutate } from '@/lib/api-client'
|
||||||
|
import { settingsKeys, settingsQueryOptions } from '@/queries/settings'
|
||||||
|
|
||||||
|
const THEME_OPTIONS = [
|
||||||
|
{ value: 'light', label: 'Светлая', icon: Sun },
|
||||||
|
{ value: 'dark', label: 'Тёмная', icon: Moon },
|
||||||
|
{ value: 'system', label: 'Система', icon: SunMoon },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function parseShowQuickActions(value: unknown): boolean {
|
||||||
|
if (value === false || value === 0 || value === 'false' || value === '0') return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppearanceSettingsTab() {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
|
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
|
||||||
|
|
||||||
|
const patchMut = useMutation({
|
||||||
|
mutationFn: (payload: Record<string, boolean>) =>
|
||||||
|
apiMutate('/v1/settings', 'PATCH', payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Настройки интерфейса сохранены')
|
||||||
|
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SettingsCard
|
||||||
|
title="Тема интерфейса"
|
||||||
|
description="Быстрый переключатель также доступен в боковой панели"
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Тема"
|
||||||
|
description="Цветовая схема всех экранов в этом браузере."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Тема"
|
||||||
|
description="Влияет на цветовую схему всех экранов в этом браузере."
|
||||||
|
titleAddon={
|
||||||
|
<Badge variant="primary-light" size="sm">
|
||||||
|
мгновенно
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<ToggleGroup
|
||||||
|
multiple={false}
|
||||||
|
value={[theme ?? 'system']}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value.length > 0) setTheme(value[0])
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Тема интерфейса"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((option) => {
|
||||||
|
const Icon = option.icon
|
||||||
|
return (
|
||||||
|
<ToggleGroupItem key={option.value} value={option.value} className="gap-1.5">
|
||||||
|
<Icon aria-hidden className="size-3.5" />
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ToggleGroup>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Дашборд"
|
||||||
|
description="Блоки на экране «Обзор»"
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Быстрые действия"
|
||||||
|
description="Показывать KPI-like плитки быстрых переходов под метриками."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Быстрые действия"
|
||||||
|
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={showQuickActions}
|
||||||
|
disabled={settingsQ.isLoading || patchMut.isPending}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
patchMut.mutate({ ui_show_quick_actions: checked })
|
||||||
|
}
|
||||||
|
aria-label="Показывать быстрые действия"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Save } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||||
|
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||||
|
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import {
|
||||||
|
DEV_API_TOKEN,
|
||||||
|
normalizeApiToken,
|
||||||
|
setToken,
|
||||||
|
TOKEN_STORAGE_KEY,
|
||||||
|
} from '@/lib/api-client'
|
||||||
|
import { authKeys } from '@/queries/auth'
|
||||||
|
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
|
||||||
|
export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolean }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [token, setTokenValue] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''
|
||||||
|
setTokenValue(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function applyToken(raw: string) {
|
||||||
|
const normalized = normalizeApiToken(raw)
|
||||||
|
setToken(normalized || null)
|
||||||
|
setTokenValue(normalized)
|
||||||
|
await qc.invalidateQueries({ queryKey: authKeys.all })
|
||||||
|
toast.success('Токен сохранён')
|
||||||
|
if (normalized && tokenRequired) {
|
||||||
|
void navigate({ to: '/dashboard' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToken() {
|
||||||
|
void applyToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDevToken() {
|
||||||
|
void applyToken(DEV_API_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{tokenRequired ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
|
||||||
|
dev».
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Подключение к API"
|
||||||
|
description="Токен хранится только в этом браузере (localStorage)"
|
||||||
|
footer={
|
||||||
|
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||||
|
Использовать dev
|
||||||
|
</Button>
|
||||||
|
<LoadingButton onClick={saveToken}>
|
||||||
|
<Save />
|
||||||
|
Сохранить токен
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="API-токен"
|
||||||
|
description="Параметры подключения браузера к EvoBGP API."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Токен для запросов"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
|
||||||
|
ключами tenant — в разделе{' '}
|
||||||
|
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Права доступа
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
titleAddon={
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
localStorage
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
labelFor="settings-token"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="settings-token"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setTokenValue(e.target.value)}
|
||||||
|
placeholder="dev или API-ключ"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
SlidersHorizontalIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Fragment } from 'react'
|
||||||
|
|
||||||
|
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@evobgp/ui/components/item'
|
||||||
|
|
||||||
|
const ADMIN_SECTIONS = [
|
||||||
|
{
|
||||||
|
id: 'tenant-settings',
|
||||||
|
to: '/tenant-settings' as const,
|
||||||
|
title: 'Параметры арендатора',
|
||||||
|
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
||||||
|
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||||
|
badge: { label: 'operator', variant: 'warning-light' as const },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'access',
|
||||||
|
to: '/access' as const,
|
||||||
|
title: 'Права доступа',
|
||||||
|
description: 'API-ключи tenant, роли и управление доступом.',
|
||||||
|
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||||
|
badge: { label: 'operator', variant: 'warning-light' as const },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'monitoring',
|
||||||
|
to: '/monitoring' as const,
|
||||||
|
title: 'Мониторинг',
|
||||||
|
description: 'Метрики, состояние jobs и observability control plane.',
|
||||||
|
icon: <ActivityIcon aria-hidden="true" />,
|
||||||
|
badge: { label: 'viewer+', variant: 'info-light' as const },
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function SectionsSettingsTab() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SettingsCard
|
||||||
|
title="Разделы control plane"
|
||||||
|
description="Параметры tenant и операции — отдельно от настроек браузера"
|
||||||
|
>
|
||||||
|
<ItemGroup className="gap-0">
|
||||||
|
{ADMIN_SECTIONS.map((section, index) => (
|
||||||
|
<Fragment key={section.id}>
|
||||||
|
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||||
|
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
||||||
|
<ItemMedia className="self-center!">
|
||||||
|
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||||
|
{section.icon}
|
||||||
|
</Item>
|
||||||
|
</ItemMedia>
|
||||||
|
|
||||||
|
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||||
|
<ItemTitle className="gap-2 leading-5">
|
||||||
|
{section.title}
|
||||||
|
<Badge variant={section.badge.variant} size="xs">
|
||||||
|
{section.badge.label}
|
||||||
|
</Badge>
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription className="leading-5">{section.description}</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
|
||||||
|
<ItemActions className="shrink-0 justify-end self-center">
|
||||||
|
<Button variant="outline" size="sm" render={<Link to={section.to} />}>
|
||||||
|
Открыть
|
||||||
|
</Button>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</ItemGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { MonitorIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||||
|
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||||
|
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@evobgp/ui/components/item'
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
viewer: 'Просмотр',
|
||||||
|
editor: 'Редактор',
|
||||||
|
operator: 'Оператор',
|
||||||
|
node: 'Нода',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionSettingsTab() {
|
||||||
|
const hasStoredToken = Boolean(
|
||||||
|
typeof window !== 'undefined' && window.localStorage.getItem(TOKEN_STORAGE_KEY)?.trim(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionQ = useQuery({
|
||||||
|
...authSessionQueryOptions(),
|
||||||
|
enabled: hasStoredToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
|
||||||
|
<QueryState
|
||||||
|
data={sessionQ.data}
|
||||||
|
isLoading={sessionQ.isLoading && hasStoredToken}
|
||||||
|
isError={sessionQ.isError}
|
||||||
|
error={sessionQ.error}
|
||||||
|
empty={!hasStoredToken}
|
||||||
|
emptyTitle="Токен не задан"
|
||||||
|
emptyDescription="Сохраните API-токен во вкладке «Подключение»."
|
||||||
|
skeleton={<div className="h-24 px-5 py-4" />}
|
||||||
|
onRetry={() => sessionQ.refetch()}
|
||||||
|
>
|
||||||
|
{(session) => (
|
||||||
|
<ItemGroup className="gap-0">
|
||||||
|
<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">
|
||||||
|
<MonitorIcon aria-hidden="true" />
|
||||||
|
</Item>
|
||||||
|
</ItemMedia>
|
||||||
|
|
||||||
|
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||||
|
<ItemTitle className="gap-2 leading-5">
|
||||||
|
Этот браузер
|
||||||
|
<Badge variant="success-light" size="xs">
|
||||||
|
Активна
|
||||||
|
</Badge>
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription className="leading-5">
|
||||||
|
Tenant:{' '}
|
||||||
|
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
</ItemGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="Роль и доступ" description="Права текущего API-ключа">
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Роль"
|
||||||
|
description="Уровень доступа, определённый API-ключом."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Роль"
|
||||||
|
description="Определяет доступ к операциям control plane и CRUD."
|
||||||
|
titleAddon={
|
||||||
|
sessionQ.data ? (
|
||||||
|
<Badge variant="info-light" size="sm">
|
||||||
|
{ROLE_LABELS[sessionQ.data.role] ?? sessionQ.data.role}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
неизвестно
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
last
|
||||||
|
>
|
||||||
|
{sessionQ.data ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
|
||||||
|
<code className="font-mono text-xs">{sessionQ.data.tenant_id}</code>.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Сохраните токен и дождитесь проверки сессии.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||||
|
|
||||||
|
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
||||||
|
import { ConnectionSettingsTab } from './connection-settings-tab'
|
||||||
|
import { SectionsSettingsTab } from './sections-settings-tab'
|
||||||
|
import { SessionSettingsTab } from './session-settings-tab'
|
||||||
|
import {
|
||||||
|
SETTINGS_TAB_ITEMS,
|
||||||
|
type SettingsTab,
|
||||||
|
} from './settings-tabs-data'
|
||||||
|
|
||||||
|
export function SettingsPageShell({
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
tokenRequired,
|
||||||
|
}: {
|
||||||
|
activeTab: SettingsTab
|
||||||
|
onTabChange: (tab: SettingsTab) => void
|
||||||
|
tokenRequired: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<BadgeTabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => onTabChange(value as SettingsTab)}
|
||||||
|
items={SETTINGS_TAB_ITEMS.map((tab) => ({
|
||||||
|
value: tab.value,
|
||||||
|
label: tab.label,
|
||||||
|
icon: tab.icon,
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<TabsContent value="connection" className="mt-0">
|
||||||
|
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="session" className="mt-0">
|
||||||
|
<SessionSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="appearance" className="mt-0">
|
||||||
|
<AppearanceSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="sections" className="mt-0">
|
||||||
|
<SectionsSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
</BadgeTabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export function SettingsSettingField({
|
|||||||
contentClassName,
|
contentClassName,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: ReactNode
|
||||||
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
last?: boolean
|
last?: boolean
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
KeyRoundIcon,
|
||||||
|
MonitorSmartphoneIcon,
|
||||||
|
PaletteIcon,
|
||||||
|
SlidersHorizontalIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
|
||||||
|
|
||||||
|
export type SettingsTabItem = {
|
||||||
|
value: SettingsTab
|
||||||
|
label: string
|
||||||
|
icon: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||||
|
{
|
||||||
|
value: 'connection',
|
||||||
|
label: 'Подключение',
|
||||||
|
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'session',
|
||||||
|
label: 'Сессия',
|
||||||
|
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'appearance',
|
||||||
|
label: 'Оформление',
|
||||||
|
icon: <PaletteIcon aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'sections',
|
||||||
|
label: 'Разделы',
|
||||||
|
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function parseSettingsTab(value: unknown): SettingsTab {
|
||||||
|
if (
|
||||||
|
value === 'session' ||
|
||||||
|
value === 'appearance' ||
|
||||||
|
value === 'sections'
|
||||||
|
) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return 'connection'
|
||||||
|
}
|
||||||
@@ -1,34 +1,37 @@
|
|||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
|
import { KpiStatGrid } from '@/components/kpi-stat-grid'
|
||||||
import { panelCardInsetClassName } from '@/components/panel-card'
|
import { panelCardInsetClassName } from '@/components/panel-card'
|
||||||
import {
|
import {
|
||||||
chartPanelGridClassName,
|
chartPanelGridClassName,
|
||||||
dashboardMainSidebarClassName,
|
dashboardMainSidebarClassName,
|
||||||
kpiGridClassName,
|
|
||||||
} from '@/lib/ui-surface'
|
} from '@/lib/ui-surface'
|
||||||
import { SectionCards } from './section-cards'
|
|
||||||
|
|
||||||
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
export function KpiStatGridSkeleton({ count = 4 }: { count?: number }) {
|
||||||
return (
|
return (
|
||||||
<SectionCards
|
<KpiStatGrid
|
||||||
items={Array.from({ length: count }, (_, i) => ({
|
items={Array.from({ length: count }, (_, index) => ({
|
||||||
icon: <Skeleton className="size-4 rounded-sm" key={`icon-${i}`} />,
|
id: `kpi-skeleton-${index}`,
|
||||||
label: <Skeleton className="h-3 w-20" key={`label-${i}`} />,
|
icon: <Skeleton className="size-4 rounded-sm" />,
|
||||||
value: <Skeleton className="h-5 w-16" key={`value-${i}`} />,
|
value: <Skeleton className="h-8 w-16" />,
|
||||||
|
label: <Skeleton className="h-4 w-28" />,
|
||||||
|
footer: <Skeleton className="h-5 w-32 rounded-full" />,
|
||||||
}))}
|
}))}
|
||||||
|
aria-label="Загрузка показателей"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Matches dashboard-5 / telemetry layout: KPI row, charts, main+sidebar, bottom grids. */
|
/** @deprecated Use KpiStatGridSkeleton */
|
||||||
|
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||||
|
return <KpiStatGridSkeleton count={count} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches dashboard layout: KPI row, charts, main+sidebar, bottom grids. */
|
||||||
export function AnalyticsDashboardSkeleton() {
|
export function AnalyticsDashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="@container flex flex-col gap-4">
|
<div className="@container flex flex-col gap-4">
|
||||||
<div className={kpiGridClassName}>
|
<KpiStatGridSkeleton count={6} />
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<Skeleton key={`kpi-${i}`} className="h-36 w-full rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={chartPanelGridClassName}>
|
<div className={chartPanelGridClassName}>
|
||||||
<Skeleton className="h-72 w-full rounded-xl" />
|
<Skeleton className="h-72 w-full rounded-xl" />
|
||||||
@@ -49,13 +52,7 @@ export function AnalyticsDashboardSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function KpiSparklineSkeleton({ count = 4 }: { count?: number }) {
|
export function KpiSparklineSkeleton({ count = 4 }: { count?: number }) {
|
||||||
return (
|
return <KpiStatGridSkeleton count={count} />
|
||||||
<div className={kpiGridClassName}>
|
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_APP_SWITCHER_CONFIG,
|
||||||
|
getAppUrl as getAppUrlFromConfig,
|
||||||
|
type AppSwitcherConfig,
|
||||||
|
} from '@/lib/app-switcher-config'
|
||||||
|
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
|
||||||
|
import { getClaims, isAuthEnabled } from '@/lib/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portal-first switcher: tries `/api/v1/app-switcher` and filters entries by
|
||||||
|
* the current JWT `apps` claim; falls back to `VITE_APP_SWITCHER`/defaults
|
||||||
|
* when auth-portal is disabled or unavailable.
|
||||||
|
*/
|
||||||
|
export function useAppSwitcherConfig(): {
|
||||||
|
config: AppSwitcherConfig
|
||||||
|
isLoading: boolean
|
||||||
|
} {
|
||||||
|
const authOn = isAuthEnabled()
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
...appSwitcherQueryOptions(),
|
||||||
|
enabled: authOn,
|
||||||
|
})
|
||||||
|
const claims = getClaims()
|
||||||
|
|
||||||
|
const config = useMemo<AppSwitcherConfig>(() => {
|
||||||
|
const raw = data ?? DEFAULT_APP_SWITCHER_CONFIG
|
||||||
|
if (!authOn) return raw
|
||||||
|
const allowed = claims?.apps
|
||||||
|
if (!allowed?.length) return raw
|
||||||
|
const set = new Set(allowed)
|
||||||
|
const apps = raw.apps.filter((a) => set.has(a.id))
|
||||||
|
if (!apps.length) return raw
|
||||||
|
return { ...raw, apps }
|
||||||
|
}, [data, authOn, claims?.apps])
|
||||||
|
|
||||||
|
return { config, isLoading: authOn && isLoading }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppUrl(appId: string): string | undefined {
|
||||||
|
const { config } = useAppSwitcherConfig()
|
||||||
|
return getAppUrlFromConfig(appId, config ?? DEFAULT_APP_SWITCHER_CONFIG)
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import type React from "react"
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type DragEvent,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
} from "react"
|
||||||
|
|
||||||
|
export type FileMetadata = {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
type: string
|
||||||
|
url: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileWithPreview = {
|
||||||
|
file: File | FileMetadata
|
||||||
|
id: string
|
||||||
|
preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileUploadOptions = {
|
||||||
|
maxFiles?: number // Only used when multiple is true, defaults to Infinity
|
||||||
|
maxSize?: number // in bytes
|
||||||
|
accept?: string
|
||||||
|
multiple?: boolean // Defaults to false
|
||||||
|
initialFiles?: FileMetadata[]
|
||||||
|
onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change
|
||||||
|
onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added
|
||||||
|
onError?: (errors: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileUploadState = {
|
||||||
|
files: FileWithPreview[]
|
||||||
|
isDragging: boolean
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileUploadActions = {
|
||||||
|
addFiles: (files: FileList | File[]) => void
|
||||||
|
removeFile: (id: string) => void
|
||||||
|
clearFiles: () => void
|
||||||
|
clearErrors: () => void
|
||||||
|
handleDragEnter: (e: DragEvent<HTMLElement>) => void
|
||||||
|
handleDragLeave: (e: DragEvent<HTMLElement>) => void
|
||||||
|
handleDragOver: (e: DragEvent<HTMLElement>) => void
|
||||||
|
handleDrop: (e: DragEvent<HTMLElement>) => void
|
||||||
|
handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void
|
||||||
|
openFileDialog: () => void
|
||||||
|
getInputProps: (
|
||||||
|
props?: InputHTMLAttributes<HTMLInputElement>
|
||||||
|
) => InputHTMLAttributes<HTMLInputElement> & {
|
||||||
|
ref: React.Ref<HTMLInputElement>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFileUpload = (
|
||||||
|
options: FileUploadOptions = {}
|
||||||
|
): [FileUploadState, FileUploadActions] => {
|
||||||
|
const {
|
||||||
|
maxFiles = Number.POSITIVE_INFINITY,
|
||||||
|
maxSize = Number.POSITIVE_INFINITY,
|
||||||
|
accept = "*",
|
||||||
|
multiple = false,
|
||||||
|
initialFiles = [],
|
||||||
|
onFilesChange,
|
||||||
|
onFilesAdded,
|
||||||
|
onError,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const [state, setState] = useState<FileUploadState>({
|
||||||
|
files: initialFiles.map((file) => ({
|
||||||
|
file,
|
||||||
|
id: file.id,
|
||||||
|
preview: file.url,
|
||||||
|
})),
|
||||||
|
isDragging: false,
|
||||||
|
errors: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const validateFile = useCallback(
|
||||||
|
(file: File | FileMetadata): string | null => {
|
||||||
|
if (file instanceof File) {
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accept !== "*") {
|
||||||
|
const acceptedTypes = accept.split(",").map((type) => type.trim())
|
||||||
|
const fileType = file instanceof File ? file.type || "" : file.type
|
||||||
|
const fileExtension = `.${file instanceof File ? file.name.split(".").pop() : file.name.split(".").pop()}`
|
||||||
|
|
||||||
|
const isAccepted = acceptedTypes.some((type) => {
|
||||||
|
if (type.startsWith(".")) {
|
||||||
|
return fileExtension.toLowerCase() === type.toLowerCase()
|
||||||
|
}
|
||||||
|
if (type.endsWith("/*")) {
|
||||||
|
const baseType = type.split("/")[0]
|
||||||
|
return fileType.startsWith(`${baseType}/`)
|
||||||
|
}
|
||||||
|
return fileType === type
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isAccepted) {
|
||||||
|
return `File "${file instanceof File ? file.name : file.name}" is not an accepted file type.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
[accept, maxSize]
|
||||||
|
)
|
||||||
|
|
||||||
|
const createPreview = useCallback(
|
||||||
|
(file: File | FileMetadata): string | undefined => {
|
||||||
|
if (file instanceof File) {
|
||||||
|
return URL.createObjectURL(file)
|
||||||
|
}
|
||||||
|
return file.url
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const generateUniqueId = useCallback((file: File | FileMetadata): string => {
|
||||||
|
if (file instanceof File) {
|
||||||
|
return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||||
|
}
|
||||||
|
return file.id
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearFiles = useCallback(() => {
|
||||||
|
setState((prev) => {
|
||||||
|
// Clean up object URLs
|
||||||
|
for (const file of prev.files) {
|
||||||
|
if (
|
||||||
|
file.preview &&
|
||||||
|
file.file instanceof File &&
|
||||||
|
file.file.type.startsWith("image/")
|
||||||
|
) {
|
||||||
|
URL.revokeObjectURL(file.preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = {
|
||||||
|
...prev,
|
||||||
|
files: [],
|
||||||
|
errors: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilesChange?.(newState.files)
|
||||||
|
return newState
|
||||||
|
})
|
||||||
|
}, [onFilesChange])
|
||||||
|
|
||||||
|
const addFiles = useCallback(
|
||||||
|
(newFiles: FileList | File[]) => {
|
||||||
|
if (!newFiles || newFiles.length === 0) return
|
||||||
|
|
||||||
|
const newFilesArray = Array.from(newFiles)
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
// Clear existing errors when new files are uploaded
|
||||||
|
setState((prev) => ({ ...prev, errors: [] }))
|
||||||
|
|
||||||
|
// In single file mode, clear existing files first
|
||||||
|
if (!multiple) {
|
||||||
|
clearFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if adding these files would exceed maxFiles (only in multiple mode)
|
||||||
|
if (
|
||||||
|
multiple &&
|
||||||
|
maxFiles !== Number.POSITIVE_INFINITY &&
|
||||||
|
state.files.length + newFilesArray.length > maxFiles
|
||||||
|
) {
|
||||||
|
errors.push(`You can only upload a maximum of ${maxFiles} files.`)
|
||||||
|
onError?.(errors)
|
||||||
|
setState((prev) => ({ ...prev, errors }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFiles: FileWithPreview[] = []
|
||||||
|
|
||||||
|
for (const file of newFilesArray) {
|
||||||
|
// Only check for duplicates if multiple files are allowed
|
||||||
|
if (multiple) {
|
||||||
|
const isDuplicate = state.files.some(
|
||||||
|
(existingFile) =>
|
||||||
|
existingFile.file.name === file.name &&
|
||||||
|
existingFile.file.size === file.size
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skip duplicate files silently
|
||||||
|
if (isDuplicate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
errors.push(
|
||||||
|
multiple
|
||||||
|
? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`
|
||||||
|
: `File exceeds the maximum size of ${formatBytes(maxSize)}.`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = validateFile(file)
|
||||||
|
if (error) {
|
||||||
|
errors.push(error)
|
||||||
|
} else {
|
||||||
|
validFiles.push({
|
||||||
|
file,
|
||||||
|
id: generateUniqueId(file),
|
||||||
|
preview: createPreview(file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update state if we have valid files to add
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
// Call the onFilesAdded callback with the newly added valid files
|
||||||
|
onFilesAdded?.(validFiles)
|
||||||
|
|
||||||
|
setState((prev) => {
|
||||||
|
const newFiles = !multiple
|
||||||
|
? validFiles
|
||||||
|
: [...prev.files, ...validFiles]
|
||||||
|
onFilesChange?.(newFiles)
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
files: newFiles,
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (errors.length > 0) {
|
||||||
|
onError?.(errors)
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
errors,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset input value after handling files
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
state.files,
|
||||||
|
maxFiles,
|
||||||
|
multiple,
|
||||||
|
maxSize,
|
||||||
|
validateFile,
|
||||||
|
createPreview,
|
||||||
|
generateUniqueId,
|
||||||
|
clearFiles,
|
||||||
|
onFilesChange,
|
||||||
|
onFilesAdded,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeFile = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const fileToRemove = prev.files.find((file) => file.id === id)
|
||||||
|
if (
|
||||||
|
fileToRemove &&
|
||||||
|
fileToRemove.preview &&
|
||||||
|
fileToRemove.file instanceof File &&
|
||||||
|
fileToRemove.file.type.startsWith("image/")
|
||||||
|
) {
|
||||||
|
URL.revokeObjectURL(fileToRemove.preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFiles = prev.files.filter((file) => file.id !== id)
|
||||||
|
onFilesChange?.(newFiles)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
files: newFiles,
|
||||||
|
errors: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[onFilesChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearErrors = useCallback(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
errors: [],
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setState((prev) => ({ ...prev, isDragging: true }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, isDragging: false }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setState((prev) => ({ ...prev, isDragging: false }))
|
||||||
|
|
||||||
|
// Don't process files if the input is disabled
|
||||||
|
if (inputRef.current?.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
|
// In single file mode, only use the first file
|
||||||
|
if (!multiple) {
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
addFiles([file])
|
||||||
|
} else {
|
||||||
|
addFiles(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addFiles, multiple]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
addFiles(e.target.files)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addFiles]
|
||||||
|
)
|
||||||
|
|
||||||
|
const openFileDialog = useCallback(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.click()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getInputProps = useCallback(
|
||||||
|
(props: InputHTMLAttributes<HTMLInputElement> = {}) => {
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
type: "file" as const,
|
||||||
|
onChange: handleFileChange,
|
||||||
|
accept: props.accept || accept,
|
||||||
|
multiple: props.multiple !== undefined ? props.multiple : multiple,
|
||||||
|
ref: inputRef,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[accept, multiple, handleFileChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
addFiles,
|
||||||
|
removeFile,
|
||||||
|
clearFiles,
|
||||||
|
clearErrors,
|
||||||
|
handleDragEnter,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDragOver,
|
||||||
|
handleDrop,
|
||||||
|
handleFileChange,
|
||||||
|
openFileDialog,
|
||||||
|
getInputProps,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format bytes to human-readable format
|
||||||
|
export const formatBytes = (bytes: number, decimals = 2): string => {
|
||||||
|
if (bytes === 0) return "0 Bytes"
|
||||||
|
|
||||||
|
const k = 1024
|
||||||
|
const dm = decimals < 0 ? 0 : decimals
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
|
||||||
|
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + sizes[i]
|
||||||
|
}
|
||||||
@@ -30,8 +30,19 @@ export function normalizeApiToken(raw: string): string {
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Portal JWT storage key mirrored from `@/lib/auth`. Kept local to avoid a
|
||||||
|
* cycle when `auth` starts pulling from `api-client` for the config endpoint. */
|
||||||
|
const PORTAL_TOKEN_STORAGE_KEY = 'evobgp_portal_token'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearer selection: portal JWT wins over the legacy API-key. When auth-portal
|
||||||
|
* is disabled or hasn't issued a token yet we fall back to the local API-key
|
||||||
|
* (`evobgp_api_token`) so curl-style tooling keeps working.
|
||||||
|
*/
|
||||||
function getToken(): string | null {
|
function getToken(): string | null {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof window === 'undefined') return null
|
||||||
|
const portal = window.localStorage.getItem(PORTAL_TOKEN_STORAGE_KEY)
|
||||||
|
if (portal && portal.trim()) return portal.trim()
|
||||||
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
const normalized = normalizeApiToken(raw)
|
const normalized = normalizeApiToken(raw)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
ChartBarIcon,
|
||||||
|
CloudIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
ServerIcon,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { CURRENT_APP_ID } from '@/lib/auth'
|
||||||
|
|
||||||
|
export { CURRENT_APP_ID }
|
||||||
|
|
||||||
|
const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart'])
|
||||||
|
|
||||||
|
export type AppSwitcherIconName = z.infer<typeof appSwitcherIconSchema>
|
||||||
|
|
||||||
|
export const APP_SWITCHER_ICONS: Record<AppSwitcherIconName, LucideIcon> = {
|
||||||
|
server: ServerIcon,
|
||||||
|
cloud: CloudIcon,
|
||||||
|
globe: GlobeIcon,
|
||||||
|
dashboard: LayoutDashboardIcon,
|
||||||
|
chart: ChartBarIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
const appSwitcherEntrySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
subtitle: z.string().optional(),
|
||||||
|
url: z.string(),
|
||||||
|
icon: appSwitcherIconSchema.default('server'),
|
||||||
|
shortcut: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const appSwitcherConfigSchema = z.object({
|
||||||
|
menuLabel: z.string().default('Приложения'),
|
||||||
|
apps: z.array(appSwitcherEntrySchema).min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
|
||||||
|
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
|
||||||
|
|
||||||
|
/** Shared defaults across ops apps — chrome app switcher. */
|
||||||
|
export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
|
||||||
|
menuLabel: 'Приложения',
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
id: 'vps',
|
||||||
|
name: 'VPS Tracker',
|
||||||
|
subtitle: 'Учёт виртуальных серверов',
|
||||||
|
url: 'http://192.168.100.67:3001',
|
||||||
|
icon: 'server',
|
||||||
|
shortcut: '⌘1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cfdm',
|
||||||
|
name: 'CF Domain Manager',
|
||||||
|
subtitle: 'Управление доменами',
|
||||||
|
url: 'http://192.168.100.67:6363',
|
||||||
|
icon: 'cloud',
|
||||||
|
shortcut: '⌘2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bgp',
|
||||||
|
name: 'EvoBGP',
|
||||||
|
subtitle: 'BGP маршрутизация',
|
||||||
|
url: 'http://192.168.100.67:3000',
|
||||||
|
icon: 'globe',
|
||||||
|
shortcut: '⌘3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAppSwitcherConfig(raw?: string): AppSwitcherConfig {
|
||||||
|
if (!raw?.trim()) {
|
||||||
|
return DEFAULT_APP_SWITCHER_CONFIG
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
return appSwitcherConfigSchema.parse(parsed)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Invalid VITE_APP_SWITCHER, using defaults:', error)
|
||||||
|
return DEFAULT_APP_SWITCHER_CONFIG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppSwitcherConfig(): AppSwitcherConfig {
|
||||||
|
return parseAppSwitcherConfig(import.meta.env.VITE_APP_SWITCHER)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppUrl(
|
||||||
|
appId: string,
|
||||||
|
config: AppSwitcherConfig = getAppSwitcherConfig(),
|
||||||
|
): string | undefined {
|
||||||
|
return config.apps.find((app) => app.id === appId)?.url
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentApp(
|
||||||
|
config: AppSwitcherConfig = getAppSwitcherConfig(),
|
||||||
|
): AppSwitcherEntry {
|
||||||
|
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]!
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* Portal JWT SSO integration for EvoBGP UI.
|
||||||
|
*
|
||||||
|
* Two independent auth channels:
|
||||||
|
* - Portal JWT (this module) — SSO from auth-portal, used to gate UI + Bearer
|
||||||
|
* to EvoBGP API when the API accepts portal-issued tokens.
|
||||||
|
* - Local API key (see `@/lib/api-client`) — legacy `evobgp_api_token`
|
||||||
|
* in localStorage; used when auth-portal is disabled or as a fallback.
|
||||||
|
*
|
||||||
|
* `VITE_AUTH_ENABLED=false` → keep the API-key gate.
|
||||||
|
* `VITE_AUTH_ENABLED=true` → require portal JWT; API-key kept only for tools
|
||||||
|
* (curl/dev) and as backup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'evobgp_portal_token'
|
||||||
|
const HANDOFF_KEY = 'evobgp_portal_401_handoff'
|
||||||
|
const HANDOFF_AT_KEY = 'evobgp_portal_handoff_at'
|
||||||
|
/** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */
|
||||||
|
const HANDOFF_COOLDOWN_MS = 12_000
|
||||||
|
|
||||||
|
/** EvoBGP is `bgp` in the auth-portal registry (see APP_IDS). */
|
||||||
|
export const CURRENT_APP_ID = 'bgp'
|
||||||
|
|
||||||
|
export type AccessClaims = {
|
||||||
|
sub: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
apps: string[]
|
||||||
|
permissions: string[]
|
||||||
|
is_admin?: boolean
|
||||||
|
iss?: string
|
||||||
|
exp?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeAuthConfig = {
|
||||||
|
required: boolean
|
||||||
|
portalUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtimeConfig: RuntimeAuthConfig | null = null
|
||||||
|
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
|
||||||
|
|
||||||
|
function viteAuthEnabled(): boolean {
|
||||||
|
return (
|
||||||
|
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
|
||||||
|
import.meta.env.VITE_AUTH_ENABLED === '1'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function vitePortalUrl(): string {
|
||||||
|
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
|
||||||
|
/\/$/,
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load auth mode from EvoBGP API (Docker-friendly). Falls back to VITE_* flags
|
||||||
|
* when the endpoint isn't implemented (404) or the API is unreachable.
|
||||||
|
*
|
||||||
|
* Note: EvoBGP uses `/v1/...` (not `/api/v1/...`).
|
||||||
|
*/
|
||||||
|
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
|
||||||
|
if (runtimeConfig) return runtimeConfig
|
||||||
|
if (runtimeConfigPromise) return runtimeConfigPromise
|
||||||
|
|
||||||
|
runtimeConfigPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/v1/auth/config', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
required?: boolean
|
||||||
|
portal_url?: string
|
||||||
|
}
|
||||||
|
runtimeConfig = {
|
||||||
|
required: Boolean(data.required) || viteAuthEnabled(),
|
||||||
|
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
|
||||||
|
}
|
||||||
|
return runtimeConfig
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore — fall through to vite defaults */
|
||||||
|
}
|
||||||
|
runtimeConfig = {
|
||||||
|
required: viteAuthEnabled(),
|
||||||
|
portalUrl: vitePortalUrl(),
|
||||||
|
}
|
||||||
|
return runtimeConfig
|
||||||
|
})().finally(() => {
|
||||||
|
runtimeConfigPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return runtimeConfigPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthConfigSync(): RuntimeAuthConfig | null {
|
||||||
|
return runtimeConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPortalToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
return window.localStorage.getItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPortalToken(token: string): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPortalToken(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.removeItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthEnabled(): boolean {
|
||||||
|
if (runtimeConfig) return runtimeConfig.required
|
||||||
|
return viteAuthEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authPortalUrl(): string {
|
||||||
|
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
|
||||||
|
return vitePortalUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPortalHandoffCoolingDown(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
const raw = window.sessionStorage.getItem(HANDOFF_AT_KEY)
|
||||||
|
if (!raw) return false
|
||||||
|
const at = Number(raw)
|
||||||
|
if (!Number.isFinite(at)) return false
|
||||||
|
return Date.now() - at < HANDOFF_COOLDOWN_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPortalHandoff(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.sessionStorage.setItem(HANDOFF_KEY, '1')
|
||||||
|
window.sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPortalHandoffFlag(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.sessionStorage.removeItem(HANDOFF_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear cooldown too — use on intentional logout so next login is allowed. */
|
||||||
|
export function resetPortalHandoff(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.sessionStorage.removeItem(HANDOFF_KEY)
|
||||||
|
window.sessionStorage.removeItem(HANDOFF_AT_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPortalHandoffFlag(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
return window.sessionStorage.getItem(HANDOFF_KEY) === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to auth-portal SSO. Returns false if cooldown blocks the handoff
|
||||||
|
* (clears local token) — prevents infinite SSO when API rejects JWT.
|
||||||
|
*/
|
||||||
|
export function redirectToPortalLogin(returnTo?: string): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
if (isPortalHandoffCoolingDown()) {
|
||||||
|
clearPortalToken()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
markPortalHandoff()
|
||||||
|
const callback = returnTo ?? `${window.location.origin}/auth/callback`
|
||||||
|
const url = new URL(authPortalUrl())
|
||||||
|
url.searchParams.set('return_to', callback)
|
||||||
|
window.location.assign(url.toString())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End portal SSO session (refresh cookie + portal token). */
|
||||||
|
export function redirectToPortalLogout(): void {
|
||||||
|
clearPortalToken()
|
||||||
|
resetPortalHandoff()
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.location.assign(`${authPortalUrl()}/logout`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHashToken(hash: string): {
|
||||||
|
accessToken: string | null
|
||||||
|
expiresAt: string | null
|
||||||
|
} {
|
||||||
|
const raw = hash.startsWith('#') ? hash.slice(1) : hash
|
||||||
|
const params = new URLSearchParams(raw)
|
||||||
|
return {
|
||||||
|
accessToken: params.get('access_token'),
|
||||||
|
expiresAt: params.get('expires_at'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeClaims(token: string): AccessClaims | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length < 2) return null
|
||||||
|
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
|
const payload = JSON.parse(json) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
sub: String(payload.sub ?? ''),
|
||||||
|
email: String(payload.email ?? ''),
|
||||||
|
name: String(payload.name ?? ''),
|
||||||
|
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
|
||||||
|
permissions: Array.isArray(payload.permissions)
|
||||||
|
? payload.permissions.map(String)
|
||||||
|
: [],
|
||||||
|
is_admin: Boolean(payload.is_admin),
|
||||||
|
iss: payload.iss ? String(payload.iss) : undefined,
|
||||||
|
exp: typeof payload.exp === 'number' ? payload.exp : undefined,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClaims(): AccessClaims | null {
|
||||||
|
const token = getPortalToken()
|
||||||
|
if (!token) return null
|
||||||
|
const claims = decodeClaims(token)
|
||||||
|
if (!claims) return null
|
||||||
|
if (claims.exp && claims.exp * 1000 < Date.now()) {
|
||||||
|
clearPortalToken()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPermission(
|
||||||
|
granted: readonly string[],
|
||||||
|
required: string,
|
||||||
|
): boolean {
|
||||||
|
if (granted.includes(required)) return true
|
||||||
|
const parts = required.split(':')
|
||||||
|
if (parts.length !== 3) return false
|
||||||
|
const [app, section, action] = parts
|
||||||
|
if (action === 'read') {
|
||||||
|
return (
|
||||||
|
granted.includes(`${app}:${section}:write`) ||
|
||||||
|
granted.includes(`${app}:${section}:admin`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (action === 'write') {
|
||||||
|
return granted.includes(`${app}:${section}:admin`)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Access-check: pass when portal auth is disabled or claim grants required. */
|
||||||
|
export function can(required: string): boolean {
|
||||||
|
if (!isAuthEnabled()) return true
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!claims) return false
|
||||||
|
if (!claims.apps.includes(CURRENT_APP_ID)) return false
|
||||||
|
if (claims.is_admin) return true
|
||||||
|
return hasPermission(claims.permissions, required)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether /v1/auth/session may manage API keys (`bgp:access:admin`).
|
||||||
|
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key operator.
|
||||||
|
*/
|
||||||
|
export function sessionCanManageApiKeys(session: {
|
||||||
|
role?: string
|
||||||
|
kind?: string
|
||||||
|
is_admin?: boolean
|
||||||
|
permissions?: readonly string[]
|
||||||
|
} | null | undefined): boolean {
|
||||||
|
if (!session) return false
|
||||||
|
const jwtPath =
|
||||||
|
session.kind === 'jwt' ||
|
||||||
|
session.is_admin === true ||
|
||||||
|
(session.permissions?.length ?? 0) > 0
|
||||||
|
if (jwtPath) {
|
||||||
|
return (
|
||||||
|
session.is_admin === true ||
|
||||||
|
hasPermission(session.permissions ?? [], 'bgp:access:admin')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return session.role === 'operator'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
|
||||||
|
export function permissionForPath(pathname: string): string | null {
|
||||||
|
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
||||||
|
return 'bgp:dashboard:read'
|
||||||
|
}
|
||||||
|
if (pathname.startsWith('/modules')) return 'bgp:modules:read'
|
||||||
|
if (pathname.startsWith('/lookup')) return 'bgp:lookup:read'
|
||||||
|
if (pathname.startsWith('/network')) return 'bgp:network:read'
|
||||||
|
if (pathname.startsWith('/directories')) return 'bgp:directories:read'
|
||||||
|
if (pathname.startsWith('/operations')) return 'bgp:operations:read'
|
||||||
|
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||||
|
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||||
|
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||||
|
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
|
||||||
|
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_PATH = '/dashboard'
|
||||||
|
|
||||||
|
/** First path in the sidebar the current user may open. */
|
||||||
|
export function firstAllowedPath(): string {
|
||||||
|
const candidates: readonly string[] = [
|
||||||
|
'/dashboard',
|
||||||
|
'/modules',
|
||||||
|
'/lookup',
|
||||||
|
'/network',
|
||||||
|
'/directories',
|
||||||
|
'/operations',
|
||||||
|
'/schedule',
|
||||||
|
'/monitoring',
|
||||||
|
'/access',
|
||||||
|
'/tenant-settings',
|
||||||
|
'/settings',
|
||||||
|
]
|
||||||
|
for (const path of candidates) {
|
||||||
|
const perm = permissionForPath(path)
|
||||||
|
if (!perm || can(perm)) return path
|
||||||
|
}
|
||||||
|
return FALLBACK_PATH
|
||||||
|
}
|
||||||
@@ -1,23 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* EvoBGP UI surface contract (ReUI Pro).
|
* EvoBGP UI surface contract (ReUI Pro).
|
||||||
*
|
*
|
||||||
* Primary surface: **Card** via `PanelCard` / `DataGridCard`.
|
* Ops / list / dashboard / detail / settings: **Frame** only.
|
||||||
* All route-level panels, KPI tiles, charts and data grids use shadcn Card —
|
* KPI tiles: **stats-12** via `KpiStatGrid` / `reui-kit/KpiStatGrid`.
|
||||||
* not ReUI Frame — for visual consistency across screens.
|
* Lists: Frame + DataGrid (`ResourcePage` / `DataGridSection`), not Card shell.
|
||||||
*
|
*
|
||||||
* ReUI Frame blocks from registry are adapted into Card composition when installed.
|
* @see https://reui.io/preview/base/stats-12
|
||||||
*
|
* @see https://reui.io/preview/base/dashboard-1
|
||||||
* @see https://reui.io/blocks — card category
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
* @see `.agents/skills/shadcn-react/SKILL.md`
|
* @see docs/ui-design-contract.md
|
||||||
*/
|
*/
|
||||||
export const UI_SURFACE = 'card' as const
|
export const UI_SURFACE = 'frame' as const
|
||||||
|
|
||||||
/** Shared padding for KPI / metric tiles inside PanelCard. */
|
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
|
||||||
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
|
export const kpiStatGridClassName = '@container w-full'
|
||||||
|
|
||||||
/** Grid for dashboard-5 style KPI sparkline row (4 columns at xl). */
|
/** @deprecated Use kpiStatGridClassName — kept for legacy imports. */
|
||||||
|
export const dashboardKpiGridClassName = kpiStatGridClassName
|
||||||
|
|
||||||
|
/** @deprecated Sparkline KPI row replaced by stats-12 grid. */
|
||||||
export const kpiGridClassName =
|
export const kpiGridClassName =
|
||||||
'grid grid-cols-1 gap-4 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
'grid grid-cols-1 gap-5 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
|
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
|
||||||
|
|
||||||
/** Two-column chart panel row (monitoring / network overview). */
|
/** Two-column chart panel row (monitoring / network overview). */
|
||||||
export const chartPanelGridClassName = 'grid grid-cols-1 gap-4 @5xl:grid-cols-2'
|
export const chartPanelGridClassName = 'grid grid-cols-1 gap-4 @5xl:grid-cols-2'
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { ensureAuthConfig } from '@/lib/auth'
|
||||||
|
import {
|
||||||
|
DEFAULT_APP_SWITCHER_CONFIG,
|
||||||
|
parseAppSwitcherConfig,
|
||||||
|
type AppSwitcherConfig,
|
||||||
|
} from '@/lib/app-switcher-config'
|
||||||
|
|
||||||
|
export const appSwitcherQueryKey = ['app-switcher', 'portal'] as const
|
||||||
|
|
||||||
|
/** Portal contract shape: `{ menuLabel, apps: [{ id, name, url, icon, enabled }] }`. */
|
||||||
|
async function fetchPortalAppSwitcher(): Promise<AppSwitcherConfig> {
|
||||||
|
const { portalUrl } = await ensureAuthConfig()
|
||||||
|
const base = portalUrl.replace(/\/$/, '')
|
||||||
|
const res = await fetch(`${base}/api/v1/app-switcher`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`app-switcher ${res.status}`)
|
||||||
|
const raw = (await res.json()) as unknown
|
||||||
|
return parseAppSwitcherConfig(JSON.stringify(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appSwitcherQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: appSwitcherQueryKey,
|
||||||
|
queryFn: fetchPortalAppSwitcher,
|
||||||
|
staleTime: 60_000,
|
||||||
|
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
import { apiJSON } from '@/lib/api-client'
|
|
||||||
import type {
|
|
||||||
FirewallClient,
|
|
||||||
FirewallClientsResponse,
|
|
||||||
FirewallInstallContext,
|
|
||||||
FirewallRule,
|
|
||||||
FirewallRulesResponse,
|
|
||||||
} from '@/types/api'
|
|
||||||
|
|
||||||
export const firewallKeys = {
|
|
||||||
all: ['firewall'] as const,
|
|
||||||
clients: () => [...firewallKeys.all, 'clients'] as const,
|
|
||||||
installContext: () => [...firewallKeys.all, 'install-context'] as const,
|
|
||||||
rules: (scope: string, clientId?: string) =>
|
|
||||||
[...firewallKeys.all, 'rules', scope, clientId ?? ''] as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function firewallInstallContextQueryOptions() {
|
|
||||||
return queryOptions<FirewallInstallContext>({
|
|
||||||
queryKey: firewallKeys.installContext(),
|
|
||||||
queryFn: () => apiJSON<FirewallInstallContext>('/v1/firewall/install-context'),
|
|
||||||
staleTime: 60_000,
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function firewallClientsQueryOptions() {
|
|
||||||
return queryOptions<FirewallClientsResponse>({
|
|
||||||
queryKey: firewallKeys.clients(),
|
|
||||||
queryFn: () => apiJSON<FirewallClientsResponse>('/v1/firewall/clients'),
|
|
||||||
staleTime: 15_000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function firewallRulesQueryOptions(scope: 'tenant' | 'client', clientId?: string) {
|
|
||||||
const qs =
|
|
||||||
scope === 'client' && clientId
|
|
||||||
? `?scope=client&client_id=${encodeURIComponent(clientId)}`
|
|
||||||
: '?scope=tenant'
|
|
||||||
return queryOptions<FirewallRulesResponse>({
|
|
||||||
queryKey: firewallKeys.rules(scope, clientId),
|
|
||||||
queryFn: () => apiJSON<FirewallRulesResponse>(`/v1/firewall/rules${qs}`),
|
|
||||||
staleTime: 15_000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useApproveFirewallClient() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Клиент одобрен')
|
|
||||||
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось одобрить'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteFirewallClient() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiJSON<void>(`/v1/firewall/clients/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Клиент удалён')
|
|
||||||
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateFirewallRule() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (body: Record<string, unknown>) =>
|
|
||||||
apiJSON<FirewallRule>('/v1/firewall/rules', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Правило добавлено')
|
|
||||||
void qc.invalidateQueries({ queryKey: firewallKeys.all })
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось добавить правило'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteFirewallRule() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiJSON<void>(`/v1/firewall/rules/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
void qc.invalidateQueries({ queryKey: firewallKeys.all })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { apiJSON } from '@/lib/api-client'
|
||||||
|
import type { LookupResponse } from '@/types/api'
|
||||||
|
|
||||||
|
export const lookupKeys = {
|
||||||
|
all: ['lookup'] as const,
|
||||||
|
query: (q: string) => [...lookupKeys.all, q] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /v1/lookup?q= — dual-layer membership (entry + snapshot). */
|
||||||
|
export function lookupQueryOptions(q: string) {
|
||||||
|
const trimmed = q.trim()
|
||||||
|
return queryOptions<LookupResponse>({
|
||||||
|
queryKey: lookupKeys.query(trimmed),
|
||||||
|
queryFn: () =>
|
||||||
|
apiJSON<LookupResponse>(`/v1/lookup?q=${encodeURIComponent(trimmed)}`),
|
||||||
|
enabled: trimmed.length > 0,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,15 +1,69 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
|
import {
|
||||||
|
can,
|
||||||
|
ensureAuthConfig,
|
||||||
|
firstAllowedPath,
|
||||||
|
getClaims,
|
||||||
|
getPortalToken,
|
||||||
|
permissionForPath,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
} from '@/lib/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-mode gate:
|
||||||
|
* - VITE_AUTH_ENABLED / API `/v1/auth/config { required: true }`
|
||||||
|
* → require auth-portal JWT (SSO) + section permission via `can()`.
|
||||||
|
* - Off → keep legacy `evobgp_api_token` gate (redirect to /settings if empty).
|
||||||
|
*/
|
||||||
export const Route = createFileRoute('/_auth')({
|
export const Route = createFileRoute('/_auth')({
|
||||||
beforeLoad: ({ location }) => {
|
beforeLoad: async ({ location }) => {
|
||||||
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
|
const cfg = await ensureAuthConfig()
|
||||||
|
|
||||||
|
if (cfg.required) {
|
||||||
|
const token = getPortalToken()
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!token || !claims) {
|
||||||
|
const ok = redirectToPortalLogin(
|
||||||
|
`${window.location.origin}/auth/callback`,
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/auth/callback',
|
||||||
|
search: { error: 'sso_loop' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await new Promise(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!claims.apps.includes('bgp')) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/auth/callback',
|
||||||
|
search: { error: 'sso_loop' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const perm = permissionForPath(location.pathname)
|
||||||
|
if (perm && !can(perm)) {
|
||||||
|
const fallback = firstAllowedPath()
|
||||||
|
if (fallback !== location.pathname) {
|
||||||
|
throw redirect({ to: fallback as '/dashboard' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings available without token — first-run onboarding (incl. `dev`).
|
||||||
if (location.pathname === '/settings') return
|
if (location.pathname === '/settings') return
|
||||||
const raw =
|
const raw =
|
||||||
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
|
typeof window !== 'undefined'
|
||||||
|
? window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||||
|
: null
|
||||||
if (!raw || !normalizeApiToken(raw)) {
|
if (!raw || !normalizeApiToken(raw)) {
|
||||||
throw redirect({ to: '/settings', search: { reason: 'token-required' } })
|
throw redirect({
|
||||||
|
to: '/settings',
|
||||||
|
search: { tab: 'connection', reason: 'token-required' },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: AuthLayout,
|
component: AuthLayout,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user