Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f66d68d1c7 | ||
|
|
e04fea657c | ||
|
|
452f6b2db0 | ||
|
|
b321aa5321 | ||
|
|
5d1102b497 | ||
|
|
5edbd656ba | ||
|
|
4a4c11c6bf | ||
|
|
e51999c908 | ||
|
|
b7f7669685 | ||
|
|
947d1f0cc4 | ||
|
|
68f9d4b832 | ||
|
|
72045afcde | ||
|
|
e15768b25b | ||
|
|
7b3f002e5f | ||
|
|
fa2abc81f3 | ||
|
|
7a3eae98b1 | ||
|
|
276194a9d0 | ||
|
|
53b3c49612 | ||
|
|
db79820df0 | ||
|
|
a902a4270d | ||
|
|
3a3e6db018 | ||
|
|
144d342c16 | ||
|
|
0af37d55c4 | ||
|
|
78f2ecc246 | ||
|
|
8c97445f7e | ||
|
|
0ea5b3b738 | ||
|
|
66b785f7cb | ||
|
|
cb14194a5f | ||
|
|
9c38e1bc57 | ||
|
|
4ac99e43ae | ||
|
|
f26c15401c | ||
|
|
c144b49acf |
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: shadcn-react
|
||||
description: Управление shadcn/ui (React) + ReUI компонентами в EvoBGP — registry, CLI, импорты, матрица выбора. Использовать при любых UI-задачах в apps/web/ или packages/ui/ (новые экраны, компоненты, формы, data-grid, filters и др.).
|
||||
---
|
||||
|
||||
# shadcn/ui (React) + ReUI в EvoBGP
|
||||
|
||||
EvoBGP использует **React 19 + shadcn/ui (base-nova) + ReUI** (`style: base-nova`, не Radix). Источники правды: MCP `plugin-shadcn-shadcn` + [ui.shadcn.com/docs](https://ui.shadcn.com/docs/components) + [reui.io/docs](https://reui.io/docs/components/base/).
|
||||
|
||||
См. `.cursor/rules/web-shadcn.mdc` (основные правила) и `.cursor/rules/context7-stack.mdc` (Context7 ID стека).
|
||||
|
||||
## Порядок UI-задачи (строго)
|
||||
|
||||
0. **Codegraph** `codegraph_explore` — найти существующие реализации и shared-обёртки.
|
||||
1. **MCP `plugin-shadcn-shadcn`** — `get_project_registries` (должны быть `@shadcn` и `@reui`).
|
||||
2. **`search_items_in_registries`** — компонент/block/example:
|
||||
- shadcn primitives/blocks → omit `registries` или `["@shadcn"]`
|
||||
- Data Grid, Filters, Stepper, Kanban, Autocomplete и др. → `registries: ["@reui"]`
|
||||
3. **`get_item_examples_from_registries`** — полный код примера перед JSX.
|
||||
4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...`.
|
||||
5. Выполнить add **из `apps/web`** (не из корня монорепо, не из `packages/ui`).
|
||||
6. **CLI docs (обязательно):**
|
||||
- `@shadcn/*` → `cd apps/web && pnpm dlx shadcn@latest docs <component>` — [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components)
|
||||
- `@reui/*` → [ReUI docs](https://reui.io/docs/components/base/<name>) + [llms.txt](https://reui.io/llms.txt)
|
||||
7. Сверить examples из MCP с API из docs CLI — реализовать только после совпадения.
|
||||
8. Адаптировать под TanStack Router / Query → `apps/web/src/`.
|
||||
9. **`get_audit_checklist`** — перед merge PR.
|
||||
|
||||
## Размещение и импорты
|
||||
|
||||
| Слой | Путь | Импорт |
|
||||
|------|------|--------|
|
||||
| shadcn primitives | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
||||
| ReUI enterprise | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
||||
| Проектные обёртки | `apps/web/src/components/` | `@/components/<name>` |
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm dlx shadcn@latest add button # @shadcn primitive → packages/ui/src/components/
|
||||
pnpm dlx shadcn@latest add @reui/data-grid # ReUI enterprise → apps/web/src/components/reui/
|
||||
```
|
||||
|
||||
## Матрица выбора shadcn vs ReUI
|
||||
|
||||
| Задача | Registry | Импорт |
|
||||
|--------|----------|--------|
|
||||
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
|
||||
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
|
||||
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` |
|
||||
| Мультифильтры | `@reui` | `@/components/reui/filters` |
|
||||
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
||||
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
||||
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
||||
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
||||
|
||||
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid (через `DataGridCard`), не shadcn Data Table.
|
||||
|
||||
## Уже установленные shared-обёртки
|
||||
|
||||
В `apps/web/src/components/`:
|
||||
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
|
||||
- `QueryState` — обёртка loading/error/empty для TanStack Query
|
||||
- `EmptyState` — пустые списки
|
||||
- `ConfirmDialog` — подтверждения (не `window.confirm`)
|
||||
- `LoadingButton` — кнопка с loading-состоянием
|
||||
- `StatusBadge` — статусные бейджи
|
||||
- `SectionCards` — сетка KPI-карточек
|
||||
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
|
||||
- `TruncatedText` — текст с тултипом
|
||||
- `ModeToggle` — переключатель темы
|
||||
|
||||
Перед созданием новой обёртки — проверить существующие через Codegraph.
|
||||
|
||||
## Уже установленные ReUI-компоненты
|
||||
|
||||
В `apps/web/src/components/reui/`:
|
||||
- `autocomplete`, `badge`, `data-grid/*`, `date-selector`, `filters`, `number-field`
|
||||
|
||||
Перед добавлением дубликата — проверить Codegraph и существующие обёртки.
|
||||
|
||||
## Зависимости (только `apps/web`, не `packages/ui`)
|
||||
|
||||
| npm-пакет | ReUI-компоненты |
|
||||
|-----------|-----------------|
|
||||
| `@tanstack/react-table` | data-grid |
|
||||
| `@tanstack/react-virtual` | data-grid (virtual) |
|
||||
| `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/modifiers`, `@dnd-kit/utilities` | data-grid dnd, sortable, kanban |
|
||||
| `date-fns`, `react-day-picker` | date-selector |
|
||||
|
||||
После `shadcn add @reui/...` — проверить, что CLI добавил недостающие deps в `apps/web/package.json`.
|
||||
|
||||
## Semantic tokens (Styling)
|
||||
|
||||
ReUI расширяет тему shadcn токенами `--success`, `--info`, `--warning`, `--destructive-foreground`, `--invert` — уже в `packages/ui/src/styles/globals.css`.
|
||||
|
||||
- Badge/Alert: `variant="success"` / `"info"` / `"warning"` — не `bg-emerald-*`
|
||||
- Базовая тема: `pnpm dlx shadcn@latest apply b2fA --only theme`
|
||||
- ReUI-токены: по [Styling guide](https://reui.io/docs/styling); не править `globals.css` вручную без сверки с docs
|
||||
|
||||
## Запрещено
|
||||
|
||||
- Писать UI по памяти, не проверив MCP
|
||||
- Копипаст с ui.shadcn.com без examples/add из MCP
|
||||
- Самописные примитивы, если есть item в registry
|
||||
- Пропускать MCP «потому что компонент простой»
|
||||
- Класть ReUI в `packages/ui` или импортировать как `@evobgp/ui`
|
||||
- Radix-варианты (`/docs/components/radix/...`) — только Base UI
|
||||
- Raw Tailwind-цвета вместо ReUI semantic `variant`
|
||||
- Использовать Tabler/Bootstrap/Material UI
|
||||
|
||||
## Чеклист перед завершением UI-задачи
|
||||
|
||||
- [ ] MCP search (с правильным registry) + examples
|
||||
- [ ] `shadcn add <name>` (или `@reui/<name>`) из `apps/web`
|
||||
- [ ] Импорты: `@evobgp/ui/components/*` для shadcn, `@/components/reui/*` для ReUI
|
||||
- [ ] Зависимости в `apps/web/package.json`
|
||||
- [ ] `pnpm --filter @evobgp/web run typecheck` — exit 0
|
||||
- [ ] `pnpm --filter @evobgp/web run lint` — exit 0
|
||||
- [ ] `pnpm --filter @evobgp/web run build` — exit 0
|
||||
@@ -1,34 +0,0 @@
|
||||
# shadcn-svelte в EvoBGP monorepo
|
||||
|
||||
Использовать при добавлении примитивов, блоков, правке темы в `apps/web` / `packages/ui`.
|
||||
|
||||
## Структура
|
||||
|
||||
- Примитивы: `packages/ui/src/components/` → `@evobgp/ui/components/*`
|
||||
- Паттерны приложения: `apps/web/src/lib/components/patterns/`
|
||||
- CLI всегда из `apps/web/`
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Проверить, есть ли компонент в `packages/ui/src/components/`
|
||||
2. `cd apps/web && pnpm dlx shadcn-svelte@latest add <name> -y -o`
|
||||
3. Если файлы в `apps/web/@evobgp/ui/` — перенести в `packages/ui/src/components/`
|
||||
4. Импорты shadcn в app: `@evobgp/ui/components/<name>/index.js`
|
||||
5. `pnpm --filter @evobgp/web check && pnpm --filter @evobgp/web lint`
|
||||
|
||||
## Тема
|
||||
|
||||
- `packages/ui/src/styles/globals.css` с `@source`
|
||||
- Импорт в `apps/web/src/routes/layout.css`: `@import '@evobgp/ui/styles/globals.css';`
|
||||
|
||||
## Не использовать
|
||||
|
||||
- React shadcn/ui примеры без адаптации под Svelte 5
|
||||
- ReUI (`@reui/*`) — только React
|
||||
- Legacy `$lib/components/ui/` re-exports
|
||||
|
||||
## Документация
|
||||
|
||||
- https://shadcn-svelte.com/docs
|
||||
- https://shadcn-svelte.com/llms.txt
|
||||
- Monorepo: `.cursor/rules/frontend-monorepo.mdc`
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 46304,
|
||||
"pid": 43636,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1782824332519
|
||||
"startedAt": 1783570299043
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
---
|
||||
name: EvoBGP → React+shadcn/ui+ReUI
|
||||
overview: "Big-bang миграция web UI EvoBGP с SvelteKit+Svelte5 на React 19 + Vite + TanStack Router/Query + shadcn/ui (Base UI, base-nova) + ReUI registry, идентично эталону vps-tracker. Структура — pnpm workspaces monorepo (apps/web + packages/ui как @evobgp/ui). Скоуп: только фронтенд; Go-бэкенд, OpenAPI, nginx, compose и bake-конфиги сохраняются с минимальной правкой путей сборки. Старый web/ заменяется полностью; перенос доменных экранов идёт по одному роуту через MCP-shadcn с проверкой по docs."
|
||||
todos:
|
||||
- id: "1"
|
||||
content: "Этап 0: Установить MCP в workspace EvoBGP (plugin-shadcn-shadcn с @reui, plugin-context7, cursor-ide-browser) — скопировать .cursor/mcp.json из vps-tracker"
|
||||
status: pending
|
||||
- id: "2"
|
||||
content: "Этап 1: Архивировать web/ → web-legacy-svelte/, зафиксировать инвентарь 13 роутов и компонентов"
|
||||
status: pending
|
||||
- id: "3"
|
||||
content: "Этап 2: Создать pnpm workspaces монорепо — pnpm-workspace.yaml, корневой package.json, tsconfig.base.json, .npmrc, .nvmrc"
|
||||
status: pending
|
||||
- id: "4"
|
||||
content: "Этап 3: Создать packages/ui (@evobgp/ui) — package.json, components.json (base-nova + @reui), src/styles/globals.css (копия vps-tracker), lib/utils.ts, hooks/use-mobile.ts"
|
||||
status: pending
|
||||
- id: "5"
|
||||
content: "Этап 4: Добавить shadcn-примитивы в packages/ui через MCP+CLI (33 компонента идентично vps-tracker)"
|
||||
status: pending
|
||||
- id: "6"
|
||||
content: "Этап 5: Создать apps/web каркас — package.json (@evobgp/web), components.json, tsconfig, vite.config.ts (TanStackRouterPlugin+react+tailwindcss, alias @ и @evobgp/ui/*, proxy /v1)"
|
||||
status: pending
|
||||
- id: "7"
|
||||
content: "Этап 6: React-инициализация — main.tsx (StrictMode→ThemeProvider→QC→Router+Toaster), lib/queryClient.ts, lib/router.ts (Register augmentation), routes/__root.tsx, theme-provider.tsx, index.html"
|
||||
status: pending
|
||||
- id: "8"
|
||||
content: "Этап 7: Перенос app-shell (sidebar-07) и shared-обёрток (PageShell, PageHeader, EmptyState, QueryState, ConfirmDialog, StatusBadge, DataGridCard, FormSheet, FormField, LoadingButton, skeletons) из vps-tracker с адаптацией брендинга"
|
||||
status: pending
|
||||
- id: "9"
|
||||
content: "Этап 8: Перенос API-клиента и типов из legacy — api-client.ts (Bearer localStorage, Idempotency-Key, RFC 9457 Problem, waitForJob, apiPageAll), types/api.ts, queries/* по доменам"
|
||||
status: pending
|
||||
- id: "10"
|
||||
content: "Этап 9: Добавить ReUI enterprise через MCP+CLI — @reui/data-grid, filters, autocomplete, date-selector, number-field, color-picker, badge"
|
||||
status: pending
|
||||
- id: "11"
|
||||
content: "Этап 10.1: Роуты _auth layout + settings (token+theme) + access (session+api-keys) — простые экраны"
|
||||
status: pending
|
||||
- id: "12"
|
||||
content: "Этап 10.2: Роуты index (dashboard KPI+recent) + modules/index (DataGridCard) + modules/$moduleId (детали с cards)"
|
||||
status: pending
|
||||
- id: "13"
|
||||
content: "Этап 10.3: Роуты network (peers/speakers/BIRD tabs, live refetchInterval) + operations (jobs/revisions/diff, waitForJob) + schedule"
|
||||
status: pending
|
||||
- id: "14"
|
||||
content: "Этап 10.4: Роуты directories (communities/DoH) + monitoring (bird/postgres/runtime) + tenant-settings + редиректы peers→network, revisions→operations"
|
||||
status: pending
|
||||
- id: "15"
|
||||
content: "Этап 11: Обновить deploy/docker/evobgp-web/Dockerfile (pnpm+corepack, COPY apps/web + packages, dist вместо build); nginx.conf НЕ трогать; проверить bake"
|
||||
status: pending
|
||||
- id: "16"
|
||||
content: "Этап 12: Обновить .gitea/workflows/ci.yaml web job (pnpm, tsc --noEmit, eslint, build) + path-filter apps/web/** + packages/ui/**"
|
||||
status: pending
|
||||
- id: "17"
|
||||
content: "Этап 13: Cursor rules — удалить web-shadcn.mdc; скопировать shadcn-mcp/reui-mcp/frontend-* из vps-tracker; обновить engineering.mdc (DEP-04, TEST-04, DOC-SYNC) и context7-stack.mdc; создать .agents/skills/{shadcn,reui}"
|
||||
status: pending
|
||||
- id: "18"
|
||||
content: "Этап 14: Финал — pnpm build без ошибок, MCP get_audit_checklist, cursor-ide-browser smoke 13 роутов, удалить web-legacy-svelte/, коммит feat(frontend)"
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Plan: Миграция EvoBGP web UI на React + shadcn/ui + ReUI
|
||||
|
||||
## Контекст
|
||||
|
||||
**Цель:** Перевести `web/` EvoBGP с SvelteKit 2.50 + Svelte 5.54 + shadcn-svelte на стек идентичный `vps-tracker/apps/web`:
|
||||
- **Стек:** React 19 + Vite 7 + TanStack Router/Query v5 + shadcn/ui (Base UI, `style: base-nova`) + ReUI registry `@reui` + Tailwind v4 monorepo + lucide-react + react-hook-form + Zod + recharts + sonner + next-themes
|
||||
- **Структура:** pnpm workspaces monorepo — `apps/web` (SPA) + `packages/ui` (`@evobgp/ui` barrel)
|
||||
- **Скоуп:** только frontend. Go-бэкенд (`internal/*`), OpenAPI (`docs/openapi.yaml`), compose/bake/nginx — не трогаются (минимальная правка только путей сборки в Dockerfile)
|
||||
- **Стратегия:** big-bang. Старый `web/` архивируется в `web-legacy-svelte/` и удаляется в финале. Рабочий UI создаётся с нуля
|
||||
- **Эталон:** `c:\Users\shats\Dev\vps-tracker\apps\web\` + `c:\Users\shats\Dev\vps-tracker\packages\ui\`
|
||||
|
||||
**Инвентарь существующего EvoBGP web** (из исследования):
|
||||
- 13 роутов SvelteKit: `/`, `/modules`, `/modules/[id]`, `/network` (+редирект `/peers`), `/operations` (+редирект `/revisions`), `/monitoring`, `/schedule`, `/directories`, `/access`, `/tenant-settings`, `/settings`
|
||||
- ~130 доменных `.svelte`-компонентов в `web/src/lib/components/{modules,network,operations,monitoring,tenant-settings,access,...}`
|
||||
- API-клиент `web/src/lib/api/client.ts` + типы `web/src/lib/api/types.ts` (4301 строка OpenAPI, 76 эндпоинтов)
|
||||
- Токен в localStorage (`evobgp_api_token`), Bearer, RFC 9457 errors, cursor-пагинация, Idempotency-Key, polling jobs
|
||||
- Тема `neutral` в `web/src/routes/layout.css` с semantic tokens `--success/--warning/--info`
|
||||
- nginx.conf — SPA fallback `try_files $uri $uri/ /index.html` (нейтрален к фреймворку, не меняется)
|
||||
- Сейчас npm (НЕ pnpm), корневого `pnpm-workspace.yaml` нет
|
||||
|
||||
**Архитектура после миграции**:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph EvoBGP[EvoBGP repo root]
|
||||
PWS[pnpm-workspace.yaml]
|
||||
RP[package.json + pnpm-lock.yaml]
|
||||
TSB[tsconfig.base.json]
|
||||
NVM[.nvmrc Node 22]
|
||||
MCP[.cursor/mcp.json]
|
||||
subgraph Apps
|
||||
AW[apps/web — React SPA]
|
||||
end
|
||||
subgraph Packages
|
||||
UI[packages/ui — @evobgp/ui]
|
||||
end
|
||||
Deploy[deploy/ nginx+compose+bake]
|
||||
GoAPI[internal/httpapi — Go API]
|
||||
end
|
||||
AW -->|imports| UI
|
||||
AW -->|/v1 proxy dev| GoAPI
|
||||
Deploy -->|Dockerfile build| AW
|
||||
Deploy -->|nginx /v1 proxy| GoAPI
|
||||
UI -->|globals.css @source| AW
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Этапы (последовательность, safe-by-design)
|
||||
|
||||
### Этап 0 — MCP environment (предварительный)
|
||||
|
||||
Установить MCP-серверы в workspace EvoBGP. Источник: `c:\Users\shats\.cursor\projects\c-Users-shats-Dev-vps-tracker\mcps\` — там работают:
|
||||
- `plugin-shadcn-shadcn` (serverName `shadcn`) — даёт инструменты `get_project_registries`, `search_items_in_registries` (с поддержкой `registries: ["@reui"]`), `get_item_examples_from_registries`, `get_add_command_for_items`, `view_items_in_registries`, `list_items_in_registries`, `get_audit_checklist`
|
||||
- `plugin-context7-plugin-context7` — docs-lookup React/TanStack/Recharts
|
||||
- `cursor-ide-browser` — визуальная проверка UI
|
||||
|
||||
Скопировать `.cursor/mcp.json` из vps-tracker → `c:\Users\shats\Dev\EvoBGP\.cursor\mcp.json`. Enable серверы в Cursor Settings. ReUI MCP и codegraph — **не существуют как отдельные MCP** (исследовано); ReUI доступен **через** `plugin-shadcn-shadcn` с `registries: ["@reui"]`.
|
||||
|
||||
### Этап 1 — Подготовка
|
||||
|
||||
1. **Зафиксировать инвентарь**: список 13 роутов + ~130 компонентов + API-клиент + типы. Не удалять пока ничего.
|
||||
2. **Архивировать старый стек**: `web/` → `web-legacy-svelte/`. Оставить до финала как референс при переносе экранов.
|
||||
|
||||
### Этап 2 — Монорепо-каркас
|
||||
|
||||
Корневые файлы:
|
||||
|
||||
- `pnpm-workspace.yaml`: `packages: ['apps/*', 'packages/*']`
|
||||
- Корневой `package.json`: `{"private": true, "scripts": {"dev": "pnpm --filter @evobgp/web dev", "build": "pnpm --filter @evobgp/web build", "lint": "pnpm -r lint"}}`. Существующий корневой `package.json` (semantic-release/commitlint) — слить в один или оставить как `package.release.json` (решить по ходу).
|
||||
- `tsconfig.base.json`: `strict: true`, `target: ES2022`, `moduleResolution: bundler`, `jsx: react-jsx`, `paths: {"@/*": ["./apps/web/src/*"], "@evobgp/ui/components/*": ["./packages/ui/src/components/*"], "@evobgp/ui/hooks/*": ["./packages/ui/src/hooks/*"], "@evobgp/ui/lib/utils": ["./packages/ui/src/lib/utils.ts"]}`
|
||||
- `.npmrc`: `engine-strict=true`
|
||||
- `.nvmrc`: `22` (приводим к единой версии с Dockerfile)
|
||||
- `.gitignore`: добавить `node_modules/`, `dist/`, `routeTree.gen.ts` (опц.)
|
||||
|
||||
### Этап 3 — `packages/ui` (@evobgp/ui)
|
||||
|
||||
Структура полностью повторяет `c:\Users\shats\Dev\vps-tracker\packages\ui\`:
|
||||
|
||||
- `packages/ui/package.json`:
|
||||
- `name: "@evobgp/ui"`
|
||||
- `exports`: `./components/*`, `./hooks/*`, `./lib/utils`, `./globals.css`
|
||||
- `dependencies`: `@base-ui/react`, `class-variance-authority`, `clsx`, `cmdk`, `date-fns`, `lucide-react`, `next-themes`, `react-day-picker`, `recharts`, `sonner`, `tailwind-merge`
|
||||
- `peerDependencies`: `react`, `react-dom` (19)
|
||||
- `packages/ui/components.json` (точная копия vps-tracker с заменой `@cfdm` → `@evobgp`):
|
||||
```json
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {"config": "", "css": "src/styles/globals.css", "baseColor": "neutral", "cssVariables": true},
|
||||
"iconLibrary": "lucide",
|
||||
"registries": {"@reui": "https://reui.io/r/{style}/{name}.json"},
|
||||
"aliases": {"components": "@evobgp/ui/components", "utils": "@evobgp/ui/lib/utils", "hooks": "@evobgp/ui/hooks", "lib": "@evobgp/ui/lib", "ui": "@evobgp/ui/components"}
|
||||
}
|
||||
```
|
||||
- `packages/ui/src/styles/globals.css`: **точная копия** `c:\Users\shats\Dev\vps-tracker\packages\ui\src\styles\globals.css` (Tailwind v4 + `tw-animate-css` + `@source "../"` + `@source "../../../apps/web/src"` + `:root`/`.dark` со всеми oklch-токенами + ReUI semantic tokens `--success/--info/--warning/--destructive-foreground/--invert/--focus` + chart-1..5 + sidebar-* + `@theme inline` + `@layer base`)
|
||||
- `packages/ui/src/lib/utils.ts`: `cn()` через `clsx` + `tailwind-merge`
|
||||
- `packages/ui/src/hooks/use-mobile.ts`
|
||||
|
||||
### Этап 4 — shadcn-примитивы через MCP
|
||||
|
||||
Перед каждым `add` — обязательно через MCP:
|
||||
1. `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`
|
||||
2. CLI `pnpm dlx shadcn@latest docs <component>` сверка с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components)
|
||||
|
||||
Компоненты (идентично набору `vps-tracker/packages/ui/src/components/`):
|
||||
```
|
||||
button button-group card input textarea label select checkbox field
|
||||
separator scroll-area table badge tabs dialog alert-dialog sheet popover
|
||||
dropdown-menu tooltip breadcrumb sidebar skeleton sonner spinner alert
|
||||
command kbd calendar slider chart input-group input-otp toggle
|
||||
```
|
||||
Команда: `cd apps/web && pnpm dlx shadcn@latest add button card input ...` (CLI пишет в `packages/ui/src/components/` через aliases).
|
||||
|
||||
Применение темы: `pnpm dlx shadcn@latest apply b2fA --only theme -y` (обновит `:root`/`.dark` в `globals.css`).
|
||||
|
||||
### Этап 5 — `apps/web` каркас
|
||||
|
||||
- `apps/web/package.json` (`@evobgp/web`): deps из `vps-tracker/apps/web/package.json` с заменой `@cfdm/*` → `@evobgp/*`:
|
||||
- `react`, `react-dom` 19
|
||||
- `@tanstack/react-router`, `@tanstack/react-router-devtools`, `@tanstack/react-query`, `@tanstack/react-query-devtools`, `@tanstack/react-table`, `@tanstack/react-virtual`
|
||||
- `@hookform/resolvers`, `react-hook-form`, `zod` (v3 для совместимости с RHF-resolvers — как в vps-tracker)
|
||||
- `class-variance-authority`, `cmdk`, `date-fns`, `lucide-react`, `next-themes`, `react-day-picker`, `recharts`, `sonner`
|
||||
- `@dnd-kit/core`, `@dnd-kit/modifiers`, `@dnd-kit/sortable`, `@dnd-kit/utilities`
|
||||
- devDeps: `@tailwindcss/vite`, `@tanstack/router-plugin`, `@types/react`, `@types/react-dom`, `@vitejs/plugin-react`, `happy-dom`, `tailwindcss`, `tw-animate-css`, `typescript`, `vite`, `vitest`
|
||||
- `apps/web/components.json` (алиасы web-side: `@/components`, `@/hooks`, `@/lib`, `utils: @evobgp/ui/lib/utils`, `ui: @evobgp/ui/components`; registries `@reui`; css `../../packages/ui/src/styles/globals.css`)
|
||||
- `apps/web/vite.config.ts` (точная копия vps-tracker с заменой `@cfdm` → `@evobgp`): плагины `TanStackRouterPlugin({ target: 'react', autoCodeSplitting: true })` → `react()` → `tailwindcss()`; alias `@`, `@evobgp/ui/*`; server port 5173, proxy `/v1` и `/metrics` → `http://127.0.0.1:8080`
|
||||
- `apps/web/tsconfig.json` extends `../../tsconfig.base.json`
|
||||
- `apps/web/index.html` (#root, anti-FOUC тема-скрипт `evobgp-theme`)
|
||||
|
||||
### Этап 6 — React-инициализация
|
||||
|
||||
Точная копия vps-tracker с заменой путей:
|
||||
|
||||
- `apps/web/src/main.tsx`: `StrictMode → ThemeProvider → QueryClientProvider → RouterProvider + <Toaster richColors position="top-right" />`, единственный `import '@evobgp/ui/globals.css'`
|
||||
- `apps/web/src/lib/queryClient.ts`: `staleTime: 60_000, retry: 1, refetchOnWindowFocus: false`
|
||||
- `apps/web/src/lib/router.ts`: `createRouter({ routeTree, context, defaultPreload: 'intent', scrollRestoration: true })` + `declare module '@tanstack/react-router'` Register
|
||||
- `apps/web/src/components/theme-provider.tsx`: next-themes `attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange`
|
||||
- `apps/web/src/routes/__root.tsx`: `createRootRouteWithContext<{ queryClient }>` + `<AppShell><Outlet /></AppShell>`
|
||||
|
||||
### Этап 7 — Перенос app-shell и shared-обёрток
|
||||
|
||||
Перенести из `vps-tracker/apps/web/src/components/` с адаптацией брендинга/навигации:
|
||||
- `layout/app-shell.tsx` (block sidebar-07, `SidebarProvider → Sidebar collapsible="icon" → SidebarHeader/SidebarContent(navGroups)/SidebarFooter + SidebarInset(header sticky h-16 backdrop-blur + SidebarTrigger + Breadcrumb + actions + main)`). `render={<Link to={item.to} />}` — **Base UI render-prop, не Radix asChild**. Навигация подставляется под EvoBGP-экраны.
|
||||
- `mode-toggle.tsx`, `page-shell.tsx`, `page-header.tsx`, `empty-state.tsx`, `query-state.tsx`, `confirm-dialog.tsx`, `status-badge.tsx`, `section-cards.tsx`, `form-sheet.tsx`, `form-field.tsx`, `loading-button.tsx`, `skeletons.tsx`
|
||||
- `data-grid-card.tsx` (обёртка над `@reui/data-grid`) — полная копия с типизированным `DataGridCardProps<TData>`
|
||||
|
||||
### Этап 8 — API-клиент и типы (перенос из legacy)
|
||||
|
||||
- `apps/web/src/lib/api-client.ts`: перенос логики из `web-legacy-svelte/src/lib/api/client.ts`:
|
||||
- `TOKEN_STORAGE_KEY = 'evobgp_api_token'`
|
||||
- `mergeHeaders`: Accept JSON + Bearer из localStorage
|
||||
- `apiFetch`, `apiJSON<T>`, `apiMutate<T>` с auto-Idempotency-Key
|
||||
- `parseResponse<T>`: 204/205→undefined, ошибки → `ApiError` с RFC 9457 Problem
|
||||
- `waitForJob(jobId, opts?)`: poll `GET /v1/jobs/{id}` каждые 400ms
|
||||
- `apiPageAll<T>`: cursor-пагинация (`items`/`next_cursor`/`has_more`)
|
||||
- `apps/web/src/types/api.ts`: перенос всех типов из `web-legacy-svelte/src/lib/api/types.ts` (`ModuleRow`, `BgpPeer`, `SpeakerRow`, `RevisionRow`, `JobRow`, `AuthSession`, `ApiKey`, `PostgresOverview`, etc.)
|
||||
- `apps/web/src/queries/` — по доменам: `auth.ts`, `modules.ts`, `network.ts`, `operations.ts`, `monitoring.ts`, `directories.ts`, `access.ts`, `settings.ts` (queryOptions + key factories)
|
||||
|
||||
### Этап 9 — ReUI enterprise-компоненты
|
||||
|
||||
Через MCP `search_items_in_registries` с `registries: ["@reui"]` → `get_item_examples` → `get_add_command`:
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm dlx shadcn@latest add @reui/data-grid
|
||||
pnpm dlx shadcn@latest add @reui/filters
|
||||
pnpm dlx shadcn@latest add @reui/autocomplete
|
||||
pnpm dlx shadcn@latest add @reui/date-selector
|
||||
pnpm dlx shadcn@latest add @reui/number-field
|
||||
pnpm dlx shadcn@latest add @reui/color-picker
|
||||
pnpm dlx shadcn@latest add @reui/badge
|
||||
```
|
||||
Документация для `@reui/*`: [reui.io/docs/components/base/](https://reui.io/docs/components/base) + [llms.txt](https://reui.io/llms.txt) — **не** ui.shadcn.com для ReUI.
|
||||
|
||||
Ложатся в `apps/web/src/components/reui/` (НЕ в packages/ui). Post-add: импорты shadcn-примитивов внутри ReUI → `@evobgp/ui/components/*`. Проверить что `@tanstack/react-table`, `@tanstack/react-virtual`, `@dnd-kit/*`, `date-fns`, `react-day-picker` попали в `apps/web/package.json`.
|
||||
|
||||
### Этап 10 — Реализация 13 роутов (поэтапно)
|
||||
|
||||
Каждый роут — отдельная подзадача. Порядок от простого к сложному (позволяет рано верифицировать стек):
|
||||
|
||||
| # | Файл (TanStack file-based) | Что делает | Источник (legacy) |
|
||||
|---|---|---|---|
|
||||
| 1 | `routes/_auth.tsx` + `_auth.tsx` layout | auth guard (токен в localStorage) | `routes/+layout.ts` |
|
||||
| 2 | `routes/_auth/settings.tsx` | API-токен + тема (свет/тёмн/сист) | `routes/settings/+page.svelte` |
|
||||
| 3 | `routes/_auth/access.tsx` | `GET /v1/auth/session` + список API-ключей | `routes/access/+page.svelte` |
|
||||
| 4 | `routes/index.tsx` | dashboard: KPI + recent jobs/revisions + network status | `routes/+page.svelte` |
|
||||
| 5 | `routes/_auth/modules/index.tsx` | список модулей (DataGridCard) | `routes/modules/+page.svelte` |
|
||||
| 6 | `routes/_auth/modules/$moduleId.tsx` | детали модуля (cards: AS/Cdn/Domain/IpRange) | `routes/modules/[moduleId]/+page.svelte` |
|
||||
| 7 | `routes/_auth/network.tsx` | peers + speakers + BIRD settings (tabs) | `routes/network/+page.svelte` |
|
||||
| 8 | `routes/_auth/operations.tsx` | jobs + revisions + diff (tabs, waitForJob) | `routes/operations/+page.svelte` |
|
||||
| 9 | `routes/_auth/schedule.tsx` | refresh jobs (tabs all/refresh/failed) | `routes/schedule/+page.svelte` |
|
||||
| 10 | `routes/_auth/directories.tsx` | BGP communities + DoH profiles (tabs) | `routes/directories/+page.svelte` |
|
||||
| 11 | `routes/_auth/monitoring.tsx` | bird status + version + postgres + runtime logs | `routes/monitoring/+page.svelte` |
|
||||
| 12 | `routes/_auth/tenant-settings.tsx` | настройки BIRD/ревизий/runtime (tabs) | `routes/tenant-settings/+page.svelte` |
|
||||
| 13 | редиректы | `/peers`→`/network`, `/revisions`→`/operations?tab=revisions` | `routes/peers`, `routes/revisions` |
|
||||
|
||||
Для каждого роута: MCP-shadcn search+examples → `pnpm dlx shadcn@latest docs <name>` сверка с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components) → ReUI для data-grid/filters → композиция @evobgp/ui + DataGridCard; логика переносится из соответствующего `+page.svelte` (но на TanStack Query вместо инлайн fetch). Live-данные (network, monitoring) — `refetchInterval` вместо кастомного `setInterval`.
|
||||
|
||||
### Этап 11 — Инфраструктура деплоя
|
||||
|
||||
**Минимальные правки (compose/bake не трогать):**
|
||||
|
||||
`deploy/docker/evobgp-web/Dockerfile` (3 стадии → 3 стадии):
|
||||
- `deps`: установить pnpm (corepack), `COPY pnpm-workspace.yaml package.json apps/web/package.json packages/ui/package.json ./` + `COPY apps/web/ apps/web/` + `COPY packages/ packages/` → `pnpm install --frozen-lockfile`
|
||||
- `build`: `COPY . .` → `pnpm --filter @evobgp/web build`
|
||||
- `web`: `COPY --from=web-artifacts /app/apps/web/dist /usr/share/nginx/html` (вместо `/web/build`)
|
||||
|
||||
`deploy/docker/evobgp-web/nginx.conf` — **не меняется** (SPA fallback совместим).
|
||||
|
||||
`deploy/docker/docker-bake.hcl` — проверить что target `web-build` корректно прокидывает контекст.
|
||||
|
||||
### Этап 12 — CI/CD
|
||||
|
||||
`.gitea/workflows/ci.yaml`, job `web` (строки 164-181):
|
||||
- `actions/setup-node@v4` → `node-version: "22"`, `cache: pnpm`, `cache-dependency-path: pnpm-lock.yaml`
|
||||
- Добавить `pnpm install` (через corepack)
|
||||
- Заменить `npm run check` → `pnpm --filter @evobgp/web exec tsc --noEmit`
|
||||
- Заменить `npm run lint` → `pnpm --filter @evobgp/web lint` (eslint)
|
||||
- Добавить `pnpm --filter @evobgp/web build` (раньше проверялось только в Docker)
|
||||
|
||||
Job `changes` — path-filter `web/*` → `apps/web/**` + `packages/ui/**` (расширить globs).
|
||||
|
||||
### Этап 13 — Cursor rules + skills
|
||||
|
||||
**Удалить** (Svelte-специфика):
|
||||
- `.cursor/rules/web-shadcn.mdc` (WEB-01..WEB-19 — всё про shadcn-svelte)
|
||||
|
||||
**Скопировать из vps-tracker `.cursor/rules/`** (с заменой `@cfdm` → `@evobgp`, `Vps*` → `EvoBgp*`):
|
||||
- `shadcn-mcp.mdc`, `reui-mcp.mdc`, `shadcn-ui-production.mdc`, `frontend-shadcn.mdc`, `frontend-monorepo.mdc`, `frontend-ui-patterns.mdc`, `vite-tanstack-frontend.mdc`
|
||||
|
||||
**Обновить** `engineering.mdc`:
|
||||
- `DEP-04`: «shadcn-svelte/bits-ui» → «shadcn/ui React (Base UI) + ReUI registry»
|
||||
- `TEST-04`: `npm run check` → `pnpm --filter @evobgp/web exec tsc --noEmit`; `npm run lint` → eslint
|
||||
- `DOC-SYNC-06/07`: `shadcn-svelte.com` → `ui.shadcn.com/docs` + `reui.io/docs`
|
||||
|
||||
**Обновить** `context7-stack.mdc` Web UI таблица: убрать svelte/sveltekit/bits-ui/formsnap/`@lucide/svelte`; добавить React 19, TanStack Router/Query/Table/Virtual, ReUI llms.txt, `lucide-react`.
|
||||
|
||||
**Создать** `c:\Users\shats\Dev\EvoBGP\.agents\skills\` (сейчас не существует):
|
||||
- `shadcn/SKILL.md` — скопировать из cloudflare-domain-manager
|
||||
- `reui/SKILL.md` — скопировать из cloudflare-domain-manager, заменить обёртки под EvoBGP (`DataGridCard`, `NetworkFiltersToolbar`)
|
||||
|
||||
### Этап 14 — Финальная проверка и очистка
|
||||
|
||||
1. `pnpm install && pnpm --filter @evobgp/web build` без ошибок
|
||||
2. MCP `get_audit_checklist` — пройти по чек-листу
|
||||
3. `cursor-ide-browser` smoke-тест всех 13 роутов (login через dev-токен, навигация, CRUD, dark/light toggle)
|
||||
4. Удалить `web-legacy-svelte/`
|
||||
5. Проверить что compose поднимается (`docker compose --profile reference up`)
|
||||
6. Коммит на main (gitflow по `.cursor/rules/conventional-commits.mdc`): `feat(frontend): миграция web UI на React + shadcn/ui + ReUI`
|
||||
|
||||
---
|
||||
|
||||
## Цитаты ключевых файлов эталона
|
||||
|
||||
- Конфиги: [apps/web/components.json](c:\Users\shats\Dev\vps-tracker\apps\web\components.json), [packages/ui/components.json](c:\Users\shats\Dev\vps-tracker\packages\ui\components.json)
|
||||
- Стили: [packages/ui/src/styles/globals.css](c:\Users\shats\Dev\vps-tracker\packages\ui\src\styles\globals.css)
|
||||
- Инициализация: [apps/web/src/main.tsx](c:\Users\shats\Dev\vps-tracker\apps\web\src\main.tsx), [lib/queryClient.ts](c:\Users\shats\Dev\vps-tracker\apps\web\src\lib\queryClient.ts), [lib/router.ts](c:\Users\shats\Dev\vps-tracker\apps\web\src\lib\router.ts)
|
||||
- Layout: [components/layout/app-shell.tsx](c:\Users\shats\Dev\vps-tracker\apps\web\src\components\layout\app-shell.tsx)
|
||||
- Vite: [apps/web/vite.config.ts](c:\Users\shats\Dev\vps-tracker\apps\web\vite.config.ts)
|
||||
- ReUI обёртка: [components/data-grid-card.tsx](c:\Users\shats\Dev\vps-tracker\apps\web\src\components\data-grid-card.tsx)
|
||||
- API-клиент legacy: [web/src/lib/api/client.ts](c:\Users\shats\Dev\EvoBGP\web\src\lib\api\client.ts), [web/src/lib/api/types.ts](c:\Users\shats\Dev\EvoBGP\web\src\lib\api\types.ts)
|
||||
|
||||
## Документация
|
||||
|
||||
- [shadcn/ui Installation](https://ui.shadcn.com/docs/installation)
|
||||
- [shadcn/ui Components](https://ui.shadcn.com/docs/components)
|
||||
- [shadcn/ui Monorepo](https://ui.shadcn.com/docs/monorepo)
|
||||
- [shadcn/ui MCP Server](https://ui.shadcn.com/docs/mcp)
|
||||
- [ReUI Get Started](https://reui.io/docs/get-started)
|
||||
- [ReUI Styling](https://reui.io/docs/styling)
|
||||
- [ReUI MCP](https://reui.io/docs/mcp)
|
||||
- [ReUI llms.txt](https://reui.io/llms.txt)
|
||||
|
||||
## Риски и митигация
|
||||
|
||||
| Риск | Митигация |
|
||||
|---|---|
|
||||
| Big-bang = длинное окно неработающего UI в dev | Этапы 3-9 делаются параллельно с рабочим `web-legacy-svelte/`; переключение атомарно в финале |
|
||||
| ReUI на React 19 / Base UI может иметь breaking changes | MCP `get_item_examples` + dry-run `--dry-run` перед add |
|
||||
| OpenAPI drift (типы в legacy vs контракт) | Типы переносятся как есть (frontend-only scope); Zod-схемы по желанию позже |
|
||||
| npm → pnpm меняет lock-файлы и CI | Пункт 12 явно покрывает CI; corepack в Dockerfile |
|
||||
| 13 роутов = большой объём работы | Этап 10 разбит по сложности; можно остановиться после базовых и продолжить инкрементально |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,23 +32,28 @@ alwaysApply: true
|
||||
|
||||
---
|
||||
|
||||
## Web UI (`web/`)
|
||||
## Web UI (`apps/web/` + `packages/ui/`)
|
||||
|
||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||
|------------|-------------|------------------|-------|
|
||||
| Svelte | `/websites/svelte_dev` | ^5.54 | runes, компоненты, реактивность |
|
||||
| SvelteKit | `/sveltejs/kit` | ^2.50 | routing, `load`, adapters, SSR |
|
||||
| React | `/facebook/react` | ^19.2 | hooks, components, JSX |
|
||||
| TanStack Router | `/tanstack/router` | ^1.130 | file-based routes, `createFileRoute`, `useSearch`, `Link` |
|
||||
| TanStack Query | `/tanstack/query` | ^5.90 | `useQuery`, `useMutation`, `queryOptions`, invalidation |
|
||||
| TanStack Table | `/websites/tanstack_table` | ^8.21 | data-grid колонки, сортировка (ReUI) |
|
||||
| TanStack Virtual | `/tanstack/virtual` | ^3.14 | виртуализация списков (ReUI data-grid) |
|
||||
| Vite | `/vitejs/vite/v7.3.1` | ^7.3.1 | dev server, build, plugins |
|
||||
| TypeScript | `/microsoft/typescript/v5.9.3` | ^5.9.3 | типы, strict, tsconfig |
|
||||
| Tailwind CSS | `/tailwindlabs/tailwindcss.com` | ^4.1 | v4, `@tailwindcss/vite`, утилиты |
|
||||
| shadcn-svelte | `/websites/shadcn-svelte` | CLI | примитивы `ui/core`, theming |
|
||||
| Bits UI | `/llmstxt/bits-ui_llms_txt` | ^2.17 | headless-примитивы под shadcn |
|
||||
| sveltekit-superforms | `/ciscoheat/sveltekit-superforms` | ^2.30 | формы, server actions |
|
||||
| Formsnap | `/svecosystem/formsnap` | ^2.0 | доступные поля форм |
|
||||
| Zod | `/websites/zod_dev_v4` | ^4.4 | схемы валидации |
|
||||
| TanStack Table | `/websites/tanstack_table` | table-core ^8.21 | `AppDataTable`, колонки, сортировка |
|
||||
| shadcn/ui (React) | MCP `plugin-shadcn-shadcn` + https://ui.shadcn.com/docs | base-nova | примитивы `@evobgp/ui/components/*` |
|
||||
| ReUI | https://reui.io/llms.txt + MCP с `registries: ["@reui"]` | registry | enterprise: data-grid, filters, autocomplete |
|
||||
| react-hook-form | `/react-hook-form` | ^7.60 | формы, controller |
|
||||
| Zod | `/websites/zod_dev_v4` | ^3.25 / ^4 (apps/web) | схемы валидации |
|
||||
| recharts | `/recharts/recharts` | 3.8.0 | графики через shadcn `Chart` |
|
||||
| next-themes | `/pacocoursey/next-themes` | ^0.4 | dark/light theme provider |
|
||||
| sonner | `/emilkowalski/sonner` | ^1.7 | toast notifications |
|
||||
| lucide-react | `/lucide-icons/lucide` | ^0.468 | иконки |
|
||||
|
||||
UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (shadcn-svelte docs — первичный источник для компонентов).
|
||||
UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (MCP + shadcn/ui React docs — первичный источник для компонентов).
|
||||
|
||||
---
|
||||
|
||||
@@ -77,12 +82,14 @@ UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (shadcn
|
||||
1. **Контракт HTTP** — `docs/openapi.yaml` (не Context7).
|
||||
2. **Context7** — синтаксис и API библиотек из таблицы.
|
||||
3. **Локальные docs** — `docs/`, `web/README.md`, `AGENTS.md`.
|
||||
4. **Официальный сайт** — BIRD: https://bird.network.cz/?get_doc (если Context7 не покрыл кейс).
|
||||
4. **Официальный сайт** — BIRD: https://bird.nic.cz/?get_doc (если Context7 не покрыл кейс).
|
||||
|
||||
## Примеры запросов
|
||||
|
||||
```
|
||||
/docs /websites/svelte_dev runes $state $derived
|
||||
/docs /facebook/react hooks useState useEffect
|
||||
/docs /tanstack/router createFileRoute useSearch Link
|
||||
/docs /tanstack/query useQuery useMutation queryOptions
|
||||
/docs /golang/go/go1_24_6 net/http ServeMux pattern matching
|
||||
/docs /websites/pkg_go_dev_github_com_jackc_pgx_v5 pool acquire rows
|
||||
/docs /llmstxt/bird_xmsl_dev_llms_txt filter bgp import
|
||||
|
||||
@@ -94,8 +94,8 @@ alwaysApply: true
|
||||
**DEP-03** | MUST | Миграции схемы — пары `.up.sql`/`.down.sql` для **postgres** и **sqlite**, синхронная нумерация.
|
||||
*Проверка:* `migrations/postgres/`, `migrations/sqlite/`.
|
||||
|
||||
**DEP-04** | MUST | Web UI-библиотеки — только экосистема shadcn-svelte/bits-ui (см. `web-shadcn.mdc`).
|
||||
*Проверка:* `web/package.json` review.
|
||||
**DEP-04** | MUST | Web UI-библиотеки — только экосистема shadcn/ui (React) + ReUI (см. `web-shadcn.mdc`).
|
||||
*Проверка:* `apps/web/package.json`, `packages/ui/package.json` review.
|
||||
|
||||
---
|
||||
|
||||
@@ -123,7 +123,7 @@ alwaysApply: true
|
||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||
*Проверка:* CI job `bird2`.
|
||||
|
||||
**TEST-04** | MUST | Изменения `web/` — локально **`npm run check` и `npm run lint`** (обе команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`. Агент: при fail lint — `npx prettier --write` затем повтор. Только `check` не заменяет `lint`.
|
||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`.
|
||||
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
||||
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||
@@ -207,13 +207,13 @@ alwaysApply: true
|
||||
| OpenAPI / problem+json | `docs/openapi.yaml`, RFC 9457 |
|
||||
| Svelte / Kit | https://svelte.dev/docs , https://kit.svelte.dev/docs |
|
||||
| shadcn-svelte | https://shadcn-svelte.com/docs |
|
||||
| BIRD 2 | https://bird.network.cz/?get_doc |
|
||||
| BIRD 2 | https://bird.nic.cz/?get_doc |
|
||||
| Prometheus Go | https://pkg.go.dev/github.com/prometheus/client_golang |
|
||||
|
||||
**DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией.
|
||||
**DOC-SYNC-02** | NEVER | Устаревшие примеры (Svelte 4 `export let`, deprecated pgx).
|
||||
**DOC-SYNC-03** | MUST | Конфликт docs: **OpenAPI (HTTP)** → **код** → обзорные `docs/`; `.cursor/plans/` не контракт.
|
||||
**DOC-SYNC-04** | MUST | Сомнения по Svelte — Svelte MCP / `npm run check`.
|
||||
**DOC-SYNC-04** | MUST | Сомнения по React/shadcn/ReUI — MCP `plugin-shadcn-shadcn` + `pnpm --filter @evobgp/web run typecheck`.
|
||||
**DOC-SYNC-05** | MUST | BIRD — официальная документация BIRD2 + `networking-bird.mdc` + `go test ./internal/birdfmt/...`.
|
||||
|
||||
Приоритет при сомнениях — **официальные источники**, не блоги и не «память модели».
|
||||
@@ -230,7 +230,7 @@ alwaysApply: true
|
||||
go vet ./...
|
||||
go test ./... -race -count=1
|
||||
npx @redocly/cli lint docs/openapi.yaml
|
||||
# web: cd web; npm run check; npm run lint (или scripts/lint-web.ps1)
|
||||
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
||||
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||
```
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
description: shadcn-svelte Monorepo — apps/web + packages/ui + packages/shared, CLI workflow
|
||||
globs: apps/web/**/*,packages/ui/**/*,packages/shared/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Frontend Monorepo (shadcn-svelte)
|
||||
|
||||
Структура по образцу vps-tracker, стек — **SvelteKit 2 + Svelte 5 + shadcn-svelte** (не React/ReUI).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
apps/web/ # SvelteKit (routes, queries, domain components)
|
||||
packages/ui/ # @evobgp/ui — shadcn-svelte primitives (только CLI output)
|
||||
packages/shared/ # @evobgp/shared — Zod contracts, API types
|
||||
```
|
||||
|
||||
Go API в корне репозитория (`internal/`, `cmd/`) без изменений.
|
||||
|
||||
## Два components.json
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `apps/web/components.json` | App aliases; `ui` → `@evobgp/ui/components` |
|
||||
| `packages/ui/components.json` | UI package aliases |
|
||||
|
||||
**Синхронизировать:** `style`, `iconLibrary`, `baseColor` в обоих файлах.
|
||||
|
||||
## CLI — только из apps/web
|
||||
|
||||
```powershell
|
||||
cd apps/web
|
||||
pnpm dlx shadcn-svelte@latest add button -y -o
|
||||
pnpm dlx shadcn-svelte@latest add sidebar -y -o
|
||||
pnpm dlx shadcn-svelte@latest add breadcrumb -y -o
|
||||
```
|
||||
|
||||
Перед обновлением существующих компонентов: `pnpm dlx shadcn-svelte@latest add button --dry-run`
|
||||
|
||||
## Куда CLI кладёт файлы
|
||||
|
||||
| Команда | Куда |
|
||||
|---------|------|
|
||||
| `add button` | `packages/ui/src/components/button/` |
|
||||
| `add sidebar` | `packages/ui/src/components/sidebar/` |
|
||||
|
||||
Если CLI кладёт в `apps/web/@evobgp/ui/` — перенести в `packages/ui/src/components/`.
|
||||
|
||||
## Импорты
|
||||
|
||||
```svelte
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import { cn } from '@evobgp/ui/lib/utils';
|
||||
import '@evobgp/ui/styles/globals.css'; // только в routes/layout.css
|
||||
import type { ModuleRow } from '@evobgp/shared/types/api.js';
|
||||
import { moduleCreateSchema } from '@evobgp/shared/contracts/modules.js';
|
||||
```
|
||||
|
||||
| Запрещено | Разрешено |
|
||||
|-----------|-----------|
|
||||
| `$lib/ui/core/*` | `@evobgp/ui/components/*` |
|
||||
| `apps/web/src/lib/components/ui/` (legacy re-export) | `packages/ui/src/components/` |
|
||||
| Ручное редактирование темы вне CLI | shadcn-svelte theming docs |
|
||||
|
||||
## globals.css
|
||||
|
||||
`packages/ui/src/styles/globals.css` с `@source` на `packages/ui` и `apps/web/src`.
|
||||
|
||||
## Проверка после правок
|
||||
|
||||
```powershell
|
||||
pnpm --filter @evobgp/web check
|
||||
pnpm --filter @evobgp/web lint
|
||||
```
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
description: UI-паттерны apps/web — shared components, spacing, матрица (Svelte, без ReUI)
|
||||
globs: apps/web/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Frontend UI Patterns (Svelte)
|
||||
|
||||
Эталон UX: vps-tracker (`PageShell`, `QueryState`, `SectionCards`, `DataTableCard`). ReUI **не используется** — Data Table = shadcn-svelte + `AppDataTable` / `DataTableCard`.
|
||||
|
||||
## Иерархия
|
||||
|
||||
```
|
||||
@evobgp/ui/components/* ← shadcn-svelte CLI (packages/ui)
|
||||
apps/web/src/lib/components/ ← shared + domain + layout
|
||||
page-shell.svelte
|
||||
query-state.svelte
|
||||
section-cards.svelte
|
||||
data-table-card.svelte
|
||||
status-badge.svelte
|
||||
form-sheet.svelte
|
||||
crud-list-page.svelte
|
||||
list-filters-bar.svelte
|
||||
patterns/ ← AppDataTable, FormField, ConfirmDialog, EmptyState
|
||||
layout/app-shell.svelte
|
||||
domain-*/ ← modules, network, operations…
|
||||
```
|
||||
|
||||
## Матрица
|
||||
|
||||
| Элемент | Компонент | Primitive |
|
||||
|---------|-----------|-----------|
|
||||
| Page wrapper | `PageShell` | — |
|
||||
| Page title | `PageHeader` (`ui/app/page-header`) — **без icon** | — |
|
||||
| Stat metrics | `SectionCards` | `Card` |
|
||||
| Data list | `DataTableCard` + `AppDataTable` | `Table` |
|
||||
| Filters | `ListFiltersBar` | `Select`, `Badge` |
|
||||
| Empty | `EmptyState` | — |
|
||||
| Loading / Error | `QueryState` | `Skeleton`, `Alert` |
|
||||
| Status | `StatusBadge` | `Badge` |
|
||||
| Create/Edit form | `FormSheet` / Dialog | `Sheet`, `Field` |
|
||||
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
|
||||
| Nav | `AppShell` + `navGroups` | `Sidebar` |
|
||||
| Breadcrumbs | `AppShell` header (parent route) | `Breadcrumb` |
|
||||
| Tabs (dashboard, ops) | `LINE_TAB_TRIGGER_CLASS` | `Tabs` |
|
||||
|
||||
## Запрещено
|
||||
|
||||
- `icon` / `iconClass` в `PageHeader`
|
||||
- `KpiMetricsGrid` — только `SectionCards`
|
||||
- `mx-auto max-w-*` на `PageShell` list-страниц
|
||||
- Голый `AppDataTable` без `DataTableCard` на list-экранах
|
||||
|
||||
## Spacing
|
||||
|
||||
- `flex` + `gap-*`, не `space-y-*`
|
||||
- Page: `gap-4 md:gap-6`, padding `p-4 md:p-6`
|
||||
- Loading страницы → `Skeleton`, не page-level `Spinner`
|
||||
- Max 1 primary CTA на экран
|
||||
|
||||
## Docs
|
||||
|
||||
- shadcn-svelte: https://shadcn-svelte.com/docs
|
||||
- llms.txt: https://shadcn-svelte.com/llms.txt
|
||||
- Data layer: `@tanstack/svelte-query` в `apps/web/src/lib/queries/`
|
||||
|
||||
## Overlay
|
||||
|
||||
| Сценарий | Компонент |
|
||||
|----------|-----------|
|
||||
| Create/edit | `Sheet` / `FormSheet` |
|
||||
| Destructive | `ConfirmDialog` |
|
||||
| Preview | `Dialog` |
|
||||
@@ -23,7 +23,7 @@ alwaysApply: false
|
||||
| Параметры BIRD tenant | Global settings: `bird_router_id`, `bird_local_asn`, … (`docs/manual.md`) |
|
||||
| BGP peers | `BGPPeer` + `ParsePeerNeighbor` |
|
||||
|
||||
**BIRD2 docs:** https://bird.network.cz/?get_doc
|
||||
**BIRD2 docs:** https://bird.nic.cz/?get_doc
|
||||
|
||||
---
|
||||
|
||||
@@ -199,7 +199,7 @@ alwaysApply: false
|
||||
|
||||
## Documentation Sync
|
||||
|
||||
**DOC-SYNC-05** | MUST | BIRD — https://bird.network.cz/?get_doc
|
||||
**DOC-SYNC-05** | MUST | BIRD — https://bird.nic.cz/?get_doc
|
||||
**DOC-SYNC-08** | MUST | BGP policy — RFC 4271, 4760, 7454 + BIRD docs + `birdfmt`
|
||||
**DOC-SYNC-09** | MUST | CIDR — https://pkg.go.dev/net/netip ; примеры — RFC 5737, 3849
|
||||
|
||||
|
||||
+107
-31
@@ -1,55 +1,131 @@
|
||||
---
|
||||
description: EvoBGP WebUI — shadcn-svelte monorepo, Svelte 5, @evobgp/ui
|
||||
description: EvoBGP WebUI — React 19, shadcn/ui (base-nova), ReUI, TanStack Router/Query
|
||||
globs:
|
||||
- apps/web/**
|
||||
- packages/ui/**
|
||||
- packages/shared/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Web UI — shadcn-svelte (monorepo)
|
||||
# Web UI — React + shadcn/ui + ReUI
|
||||
|
||||
**Источник правды:** https://shadcn-svelte.com/docs
|
||||
**Источники правды:**
|
||||
- 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-задачей
|
||||
|
||||
Monorepo: [`frontend-monorepo.mdc`](frontend-monorepo.mdc), паттерны: [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc). Skill: [`.agents/skills/shadcn-svelte/SKILL.md`](.agents/skills/shadcn-svelte/SKILL.md).
|
||||
Общие правила Go/API: `.cursor/rules/engineering.mdc`. Стек ID: `.cursor/rules/context7-stack.mdc`.
|
||||
|
||||
## Слои
|
||||
## Слои UI
|
||||
|
||||
| Слой | Путь | Назначение |
|
||||
|------|------|------------|
|
||||
| Примитивы | `packages/ui/src/components/` | shadcn-svelte (только CLI) |
|
||||
| Паттерны | `apps/web/src/lib/components/patterns/` | AppDataTable, FormField, ConfirmDialog |
|
||||
| Shared UI | `apps/web/src/lib/components/` | PageShell, QueryState, SectionCards |
|
||||
| App chrome | `apps/web/src/lib/ui/app/` | PageHeader, toast, nav |
|
||||
| Контракты | `packages/shared/` | types + Zod |
|
||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
|
||||
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
||||
|
||||
Тема: `packages/ui/src/styles/globals.css` → импорт в `apps/web/src/routes/layout.css`.
|
||||
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
||||
|
||||
CLI из `apps/web/`:
|
||||
## Правила
|
||||
|
||||
**WEB-01** | MUST | Перед новым UI — MCP `plugin-shadcn-shadcn`: `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`. Только после — JSX.
|
||||
*Rationale:* единый источник правды и API.
|
||||
*Проверка:* review; нет самописных примитивов, если есть registry item.
|
||||
|
||||
**WEB-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).
|
||||
|
||||
**WEB-03** | NEVER | Альтернативные UI-kit'ы (Material, Vuetify, Tabler, Bootstrap утилиты).
|
||||
*Проверка:* `apps/web/package.json` review.
|
||||
|
||||
**WEB-04** | MUST | Композиция по docs: использовать под-компоненты (`CardHeader`, `TableRow`, `TabsList`, …).
|
||||
*Проверка:* сверка с shadcn/ReUI docs.
|
||||
|
||||
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||
|
||||
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
|
||||
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
|
||||
|
||||
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||
|
||||
**WEB-08** | MUST | Иконки — `lucide-react` (`components.json` → `iconLibrary: lucide`).
|
||||
*Проверка:* imports; нет `@tabler/icons-react`, `@lucide/svelte`.
|
||||
|
||||
**WEB-09** | MUST | Цвета — CSS-переменные `globals.css` и ReUI semantic токены (`variant="success"/"info"/"warning"`); не hex/rgb на страницах.
|
||||
*Проверка:* grep `#[0-9a-f]{3,6}` в `apps/web/src/routes/`.
|
||||
|
||||
**WEB-10** | SHOULD | Кастомизация — правка `packages/ui`/`reui` (Open Code), не `!important` поверх API.
|
||||
|
||||
**WEB-11** | MUST | `apps/web/src/routes/**` — композиция `@evobgp/ui/components/*` + `@/components/*` + `@/components/reui/*`; не копировать целые примитивы в route.
|
||||
*Проверка:* review.
|
||||
|
||||
**WEB-12** | NEVER | Примеры Svelte/SvelteKit, Tabler, Bootstrap — без адаптации под текущий React-стек.
|
||||
*Проверка:* `pnpm --filter @evobgp/web run typecheck`.
|
||||
|
||||
**WEB-13** | MUST | Реактивность — React 19 (`useState`, `useEffect`, TanStack Query/Router хуки); не Svelte runes, не `export let`.
|
||||
*Проверка:* `pnpm --filter @evobgp/web run typecheck`.
|
||||
|
||||
**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-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
|
||||
|
||||
**WEB-17** | MUST | Пустые списки — `EmptyState` или через `QueryState` с `emptyTitle`.
|
||||
*Проверка:* review.
|
||||
|
||||
**WEB-18** | SHOULD | Повторяемая комбинация core (≥2 раза) — вынести в `apps/web/src/components/`.
|
||||
|
||||
**WEB-19** | MUST | **После любого изменения `apps/web/**` или `packages/ui/**`** — перед завершением задачи агент **обязан**:
|
||||
```powershell
|
||||
pnpm dlx shadcn-svelte@latest add <component> -y -o
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run build
|
||||
```
|
||||
Все три команды должны exit 0. Не сдавать PR/ответ, пока все три не пройдут.
|
||||
*Rationale:* CI job `web` = typecheck + lint + build.
|
||||
*Проверка:* CI job `web`.
|
||||
|
||||
## Правила (кратко)
|
||||
**WEB-20** | MUST | Роутинг — TanStack Router (file-based `apps/web/src/routes/`); типобезопасные `createFileRoute`, `useSearch`, `Link`. Не `react-router-dom`.
|
||||
*Проверка:* `tsr generate` в `build`/`typecheck` скриптах.
|
||||
|
||||
- **WEB-01** Новый UI — сначала shadcn-svelte docs; не HTML+CSS с нуля
|
||||
- **WEB-02** Примитивы только через CLI → `packages/ui`
|
||||
- **WEB-05** Формы — Formsnap + superforms; `patterns/form/`
|
||||
- **WEB-06** Таблицы — `AppDataTable` / `DataTableCard`
|
||||
- **WEB-07** Toast — `notify` из `ui/app/toast.ts`
|
||||
- **WEB-08** Иконки — `@lucide/svelte`
|
||||
- **WEB-09** Цвета — CSS variables; не hex в routes
|
||||
- **WEB-13** Svelte 5 runes
|
||||
- **WEB-16** Удаление — `ConfirmDialog`
|
||||
- **WEB-17** Пустые списки — `EmptyState`
|
||||
- **WEB-19** После правок `apps/web/**`:
|
||||
**WEB-21** | MUST | Data fetching — TanStack Query (`useQuery`, `useMutation`, `queryOptions`); query-key factories в `apps/web/src/queries/`. Mutations invalidate keys, не refetch вручную.
|
||||
*Проверка:* review `queries/*.ts`.
|
||||
|
||||
```powershell
|
||||
pnpm --filter @evobgp/web check
|
||||
pnpm --filter @evobgp/web lint
|
||||
```
|
||||
**WEB-22** | MUST | Legacy Svelte — в `web-legacy-svelte/` (archive). Не использовать импорты оттуда в новом коде; только как референс при миграции роутов.
|
||||
|
||||
---
|
||||
|
||||
## Documentation Sync (Web)
|
||||
|
||||
**DOC-SYNC-06** | MUST | UI — первично MCP + shadcn/ui docs (React) + ReUI docs (Base UI); при конфликте с блогами/Stack Overflow побеждает официальная страница.
|
||||
**DOC-SYNC-07** | MUST | Перед `add` — сверить Installation/Theming с `apps/web/components.json`, `packages/ui/components.json` и `packages/ui/src/styles/globals.css`.
|
||||
|
||||
Tailwind v4 + base-nova: https://ui.shadcn.com/docs/migration/tailwind-v4
|
||||
ReUI semantic tokens: https://reui.io/docs/styling
|
||||
|
||||
---
|
||||
|
||||
## Enforcement
|
||||
|
||||
PR checklist: `check` + `lint` exit 0; примитивы в `@evobgp/ui`, не дубли в routes.
|
||||
**Обязательный финальный шаг агента при правках `apps/web/**` или `packages/ui/**`** (см. **WEB-19**):
|
||||
|
||||
```powershell
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run build
|
||||
```
|
||||
|
||||
**PR checklist `apps/web/**` / `packages/ui/**`:**
|
||||
- [ ] `pnpm --filter @evobgp/web run typecheck` — exit 0
|
||||
- [ ] `pnpm --filter @evobgp/web run lint` — exit 0
|
||||
- [ ] `pnpm --filter @evobgp/web run build` — exit 0
|
||||
- [ ] shadcn-примитивы в `packages/ui/src/components/`, ReUI в `apps/web/src/components/reui/`
|
||||
- [ ] Новые примитивы через shadcn CLI (`@shadcn` или `@reui`)
|
||||
- [ ] Импорты: `@evobgp/ui/components/*` для shadcn, `@/components/reui/*` для ReUI
|
||||
- [ ] Ссылка на docs компонента (если новый паттерн)
|
||||
|
||||
**CI:** job `web` — `typecheck` + `lint` + `build`.
|
||||
|
||||
+10
-1
@@ -1,10 +1,19 @@
|
||||
{
|
||||
"plugins": {
|
||||
"svelte": {
|
||||
"shadcn": {
|
||||
"enabled": true
|
||||
},
|
||||
"context7-plugin": {
|
||||
"enabled": true
|
||||
},
|
||||
"claude-plugins-official/gopls-lsp": {
|
||||
"enabled": true
|
||||
},
|
||||
"claude-plugins-official/typescript-lsp": {
|
||||
"enabled": true
|
||||
},
|
||||
"svelte": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ description: Context7 lookup для стека EvoBGP — использоват
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Определи область: `internal/` (Go), `web/` (Svelte), `docs/openapi.yaml`, `birdfmt`/`pipeline` (BIRD), `deploy/compose` (Docker).
|
||||
1. Определи область: `internal/` (Go), `apps/web/` + `packages/ui/` (React + shadcn/ui + ReUI), `docs/openapi.yaml`, `birdfmt`/`pipeline` (BIRD), `deploy/compose` (Docker).
|
||||
2. Найди строку в таблице `context7-stack.mdc`.
|
||||
3. Вызови `query-docs` с `libraryId` из таблицы и полным вопросом пользователя.
|
||||
4. `resolve-library-id` — только если библиотеки нет в таблице или нужна другая major-версия.
|
||||
@@ -18,14 +18,17 @@ description: Context7 lookup для стека EvoBGP — использоват
|
||||
|
||||
| Задача | libraryId |
|
||||
|--------|-----------|
|
||||
| Svelte 5 runes | `/websites/svelte_dev` |
|
||||
| SvelteKit load/forms | `/sveltejs/kit` |
|
||||
| shadcn-svelte компонент | `/websites/shadcn-svelte` |
|
||||
| React 19 hooks | `/facebook/react` |
|
||||
| TanStack Router | `/tanstack/router` |
|
||||
| TanStack Query | `/tanstack/query` |
|
||||
| shadcn/ui (React) | MCP `plugin-shadcn-shadcn` + https://ui.shadcn.com/docs |
|
||||
| ReUI Base UI | https://reui.io/llms.txt + MCP с `registries: ["@reui"]` |
|
||||
| pgx pool/query | `/websites/pkg_go_dev_github_com_jackc_pgx_v5` |
|
||||
| Go net/http | `/golang/go/go1_24_6` |
|
||||
| OpenAPI lint | `/redocly/redocly-cli` |
|
||||
| BIRD config | `/llmstxt/bird_xmsl_dev_llms_txt` |
|
||||
| Tailwind v4 | `/tailwindlabs/tailwindcss.com` |
|
||||
| Zod 4 schema | `/websites/zod_dev_v4` |
|
||||
| Zod schema | `/websites/zod_dev_v4` |
|
||||
| recharts | `/recharts/recharts` |
|
||||
|
||||
Полный список и версии — в `context7-stack.mdc`.
|
||||
|
||||
@@ -98,9 +98,9 @@ jobs:
|
||||
openapi=true
|
||||
go=true
|
||||
;;
|
||||
apps/web/README.md|apps/web/components.json)
|
||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||
;;
|
||||
apps/web/*|packages/ui/*|packages/shared/*|pnpm-workspace.yaml|pnpm-lock.yaml)
|
||||
apps/web/*|packages/ui/*|packages/shared/*)
|
||||
web=true
|
||||
;;
|
||||
deploy/bird/*)
|
||||
@@ -170,15 +170,15 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.2
|
||||
- name: pnpm install, check, lint
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: pnpm install, typecheck, lint, build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @evobgp/web check
|
||||
pnpm --filter @evobgp/web lint
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
go:
|
||||
|
||||
+8
-1
@@ -1,6 +1,13 @@
|
||||
# Root npm (semantic-release, commitlint) — npm ci in CI, never commit deps
|
||||
# Root npm/pnpm (semantic-release, commitlint, workspaces) — never commit deps
|
||||
node_modules/
|
||||
|
||||
# Vite / TS build output
|
||||
apps/web/dist/
|
||||
packages/*/dist/
|
||||
|
||||
# TanStack Router auto-generated route tree
|
||||
apps/web/src/routeTree.gen.ts
|
||||
|
||||
# Generated by deploy/docker/write-bake-override.sh (CI/local bake)
|
||||
deploy/docker/docker-bake.override.hcl
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ repos:
|
||||
hooks:
|
||||
- id: prettier-web
|
||||
name: prettier (web)
|
||||
entry: bash -c 'pnpm --filter @evobgp/web lint'
|
||||
entry: bash -c 'cd web && npx prettier --check .'
|
||||
language: system
|
||||
files: ^apps/web/
|
||||
files: ^web/
|
||||
pass_filenames: false
|
||||
|
||||
@@ -1,53 +1,74 @@
|
||||
# EvoBGP — руководство для ИИ-агентов
|
||||
# Руководство для ИИ-агентов (экономия контекста)
|
||||
|
||||
Краткая карта репозитория. Полная архитектура: [docs/architecture.md](docs/architecture.md). HTTP: [docs/openapi.yaml](docs/openapi.yaml).
|
||||
Краткие ориентиры по репозиторию **EvoBGP**, чтобы не тратить токены на полное сканирование дерева и повторное чтение одних и тех же файлов.
|
||||
|
||||
## Monorepo (фронтенд)
|
||||
## С чего начать (минимум чтения)
|
||||
|
||||
```
|
||||
EvoBGP/
|
||||
├── cmd/, internal/ # Go API /v1
|
||||
├── apps/web/ # SvelteKit SPA (@evobgp/web)
|
||||
├── packages/ui/ # @evobgp/ui — shadcn-svelte primitives
|
||||
├── packages/shared/ # @evobgp/shared — types, Zod contracts
|
||||
└── pnpm-workspace.yaml
|
||||
```
|
||||
0. **Инженерные правила** — при изменении кода следовать [.cursor/rules/engineering.mdc](.cursor/rules/engineering.mdc); для `apps/web/` + `packages/ui/` — [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc); для `birdfmt` / `pipeline` / BIRD — [.cursor/rules/networking-bird.mdc](.cursor/rules/networking-bird.mdc). **Context7 (документация библиотек)** — закреплённые ID стека: [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc); скилл [.cursor/skills/context7-evobgp/SKILL.md](.cursor/skills/context7-evobgp/SKILL.md).
|
||||
1. **[docs/README.md](docs/README.md)** — оглавление и роли читателя.
|
||||
2. **[docs/architecture.md](docs/architecture.md)** — компоненты `cmd/`, карта `internal/`, потоки данных (одного этого файла обычно достаточно для ориентации).
|
||||
3. Задача-специфично: [docs/api.md](docs/api.md), [docs/access.md](docs/access.md) — только если меняете API или доступ.
|
||||
|
||||
| Слой | Импорт |
|
||||
|------|--------|
|
||||
| Примитивы | `@evobgp/ui/components/*` |
|
||||
| CSS | `@evobgp/ui/styles/globals.css` |
|
||||
| Типы/контракты | `@evobgp/shared/types/*`, `@evobgp/shared/contracts/*` |
|
||||
| Паттерны UI | `$lib/components/*`, `$lib/components/patterns/*` |
|
||||
| Queries | `$lib/queries/*` (`@tanstack/svelte-query`) |
|
||||
Источник правды по HTTP-контракту: **[docs/openapi.yaml](docs/openapi.yaml)**. Не дублируйте длинные фрагменты спецификации в ответах — ссылайтесь на путь и тег/операцию.
|
||||
|
||||
## С чего начать
|
||||
## Карта кода (куда смотреть)
|
||||
|
||||
1. **Правила:** [engineering.mdc](.cursor/rules/engineering.mdc), [web-shadcn.mdc](.cursor/rules/web-shadcn.mdc), [frontend-monorepo.mdc](.cursor/rules/frontend-monorepo.mdc)
|
||||
2. **Фронт:** [apps/web/README.md](apps/web/README.md)
|
||||
3. **Go:** `internal/httpapi/`, `internal/store/`, `internal/pipeline/`
|
||||
| Область | Где искать |
|
||||
|---------|------------|
|
||||
| REST, auth, CORS | `internal/httpapi/` |
|
||||
| Бизнес-слой и абстракция хранилища | `internal/store/` |
|
||||
| PostgreSQL | `internal/repository/`, `internal/db/`, `migrations/` |
|
||||
| Фоновые задачи | `internal/jobs/` |
|
||||
| Цепочка refresh модуля (ingest+render, BIRD preview) | `internal/pipeline/` |
|
||||
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
|
||||
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
|
||||
| Точки входа процессов | `cmd/*/` |
|
||||
| Веб (SvelteKit) | `web/` |
|
||||
| Compose, деплой | `deploy/compose/` |
|
||||
|
||||
## Команды
|
||||
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Как не раздувать контекст
|
||||
|
||||
- **Сначала узкий поиск:** `grep`/поиск по символу или короткий семантический запрос по одной папке (`internal/httpapi/`, `internal/pipeline/`, …), а не чтение всех `.go` подряд.
|
||||
- **Читайте файлы целиком только при необходимости:** большие файлы — с `offset`/`limit` или по найденным строкам.
|
||||
- **Не подтягивайте в контекст:** `web/node_modules/`, сгенерированные артефакты сборки, бинарники, полный `openapi.html`, если достаточно `openapi.yaml`.
|
||||
- **Повторное использование:** если [docs/architecture.md](docs/architecture.md) уже описывает поток — не пересказывайте его длинно; укажите документ и конкретный подпункт задачи.
|
||||
- **Длинные планы:** `.cursor/plans/*.plan.md` — для истории решений; для навигации пользователю достаточно `docs/`; не читайте план целиком без причины.
|
||||
|
||||
## Коммиты (Conventional Commits)
|
||||
|
||||
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit**, **`/commit-message`** или это следует из плана — **сразу**:
|
||||
|
||||
1. Shell: `powershell -NoProfile -File scripts/commit/staged-context.ps1` (первый вызов, до текста коммита).
|
||||
2. Скилл [.cursor/skills/commit-message/SKILL.md](.cursor/skills/commit-message/SKILL.md) и правило [.cursor/rules/conventional-commits.mdc](.cursor/rules/conventional-commits.mdc).
|
||||
|
||||
Без вывода скрипта (exit 0) **не** придумывать сообщение коммита. Заголовок — EN, тело — RU; несвязанные области — auto-split (скилл).
|
||||
|
||||
**Кнопка ✨ Generate commit message в Source Control** skill/rule **не** использует. Для сообщений по правилам EvoBGP — Agent Chat → **`/commit-message`** (см. [.cursor/commands/commit-message.md](.cursor/commands/commit-message.md)).
|
||||
|
||||
## Команды и среда
|
||||
|
||||
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
|
||||
- Быстрый старт и переменные: [docs/quickstart.md](docs/quickstart.md), [README.md](README.md).
|
||||
- **Go:** после правок — `gofmt -w`, `go vet ./...`, `scripts/lint-go.ps1` (как CI golangci-lint).
|
||||
|
||||
## Язык документации проекта
|
||||
|
||||
Пользовательская документация в `docs/` — преимущественно на русском. Комментарии и имена в коде — в существующем стиле репозитория.
|
||||
|
||||
## Frontend (React + shadcn/ui + ReUI)
|
||||
|
||||
При правках `apps/web/**` или `packages/ui/**` следуйте [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc) (**WEB-19**): перед завершением задачи **обязательно**:
|
||||
|
||||
```powershell
|
||||
pnpm install
|
||||
pnpm --filter @evobgp/web dev
|
||||
pnpm --filter @evobgp/web build
|
||||
pnpm --filter @evobgp/web check
|
||||
pnpm --filter @evobgp/web lint
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run build
|
||||
```
|
||||
|
||||
После правок `apps/web/**` — **обязательно** `check` и `lint` (WEB-19).
|
||||
Все три команды должны exit 0. CI job `web` не пропускает без этого.
|
||||
|
||||
## UX-эталон (vps-tracker, без ReUI)
|
||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
|
||||
|
||||
- **Shell:** `layout/app-shell.svelte` — sidebar-07, `collapsible="icon"`, 5 nav-групп (`nav.ts`), `AppBrand`, `ThemeMenu` в top header, breadcrumb с parent
|
||||
- **Страница:** `PageShell` → `PageHeader` (без icon) → `QueryState` / контент
|
||||
- **Метрики:** `SectionCards` (не KpiMetricsGrid)
|
||||
- **Таблицы:** `DataTableCard` + `AppDataTable` (не голый `Card` + `Table`)
|
||||
- **Tabs:** line-variant через `LINE_TAB_TRIGGER_CLASS` (`$lib/ui/app/tabs.ts`)
|
||||
- **Тема:** `packages/ui/src/styles/globals.css` синхронизирован с vps-tracker (semantic tokens)
|
||||
|
||||
## Коммиты
|
||||
|
||||
При запросе коммита: [conventional-commits.mdc](.cursor/rules/conventional-commits.mdc), скилл `.cursor/skills/commit-message/SKILL.md`.
|
||||
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 стека.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# EvoBGP
|
||||
|
||||
Control plane для управления префиксами, модулями ingest, ревизиями конфигурации BIRD и выкладкой на BGP-спикеры. Репозиторий включает HTTP API на Go, веб-интерфейс (`apps/web/`), CLI для реплик (`evobgp-node`), агент и Docker Compose для локального и эталонного развёртывания.
|
||||
Control plane для управления префиксами, модулями ingest, ревизиями конфигурации BIRD и выкладкой на BGP-спикеры. Репозиторий включает HTTP API на Go, веб-интерфейс (`web/`), CLI для реплик (`evobgp-node`), агент и Docker Compose для локального и эталонного развёртывания.
|
||||
|
||||
## Документация
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# EvoBGP WebUI (`@evobgp/web`)
|
||||
|
||||
SvelteKit-панель управления EvoBGP. Monorepo: `apps/web` + `packages/ui` + `packages/shared`.
|
||||
|
||||
Правила: [.cursor/rules/web-shadcn.mdc](../../.cursor/rules/web-shadcn.mdc), [frontend-monorepo.mdc](../../.cursor/rules/frontend-monorepo.mdc).
|
||||
|
||||
## Структура
|
||||
|
||||
| Путь | Назначение |
|
||||
| ------------------------------ | ------------------------------------------- |
|
||||
| `packages/ui/src/components/` | shadcn-svelte примитивы (`@evobgp/ui`) |
|
||||
| `src/lib/components/` | PageShell, QueryState, SectionCards, domain |
|
||||
| `src/lib/components/patterns/` | AppDataTable, FormField, ConfirmDialog |
|
||||
| `src/lib/ui/app/` | PageHeader, nav, toast |
|
||||
| `src/lib/queries/` | TanStack Query factories |
|
||||
| `packages/shared/` | API types + Zod contracts |
|
||||
|
||||
## Разработка
|
||||
|
||||
Из корня репозитория:
|
||||
|
||||
```powershell
|
||||
pnpm install
|
||||
pnpm --filter @evobgp/web dev
|
||||
pnpm --filter @evobgp/web check
|
||||
pnpm --filter @evobgp/web lint
|
||||
pnpm --filter @evobgp/web build
|
||||
```
|
||||
|
||||
Добавление shadcn-svelte (из `apps/web/`):
|
||||
|
||||
```powershell
|
||||
pnpm dlx shadcn-svelte@latest add <component> -y -o
|
||||
```
|
||||
|
||||
Тема: `packages/ui/src/styles/globals.css` → импорт в `src/routes/layout.css`.
|
||||
|
||||
## Проверка перед PR
|
||||
|
||||
`pnpm --filter @evobgp/web check` и `lint` — exit 0 (WEB-19).
|
||||
+21
-14
@@ -1,16 +1,23 @@
|
||||
{
|
||||
"$schema": "https://www.shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "../../packages/ui/src/styles/globals.css",
|
||||
"baseColor": "neutral"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "@evobgp/ui/components",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"iconLibrary": "lucide"
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "../../packages/ui/src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"registries": {
|
||||
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"hooks": "@/hooks",
|
||||
"lib": "@/lib",
|
||||
"utils": "@evobgp/ui/lib/utils",
|
||||
"ui": "@evobgp/ui/components"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import globals from 'globals'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'prefer-const': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/components/reui/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="ru" class="">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>EvoBGP</title>
|
||||
<script>
|
||||
// Anti-FOUC: apply persisted theme before paint (matches next-themes attribute="class")
|
||||
try {
|
||||
var t = localStorage.getItem('evobgp-theme');
|
||||
var m = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var dark = t === 'dark' || (!t || t === 'system') && m;
|
||||
if (dark) document.documentElement.classList.add('dark');
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+60
-46
@@ -1,48 +1,62 @@
|
||||
{
|
||||
"name": "@evobgp/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@lucide/svelte": "^1.16.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^22.15.0",
|
||||
"formsnap": "^2.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.54.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"sveltekit-superforms": "^2.30.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@evobgp/shared": "workspace:*",
|
||||
"@evobgp/ui": "workspace:*",
|
||||
"@tanstack/svelte-query": "^5.90.2",
|
||||
"@tanstack/table-core": "^8.21.3",
|
||||
"bits-ui": "^2.17.2",
|
||||
"clsx": "^2.1.1",
|
||||
"svelte-sonner": "^1.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
"name": "@evobgp/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsr generate && tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsr generate && tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@evobgp/ui": "workspace:*",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-router": "^1.130.2",
|
||||
"@tanstack/react-router-devtools": "^1.130.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"recharts": "3.8.0",
|
||||
"sonner": "^1.7.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@tanstack/router-plugin": "^1.130.0",
|
||||
"@tanstack/router-cli": "^1.130.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.0.0",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.0",
|
||||
"globals": "^15.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"happy-dom": "^18.0.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"tw-animate-css": "^1.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#0f172a"/>
|
||||
<text x="16" y="22" font-family="ui-sans-serif,system-ui,sans-serif" font-size="18" font-weight="700" fill="#f8fafc" text-anchor="middle">B</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 272 B |
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
||||
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys'
|
||||
import type { ApiKey, ApiKeyCreated } from '@/types/api'
|
||||
|
||||
interface AccessApiKeysCardProps {
|
||||
items: ApiKey[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function AccessApiKeysCard({
|
||||
items,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: AccessApiKeysCardProps) {
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [tokenDialogOpen, setTokenDialogOpen] = useState(false)
|
||||
const [revealedToken, setRevealedToken] = useState('')
|
||||
|
||||
const revoke = useRevokeApiKeyMutation()
|
||||
const rotate = useRotateApiKeyMutation()
|
||||
|
||||
function showToken(created: ApiKeyCreated) {
|
||||
setRevealedToken(created.token)
|
||||
setTokenDialogOpen(true)
|
||||
}
|
||||
|
||||
function handleRotated(id: string) {
|
||||
rotate.mutate(id, {
|
||||
onSuccess: (created) => showToken(created),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
title="API-ключи"
|
||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет ключей"
|
||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<AccessApiKeysGrid
|
||||
items={data}
|
||||
isLoading={isLoading}
|
||||
onRotate={handleRotated}
|
||||
onRevoke={(id) => revoke.mutate(id)}
|
||||
rotatePending={rotate.isPending}
|
||||
revokePending={revoke.isPending}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<ApiKeyCreateDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={showToken}
|
||||
/>
|
||||
|
||||
<ApiKeyTokenDialog
|
||||
open={tokenDialogOpen}
|
||||
token={revealedToken}
|
||||
onOpenChange={setTokenDialogOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { ApiKey } from '@/types/api'
|
||||
|
||||
export function AccessApiKeysGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
onRotate,
|
||||
onRevoke,
|
||||
rotatePending = false,
|
||||
revokePending = false,
|
||||
}: {
|
||||
items: ApiKey[]
|
||||
isLoading?: boolean
|
||||
onRotate: (id: string) => void
|
||||
onRevoke: (id: string) => void
|
||||
rotatePending?: boolean
|
||||
revokePending?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<ApiKey>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{row.original.role}
|
||||
</Badge>
|
||||
),
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'prefix',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}…</span>
|
||||
),
|
||||
meta: { headerTitle: 'Префикс' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
header: 'Статус',
|
||||
cell: ({ row }) =>
|
||||
row.original.revoked_at ? (
|
||||
<StatusBadge status="error" label="отозван" />
|
||||
) : (
|
||||
<StatusBadge status="active" label="активен" />
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'expires_at',
|
||||
accessorFn: (row) => row.expires_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Истекает" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatApiKeyDate(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Истекает' },
|
||||
},
|
||||
{
|
||||
id: 'last_used_at',
|
||||
accessorFn: (row) => row.last_used_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatApiKeyDate(row.original.last_used_at)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Последнее использование' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const k = row.original
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
title="Ротировать"
|
||||
disabled={!!k.revoked_at || rotatePending}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Ротировать ключ?"
|
||||
description="Старый токен перестанет работать сразу."
|
||||
confirmLabel="Ротировать"
|
||||
onConfirm={() => onRotate(k.id)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={!!k.revoked_at || revokePending}
|
||||
title="Отозвать"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Отозвать API-ключ?"
|
||||
description={`${k.name} (${k.prefix}…)`}
|
||||
confirmLabel="Отозвать"
|
||||
destructive
|
||||
onConfirm={() => onRevoke(k.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[onRevoke, onRotate, revokePending, rotatePending],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ключей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск API-ключей…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
|
||||
import { useCreateApiKeyMutation } from '@/queries/api-keys'
|
||||
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
|
||||
|
||||
interface ApiKeyCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: (created: ApiKeyCreated) => void
|
||||
}
|
||||
|
||||
export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCreateDialogProps) {
|
||||
const createMutation = useCreateApiKeyMutation()
|
||||
const [name, setName] = useState('')
|
||||
const [role, setRole] = useState<ApiKeyRole>('editor')
|
||||
const [expiresLocal, setExpiresLocal] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setName('')
|
||||
setRole('editor')
|
||||
setExpiresLocal('')
|
||||
}, [open])
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
onOpenChange(next)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!name.trim()) {
|
||||
toast.error('Укажите имя')
|
||||
return
|
||||
}
|
||||
const body: ApiKeyCreate = {
|
||||
name: name.trim(),
|
||||
role,
|
||||
}
|
||||
if (expiresLocal.trim()) {
|
||||
const d = new Date(expiresLocal)
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
toast.error('Некорректная дата истечения')
|
||||
return
|
||||
}
|
||||
body.expires_at = d.toISOString()
|
||||
}
|
||||
try {
|
||||
const created = await createMutation.mutateAsync(body)
|
||||
onOpenChange(false)
|
||||
onCreated(created)
|
||||
} catch {
|
||||
// toast handled in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Новый API-ключ</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="key-name">Имя</Label>
|
||||
<Input
|
||||
id="key-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="CI / оператор UI"
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="key-role"
|
||||
label="Роль"
|
||||
items={[...API_KEY_ROLE_ITEMS]}
|
||||
value={role}
|
||||
placeholder="Выберите роль"
|
||||
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="key-expires">Истекает (опционально)</Label>
|
||||
<Input
|
||||
id="key-expires"
|
||||
type="datetime-local"
|
||||
value={expiresLocal}
|
||||
onChange={(e) => setExpiresLocal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton onClick={save} loading={createMutation.isPending}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Copy } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
|
||||
interface ApiKeyTokenDialogProps {
|
||||
open: boolean
|
||||
token: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ApiKeyTokenDialog({ open, token, onOpenChange }: ApiKeyTokenDialogProps) {
|
||||
async function copyToken() {
|
||||
if (!token) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(token)
|
||||
toast.success('Скопировано')
|
||||
} catch {
|
||||
toast.error('Не удалось скопировать')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Сохраните токен</DialogTitle>
|
||||
<DialogDescription>
|
||||
Он больше не будет показан. Скопируйте в безопасное хранилище.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">{token}</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={copyToken}>
|
||||
<Copy />
|
||||
Копировать
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>Готово</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { PlatformActivityItem } from '@/lib/metrics'
|
||||
|
||||
const KIND_ICON = {
|
||||
job: Info,
|
||||
revision: CheckCircle,
|
||||
network: AlertTriangle,
|
||||
} as const
|
||||
|
||||
const KIND_ICON_CLASS = {
|
||||
job: 'text-info',
|
||||
revision: 'text-success',
|
||||
network: 'text-warning',
|
||||
} as const
|
||||
|
||||
export function AnalyticsActivityList({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: PlatformActivityItem[]
|
||||
className?: string
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Нет недавних событий</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={cn('space-y-3', className)}>
|
||||
{items.map((item) => {
|
||||
const Icon = KIND_ICON[item.kind]
|
||||
return (
|
||||
<li key={item.id} className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted/60',
|
||||
KIND_ICON_CLASS[item.kind],
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
<p className="text-sm leading-snug">{item.message}</p>
|
||||
</div>
|
||||
<StatusBadge status={item.status} label={item.statusLabel} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@evobgp/ui/components/card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@evobgp/ui/components/tooltip'
|
||||
|
||||
export function AnalyticsCardShell({
|
||||
title,
|
||||
description,
|
||||
info,
|
||||
actions,
|
||||
footer,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
info?: string
|
||||
actions?: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn('flex h-full flex-col gap-0 overflow-hidden', className)}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 border-b py-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{title}
|
||||
{info ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="inline-flex text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Подробнее"
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||
{info}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-5 p-5">{children}</CardContent>
|
||||
{footer ? <CardFooter className="gap-2 border-t p-4">{footer}</CardFooter> : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export type AnalyticsKpiItem = {
|
||||
label: string
|
||||
value: string
|
||||
delta?: {
|
||||
direction: 'up' | 'down' | 'neutral'
|
||||
label: string
|
||||
tone?: 'success' | 'warning' | 'destructive' | 'muted'
|
||||
}
|
||||
}
|
||||
|
||||
const TONE_CLASS = {
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
muted: 'text-muted-foreground',
|
||||
} as const
|
||||
|
||||
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
|
||||
if (direction === 'up') return <TrendingUp className="size-3" />
|
||||
if (direction === 'down') return <TrendingDown className="size-3" />
|
||||
return <Minus className="size-3" />
|
||||
}
|
||||
|
||||
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
|
||||
return (
|
||||
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="min-w-0 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
|
||||
{item.delta ? (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
TONE_CLASS[item.delta.tone ?? 'muted'],
|
||||
)}
|
||||
>
|
||||
<DeltaIcon direction={item.delta.direction} />
|
||||
{item.delta.label}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
Progress,
|
||||
ProgressIndicator,
|
||||
ProgressTrack,
|
||||
} from '@evobgp/ui/components/progress'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export function AnalyticsProgress({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
className?: string
|
||||
}) {
|
||||
const clamped = Math.max(0, Math.min(100, value))
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium tabular-nums">{clamped}%</span>
|
||||
</div>
|
||||
<Progress value={clamped} className="gap-0">
|
||||
<ProgressTrack className="h-2">
|
||||
<ProgressIndicator className="bg-foreground" />
|
||||
</ProgressTrack>
|
||||
</Progress>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { ButtonGroup } from '@evobgp/ui/components/button-group'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export function AnalyticsSegmentControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className,
|
||||
}: {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: { value: T; label: string }[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<ButtonGroup className={cn('rounded-lg bg-muted/50 p-0.5', className)}>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={value === option.value ? 'secondary' : 'ghost'}
|
||||
className={cn(
|
||||
'h-7 rounded-md px-2.5 text-xs',
|
||||
value === option.value && 'bg-background shadow-sm',
|
||||
)}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Bar, BarChart, XAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, type ChartConfig } from '@evobgp/ui/components/chart'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import type { CapacityBar } from '@/lib/metrics'
|
||||
|
||||
const chartConfig = {
|
||||
value: { label: 'Загрузка', color: 'var(--color-chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function ChartBarStrip({
|
||||
bars,
|
||||
className,
|
||||
}: {
|
||||
bars: CapacityBar[]
|
||||
className?: string
|
||||
}) {
|
||||
if (bars.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex h-36 items-center justify-center text-sm text-muted-foreground', className)}>
|
||||
Нет данных для графика
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const data = bars.map((bar, index) => ({
|
||||
...bar,
|
||||
slot: index + 1,
|
||||
fill: bar.value >= 80 ? 'var(--color-chart-2)' : 'var(--color-chart-3)',
|
||||
}))
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className={cn('aspect-auto h-36 w-full', className)}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="slot" hide />
|
||||
<Bar dataKey="value" radius={[3, 3, 0, 0]} maxBarSize={10} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Cell, Label, Pie, PieChart } from 'recharts'
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
type ChartConfig,
|
||||
} from '@evobgp/ui/components/chart'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import type { BreakdownSlice } from '@/lib/metrics'
|
||||
|
||||
export function ChartDonutMetric({
|
||||
slices,
|
||||
centerLabel,
|
||||
centerValue,
|
||||
className,
|
||||
}: {
|
||||
slices: BreakdownSlice[]
|
||||
centerLabel: string
|
||||
centerValue: string | number
|
||||
className?: string
|
||||
}) {
|
||||
const chartConfig = slices.reduce<ChartConfig>((acc, slice) => {
|
||||
acc[slice.key] = { label: slice.label, color: slice.color }
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const data = slices.map((slice) => ({
|
||||
...slice,
|
||||
fill: slice.color,
|
||||
}))
|
||||
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className={cn('flex h-48 items-center justify-center text-sm text-muted-foreground', className)}>
|
||||
Нет данных
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-6', className)}>
|
||||
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="count"
|
||||
nameKey="label"
|
||||
innerRadius={52}
|
||||
outerRadius={72}
|
||||
strokeWidth={2}
|
||||
stroke="var(--color-card)"
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.key} fill={entry.fill} />
|
||||
))}
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
|
||||
const { cx, cy } = viewBox
|
||||
return (
|
||||
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan x={cx} y={(cy ?? 0) - 6} className="fill-muted-foreground text-xs">
|
||||
{centerLabel}
|
||||
</tspan>
|
||||
<tspan x={cx} y={(cy ?? 0) + 14} className="fill-foreground text-xl font-semibold">
|
||||
{centerValue}
|
||||
</tspan>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<ul className="min-w-0 flex-1 space-y-3">
|
||||
{slices.map((slice) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: slice.color }}
|
||||
/>
|
||||
<span className="truncate text-muted-foreground">{slice.label}</span>
|
||||
</div>
|
||||
<div className="shrink-0 text-right tabular-nums">
|
||||
<span className="font-semibold">{slice.count}</span>
|
||||
<span className="ml-2 text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
} from '@evobgp/ui/components/avatar'
|
||||
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { ChartBarStrip } from '@/components/analytics/chart-bar-strip'
|
||||
import {
|
||||
capacityUtilization,
|
||||
peerCapacityBars,
|
||||
speakerCapacityBars,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type CapacityMode = 'peers' | 'speakers'
|
||||
|
||||
function speakerInitials(speaker: SpeakerRow): string {
|
||||
const label = speaker.live?.label ?? speaker.agent_domain ?? speaker.endpoint ?? speaker.id
|
||||
const parts = label.split(/[.\-_@/]/).filter(Boolean)
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
|
||||
return label.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function DashboardNetworkCapacityCard({
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<CapacityMode>('peers')
|
||||
|
||||
const bars = useMemo(
|
||||
() => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)),
|
||||
[mode, peers, speakers],
|
||||
)
|
||||
const utilization = capacityUtilization(bars)
|
||||
const queued = runningJobCount(jobs)
|
||||
const previewSpeakers = speakers.slice(0, 3)
|
||||
|
||||
const deltaLabel =
|
||||
mode === 'peers'
|
||||
? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} Established`
|
||||
: `${speakers.filter((s) => s.live?.agent_ok).length} online`
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Загрузка BGP"
|
||||
description="Текущая утилизация сессий по пирам и спикерам"
|
||||
info="Каждый столбец — enabled peer или speaker. Высота отражает Established/online."
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'peers', label: 'Пиры' },
|
||||
{ value: 'speakers', label: 'Спикеры' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-3xl font-semibold tracking-tight tabular-nums">
|
||||
{loading ? '—' : `${utilization}%`}
|
||||
</p>
|
||||
<p className="text-sm text-success">{loading ? '…' : `${deltaLabel} · снимок live`}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartBarStrip bars={bars} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
Активных задач: <span className="font-medium text-foreground">{loading ? '—' : queued}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<AvatarGroup>
|
||||
{previewSpeakers.map((speaker) => (
|
||||
<Avatar key={speaker.id} size="sm">
|
||||
<AvatarFallback>{speakerInitials(speaker)}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
{speakers.length > 3 ? (
|
||||
<AvatarGroupCount>+{speakers.length - 3}</AvatarGroupCount>
|
||||
) : null}
|
||||
</AvatarGroup>
|
||||
<span className="text-muted-foreground">{speakers.length} спикеров</span>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, ModuleRow } from '@/types/api'
|
||||
|
||||
type FlowMode = 'jobs' | 'modules'
|
||||
|
||||
export function DashboardOperationsFlowCard({
|
||||
jobs,
|
||||
modules,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
modules: ModuleRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<FlowMode>('jobs')
|
||||
|
||||
const slices = useMemo(
|
||||
() => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)),
|
||||
[mode, jobs, modules],
|
||||
)
|
||||
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Поток операций"
|
||||
description="Распределение фоновых задач и типов модулей"
|
||||
info="Donut строится по текущей выборке API (до 100 последних задач)."
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'jobs', label: 'Задачи' },
|
||||
{ value: 'modules', label: 'Модули' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric
|
||||
slices={slices}
|
||||
centerLabel={centerLabel}
|
||||
centerValue={total}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { AnalyticsActivityList } from '@/components/analytics/analytics-activity-list'
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { AnalyticsProgress } from '@/components/analytics/analytics-progress'
|
||||
import {
|
||||
deploymentProgress,
|
||||
recentPlatformActivity,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, ModuleRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
export function DashboardPlatformCard({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const peersEnabled = peers.filter((p) => p.enabled !== false).length
|
||||
const peersEstablished = peers.filter(
|
||||
(p) => p.enabled !== false && p.session_state === 'Established',
|
||||
).length
|
||||
const peersMismatch = peers.filter((p) => p.session_mismatch).length
|
||||
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const running = runningJobCount(jobs)
|
||||
const riskCount = peersMismatch + failedJobs + Math.max(0, speakers.length - speakersOnline)
|
||||
|
||||
const bgpPct =
|
||||
peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null
|
||||
|
||||
const deploy = useMemo(() => deploymentProgress(speakers), [speakers])
|
||||
const activity = useMemo(
|
||||
() => recentPlatformActivity(jobs, revisions, peers, speakers),
|
||||
[jobs, revisions, peers, speakers],
|
||||
)
|
||||
|
||||
const kpis = [
|
||||
{
|
||||
label: 'Модули активны',
|
||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
||||
delta: {
|
||||
direction: 'neutral' as const,
|
||||
label: `${modules.length} всего`,
|
||||
tone: 'muted' as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'BGP готовность',
|
||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||
delta: {
|
||||
direction: (bgpPct !== null && bgpPct >= 90 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'neutral',
|
||||
label:
|
||||
bgpPct === null
|
||||
? 'нет включённых пиров'
|
||||
: `${peersEstablished} Established`,
|
||||
tone: (bgpPct !== null && bgpPct >= 90
|
||||
? 'success'
|
||||
: bgpPct !== null && bgpPct < 70
|
||||
? 'warning'
|
||||
: 'muted') as 'success' | 'warning' | 'muted',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Риски',
|
||||
value: loading ? '—' : String(riskCount),
|
||||
delta: {
|
||||
direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down',
|
||||
label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} mismatch` : 'в норме',
|
||||
tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const progressLabel =
|
||||
deploy.mode === 'revision'
|
||||
? `Синхронизация ревизий (${deploy.synced}/${deploy.total})`
|
||||
: `Спикеры online (${deploy.synced}/${deploy.total})`
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Состояние платформы"
|
||||
description="Сводка модулей, BGP и фоновых задач"
|
||||
info="Актуальный снимок без исторических трендов. Обновите данные кнопкой «Обновить» на странице."
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => navigate({ to: '/schedule' })}
|
||||
>
|
||||
Расписание
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
|
||||
>
|
||||
Мониторинг
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AnalyticsKpiRow items={kpis} />
|
||||
<AnalyticsProgress label={progressLabel} value={loading ? 0 : deploy.percent} />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Недавняя активность</span>
|
||||
{!loading ? (
|
||||
<span className="text-xs text-muted-foreground">{running} активных задач</span>
|
||||
) : null}
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Загрузка…</p>
|
||||
) : (
|
||||
<AnalyticsActivityList items={activity} />
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export { AnalyticsActivityList } from './analytics-activity-list'
|
||||
export { AnalyticsCardShell } from './analytics-card-shell'
|
||||
export { AnalyticsKpiRow, type AnalyticsKpiItem } from './analytics-kpi-row'
|
||||
export { AnalyticsProgress } from './analytics-progress'
|
||||
export { AnalyticsSegmentControl } from './analytics-segment-control'
|
||||
export { ChartBarStrip } from './chart-bar-strip'
|
||||
export { ChartDonutMetric } from './chart-donut-metric'
|
||||
export { DashboardNetworkCapacityCard } from './dashboard-network-capacity-card'
|
||||
export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
|
||||
export { DashboardPlatformCard } from './dashboard-platform-card'
|
||||
export { MonitoringHealthCard } from './monitoring-health-card'
|
||||
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
||||
export { OperationsAnalyticsCard } from './operations-analytics-card'
|
||||
@@ -0,0 +1,33 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { readinessBreakdown } from '@/lib/metrics'
|
||||
import type { ReadyStatus } from '@/queries/monitoring'
|
||||
|
||||
export function MonitoringHealthCard({
|
||||
healthOk,
|
||||
ready,
|
||||
loading,
|
||||
}: {
|
||||
healthOk: boolean
|
||||
ready: ReadyStatus | null | undefined
|
||||
loading?: boolean
|
||||
}) {
|
||||
const slices = readinessBreakdown(ready, healthOk)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Доступность системы"
|
||||
description="Health и readiness checks"
|
||||
info="Donut отражает результат GET /v1/health и checks из GET /v1/ready."
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Checks" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { peerSessionBreakdown } from '@/lib/metrics'
|
||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
export function NetworkOverviewAnalyticsCard({
|
||||
peers,
|
||||
speakers,
|
||||
loading,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
const slices = peerSessionBreakdown(peers)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Сводка BGP"
|
||||
description="Established, online и mismatch по live-данным"
|
||||
info="Снимок текущего состояния пиров и спикеров."
|
||||
>
|
||||
<AnalyticsKpiRow
|
||||
items={[
|
||||
{
|
||||
label: 'Пиры Established',
|
||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
delta: {
|
||||
direction: net.peersMismatch > 0 ? 'down' : 'up',
|
||||
label: net.peersMismatch > 0 ? `${net.peersMismatch} mismatch` : 'сессии в норме',
|
||||
tone: net.peersMismatch > 0 ? 'warning' : 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Спикеры online',
|
||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
delta: {
|
||||
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
|
||||
label:
|
||||
net.speakersOnline < net.speakersTotal
|
||||
? `${net.speakersTotal - net.speakersOnline} offline`
|
||||
: 'все online',
|
||||
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Пиры всего',
|
||||
value: loading ? '—' : String(net.peersTotal),
|
||||
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { jobStatusBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, RevisionRow } from '@/types/api'
|
||||
|
||||
export function OperationsAnalyticsCard({
|
||||
jobs,
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
|
||||
const failed = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const slices = jobStatusBreakdown(jobs)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Операции и задачи"
|
||||
description="Статистика ревизий и фоновых jobs"
|
||||
info="Данные из GET /v1/jobs и /v1/revisions."
|
||||
>
|
||||
<AnalyticsKpiRow
|
||||
items={[
|
||||
{
|
||||
label: 'Ревизий',
|
||||
value: loading ? '—' : String(revisions.length),
|
||||
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: loading ? '—' : String(running),
|
||||
delta: {
|
||||
direction: running > 0 ? 'up' : 'neutral',
|
||||
label: running > 0 ? 'выполняются' : 'очередь пуста',
|
||||
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'С ошибкой',
|
||||
value: loading ? '—' : String(failed),
|
||||
delta: {
|
||||
direction: failed > 0 ? 'down' : 'up',
|
||||
label: failed > 0 ? 'требуют внимания' : 'в норме',
|
||||
tone: failed > 0 ? 'destructive' : 'success',
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
export type BadgeTabItem = {
|
||||
value: string
|
||||
label: string
|
||||
count?: number
|
||||
badgeVariant?: ComponentProps<typeof Badge>['variant']
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
interface BadgeTabsProps {
|
||||
items: BadgeTabItem[]
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onValueChange?: (value: string) => void
|
||||
children: ReactNode
|
||||
className?: string
|
||||
listClassName?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
/** Underline tabs with optional badge counts (ReUI c-tabs-7 pattern). */
|
||||
export function BadgeTabs({
|
||||
items,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
listClassName,
|
||||
contentClassName,
|
||||
}: BadgeTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
className={className}
|
||||
>
|
||||
<TabsList variant="line" className={listClassName ?? 'mb-3.5 w-full'}>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{item.count !== undefined ? (
|
||||
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
|
||||
{item.count}
|
||||
</Badge>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className={contentClassName}>{children}</div>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export { TabsContent }
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@evobgp/ui/components/alert-dialog'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
trigger: ReactElement
|
||||
title: string
|
||||
description?: ReactNode
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
destructive?: boolean
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Подтвердить',
|
||||
cancelLabel = 'Отмена',
|
||||
destructive,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={trigger} />
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
function Row({ label, value, variant = 'default' }: { label: string; value: string; variant?: 'default' | 'warning' }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 px-1 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={
|
||||
variant === 'warning'
|
||||
? 'font-medium text-warning-foreground tabular-nums'
|
||||
: 'font-medium tabular-nums'
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardNetworkPanel({
|
||||
peers,
|
||||
speakers,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
}) {
|
||||
const m = aggregateNetworkMetrics(peers, speakers)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<Row label="Пиры Established" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
|
||||
<Row label="Спикеры online" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
|
||||
{m.peersMismatch > 0 ? (
|
||||
<Row label="Mismatches" value={String(m.peersMismatch)} variant="warning" />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { CardFooter } from '@evobgp/ui/components/card'
|
||||
|
||||
export function DashboardQuickActions() {
|
||||
return (
|
||||
<CardFooter className="flex flex-wrap gap-2 border-t-0 bg-transparent">
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
|
||||
<Plus className="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
|
||||
<Tags className="size-4" />
|
||||
Добавить community
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'overview' }} />}>
|
||||
<Network className="size-4" />
|
||||
Сеть
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'peers' }} />}>
|
||||
<Share2 className="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
render={<Link to="/operations" search={{ tab: 'revisions' }} />}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Деплой (Apply)
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}>
|
||||
<Gauge className="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
function StatusText({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === 'succeeded'
|
||||
? 'text-success'
|
||||
: status === 'failed' || status === 'cancelled'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
return <span className={`text-xs font-medium ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
export function DashboardRecentJobsGrid({
|
||||
jobs,
|
||||
nameById,
|
||||
isLoading = false,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
nameById: Map<string, string>
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const data = useMemo(() => jobs.slice(0, 8), [jobs])
|
||||
|
||||
const columns = useMemo<ColumnDef<JobRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'kind',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">{row.original.kind}</div>
|
||||
{row.original.meta?.module_id ? (
|
||||
<div className="truncate text-xs">
|
||||
{nameById.get(String(row.original.meta.module_id)) ?? ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Вид' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusText status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
],
|
||||
[nameById],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => {
|
||||
const moduleName = row.meta?.module_id
|
||||
? (nameById.get(String(row.meta.module_id)) ?? '')
|
||||
: ''
|
||||
return `${row.kind} ${row.status} ${moduleName}`
|
||||
},
|
||||
getRowId: (row) => row.job_id,
|
||||
pageSize: 8,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск задач…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { RevisionRow } from '@/types/api'
|
||||
|
||||
export function DashboardRecentRevisionsGrid({
|
||||
revisions,
|
||||
isLoading = false,
|
||||
}: {
|
||||
revisions: RevisionRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const data = useMemo(() => revisions.slice(0, 8), [revisions])
|
||||
|
||||
const columns = useMemo<ColumnDef<RevisionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'id',
|
||||
accessorFn: (row) => row.id,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">
|
||||
{row.original.id.slice(0, 10)}…
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'ID' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => row.id,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: 8,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ревизий"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск ревизий…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
|
||||
import { DataGridToolbar } from '@/components/data-grid-toolbar'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
DATA_GRID_PAGINATION_RU,
|
||||
DATA_GRID_TABLE_LAYOUT,
|
||||
} from '@/lib/data-grid-defaults'
|
||||
|
||||
interface DataGridShellProps<TData extends object> {
|
||||
table: Table<TData>
|
||||
recordCount: number
|
||||
isLoading?: boolean
|
||||
emptyMessage?: ReactNode
|
||||
showPagination?: boolean
|
||||
tableLayout?: typeof DATA_GRID_TABLE_LAYOUT
|
||||
className?: string
|
||||
onRowClick?: (row: TData) => void
|
||||
}
|
||||
|
||||
export function DataGridShell<TData extends object>({
|
||||
table,
|
||||
recordCount,
|
||||
isLoading = false,
|
||||
emptyMessage,
|
||||
showPagination = true,
|
||||
tableLayout = DATA_GRID_TABLE_LAYOUT,
|
||||
className,
|
||||
onRowClick,
|
||||
}: DataGridShellProps<TData>) {
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={recordCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={tableLayout}
|
||||
className={className}
|
||||
onRowClick={onRowClick}
|
||||
>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
{showPagination ? <DataGridPagination {...DATA_GRID_PAGINATION_RU} /> : null}
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataGridCardProps {
|
||||
title?: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DataGridCard({ title, description, actions, children, className }: DataGridCardProps) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
return (
|
||||
<Card className={className ?? 'gap-0'}>
|
||||
{hasHeader ? (
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{title ? <CardTitle className="text-base">{title}</CardTitle> : null}
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||
</CardHeader>
|
||||
) : null}
|
||||
<CardContent className="p-0">{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataGridSectionProps<TData extends object> extends DataGridShellProps<TData> {
|
||||
searchValue: string
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
toolbarFilters?: ReactNode
|
||||
toolbarActions?: ReactNode
|
||||
beforeGrid?: ReactNode
|
||||
}
|
||||
|
||||
export function DataGridSection<TData extends object>({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
toolbarFilters,
|
||||
toolbarActions,
|
||||
beforeGrid,
|
||||
...shellProps
|
||||
}: DataGridSectionProps<TData>) {
|
||||
return (
|
||||
<>
|
||||
<DataGridToolbar
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
filters={toolbarFilters}
|
||||
actions={toolbarActions}
|
||||
/>
|
||||
{beforeGrid}
|
||||
<DataGridShell {...shellProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Field } from '@evobgp/ui/components/field'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import { ListFilterIcon, SearchIcon, XIcon } from 'lucide-react'
|
||||
|
||||
interface DataGridToolbarProps {
|
||||
searchValue: string
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
filters?: ReactNode
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Search row for data grids (ReUI c-input-group-37 pattern). */
|
||||
export function DataGridToolbar({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Поиск…',
|
||||
filters,
|
||||
actions,
|
||||
className,
|
||||
}: DataGridToolbarProps) {
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-2 border-b px-3 py-3 ${className ?? ''}`}>
|
||||
<Field className="min-w-[200px] flex-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end" className="gap-1">
|
||||
{searchValue.length > 0 ? (
|
||||
<InputGroupButton
|
||||
aria-label="Очистить поиск"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => onSearchChange('')}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
) : null}
|
||||
{filters ? (
|
||||
filters
|
||||
) : (
|
||||
<InputGroupButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="pointer-events-none gap-1.5 opacity-0"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ListFilterIcon className="size-3.5" />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { BgpCommunity } from '@/types/api'
|
||||
|
||||
export function DirectoriesCommunitiesGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: BgpCommunity[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<BgpCommunity>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.title}</span>,
|
||||
meta: { headerTitle: 'Название' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'community',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.community}</span>,
|
||||
meta: { headerTitle: 'Значение' },
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
enableSorting: false,
|
||||
header: 'Тип',
|
||||
cell: () => <Badge variant="outline">community</Badge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.title} ${row.community}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет сообществ"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск сообществ…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { DohProfile } from '@/types/api'
|
||||
|
||||
export function DirectoriesDohGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: DohProfile[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DohProfile>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.url,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name ?? row.original.url}</span>,
|
||||
meta: { headerTitle: 'Название' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'url',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.url}</span>,
|
||||
meta: { headerTitle: 'URL' },
|
||||
},
|
||||
{
|
||||
id: 'default',
|
||||
enableSorting: false,
|
||||
header: 'По умолчанию',
|
||||
cell: () => <Badge variant="outline">—</Badge>,
|
||||
meta: { headerTitle: 'По умолчанию' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.name ?? ''} ${row.url}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет DoH профилей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск DoH профилей…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||
import { Field } from "@evobgp/ui/components/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import { Label } from "@evobgp/ui/components/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
|
||||
|
||||
const statuses = ["Pending", "Shipped", "Cancelled"] as const
|
||||
|
||||
type Status = (typeof statuses)[number]
|
||||
|
||||
function toggleStatus(values: Status[], value: Status) {
|
||||
return values.includes(value)
|
||||
? values.filter((item) => item !== value)
|
||||
: [...values, value]
|
||||
}
|
||||
|
||||
export function Pattern() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
|
||||
|
||||
return (
|
||||
<Field className="max-w-sm">
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<InputGroupInput
|
||||
placeholder="Search orders..."
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
/>
|
||||
|
||||
<InputGroupAddon align="inline-end" className="gap-1">
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupButton
|
||||
aria-label="Clear search"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
) : null}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<InputGroupButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
aria-label="Filter order status"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListFilterIcon className="size-3.5" aria-hidden="true" />
|
||||
Status
|
||||
{selectedStatuses.length > 0 ? (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{selectedStatuses.length}
|
||||
</span>
|
||||
) : null}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-40 p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{statuses.map((status) => (
|
||||
<div key={status} className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
id={`order-status-${status}`}
|
||||
checked={selectedStatuses.includes(status)}
|
||||
onCheckedChange={() =>
|
||||
setSelectedStatuses((previous) =>
|
||||
toggleStatus(previous, status)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`order-status-${status}`}
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
{status}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Field } from '@evobgp/ui/components/field'
|
||||
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
|
||||
const items = [
|
||||
{ label: 'Select an item', value: 'placeholder' as const },
|
||||
...Array.from({ length: 100 }).map((_, i) => ({
|
||||
label: `Item ${i}`,
|
||||
value: `item-${i}` as const,
|
||||
})),
|
||||
]
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<Field className="max-w-xs">
|
||||
<SelectMenu items={items} placeholder="Select an item" />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">
|
||||
<LayoutDashboardIcon className="size-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics">
|
||||
<BarChart3Icon className="size-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings">
|
||||
<SettingsIcon className="size-4" />
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<Card>
|
||||
<CardContent>Overview dashboard content goes here.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="analytics">
|
||||
<Card>
|
||||
<CardContent>Analytics charts and metrics.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<Card>
|
||||
<CardContent>Application settings and preferences.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Tabs defaultValue="inbox">
|
||||
<TabsList variant="line" className="mb-3.5 w-full">
|
||||
<TabsTrigger value="inbox" className="gap-2">
|
||||
Inbox
|
||||
<Badge variant="primary-light" size="sm">
|
||||
12
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drafts" className="gap-2">
|
||||
Drafts
|
||||
<Badge variant="info-light" size="sm">
|
||||
3
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sent" className="gap-2">
|
||||
Sent
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="spam" className="gap-2">
|
||||
Spam
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
24
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="inbox">
|
||||
<Card>
|
||||
<CardContent>12 unread messages in your inbox.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="drafts">
|
||||
<Card>
|
||||
<CardContent>3 drafts waiting to be sent.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="sent">
|
||||
<Card>
|
||||
<CardContent>All sent messages appear here.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="spam">
|
||||
<Card>
|
||||
<CardContent>24 spam messages detected.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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 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 }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.original.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{row.original.hostname || row.original.token_prefix}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
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="Last seen" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.last_seen_at?.slice(0, 19) ?? '—'}</span>
|
||||
),
|
||||
sortingFn: (a, b) => {
|
||||
const av = a.original.last_seen_at ?? ''
|
||||
const bv = b.original.last_seen_at ?? ''
|
||||
return av.localeCompare(bv)
|
||||
},
|
||||
meta: { headerTitle: 'Last seen' },
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
enableSorting: false,
|
||||
header: 'Apply',
|
||||
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: 'Apply' },
|
||||
},
|
||||
{
|
||||
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="Поиск клиентов…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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,174 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
Network,
|
||||
Cog,
|
||||
ListChecks,
|
||||
Activity,
|
||||
Settings,
|
||||
BookText,
|
||||
KeyRound,
|
||||
ServerCog,
|
||||
Shield,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@evobgp/ui/components/breadcrumb'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Обзор',
|
||||
items: [{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }],
|
||||
},
|
||||
{
|
||||
label: 'Маршрутизация',
|
||||
items: [
|
||||
{ to: '/modules', label: 'Модули', icon: Boxes },
|
||||
{ to: '/network', label: 'Сеть', icon: Network },
|
||||
{ to: '/directories', label: 'Справочники', icon: BookText },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Операции',
|
||||
items: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog },
|
||||
{ to: '/firewall', label: 'Firewall', icon: Shield },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Система',
|
||||
items: [
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound },
|
||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog },
|
||||
{ to: '/settings', label: 'Настройки UI', icon: Settings },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
||||
|
||||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||
)
|
||||
|
||||
const PARENT_ROUTE: Record<string, string> = {}
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const activeItem =
|
||||
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
||||
ALL_NAV_ITEMS[0]
|
||||
const parentTo = PARENT_ROUTE[activeItem.to]
|
||||
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
|
||||
B
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-semibold">EvoBGP</span>
|
||||
<span className="truncate text-xs text-muted-foreground">Control Plane</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{parentLabel && parentTo ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
</>
|
||||
) : null}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? activeItem.label}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
loading?: boolean
|
||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
|
||||
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
|
||||
return (
|
||||
<Button disabled={disabled || loading} {...props}>
|
||||
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
import {
|
||||
NONE_OPTION,
|
||||
communityOptionLabel,
|
||||
fromNullableSelect,
|
||||
nullableSelectValue,
|
||||
} from '@/lib/modules/helpers'
|
||||
import type { BgpCommunity } from '@/types/api'
|
||||
|
||||
interface CommunitySelectProps {
|
||||
id?: string
|
||||
label?: string
|
||||
value: string | null
|
||||
onValueChange: (value: string | null) => void
|
||||
communities: BgpCommunity[]
|
||||
nullable?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function CommunitySelect({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
communities,
|
||||
nullable = false,
|
||||
placeholder = 'Выберите community',
|
||||
}: CommunitySelectProps) {
|
||||
const items = useMemo(() => {
|
||||
const communityItems = communities.map((c) => ({
|
||||
value: c.id,
|
||||
label: communityOptionLabel(c),
|
||||
}))
|
||||
if (nullable) {
|
||||
return [{ value: NONE_OPTION, label: 'Не выбрано' }, ...communityItems]
|
||||
}
|
||||
return communityItems
|
||||
}, [communities, nullable])
|
||||
|
||||
const selectValue = nullable ? nullableSelectValue(value) : (value ?? '')
|
||||
|
||||
const select = (
|
||||
<SelectMenu
|
||||
id={id}
|
||||
items={items}
|
||||
value={selectValue}
|
||||
placeholder={placeholder}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return
|
||||
onValueChange(nullable ? fromNullableSelect(v) : v)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!label) {
|
||||
return select
|
||||
}
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
{select}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '@/types/api'
|
||||
|
||||
interface ModuleAsEntryDialogProps {
|
||||
open: boolean
|
||||
moduleId: string
|
||||
edit: AsEntry | null
|
||||
communities: BgpCommunity[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function ModuleAsEntryDialog({
|
||||
open,
|
||||
moduleId,
|
||||
edit,
|
||||
communities,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: ModuleAsEntryDialogProps) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<AsEntryCreate>({ asn: 0, community_id: null })
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setForm(
|
||||
edit
|
||||
? { asn: edit.asn, community_id: edit.community_id }
|
||||
: { asn: 0, community_id: null },
|
||||
)
|
||||
}, [open, edit])
|
||||
|
||||
async function save() {
|
||||
const asn = Number(form.asn)
|
||||
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
|
||||
toast.error('Укажите корректный ASN (1–4294967295)')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id }
|
||||
if (edit) {
|
||||
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body)
|
||||
toast.success('Запись обновлена')
|
||||
} else {
|
||||
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate)
|
||||
toast.success('Запись добавлена')
|
||||
}
|
||||
onOpenChange(false)
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Номер автономной системы и community для политики анонса.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="as-asn">ASN</Label>
|
||||
<Input
|
||||
id="as-asn"
|
||||
type="number"
|
||||
placeholder="12345"
|
||||
value={form.asn || ''}
|
||||
min={1}
|
||||
max={4294967295}
|
||||
onChange={(e) => setForm((s) => ({ ...s, asn: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="as-comm"
|
||||
label="Community"
|
||||
value={form.community_id ?? null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</LoadingButton>
|
||||
<LoadingButton loading={saving} onClick={() => void save()}>
|
||||
{edit ? 'Сохранить' : 'Добавить'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||
import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
|
||||
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
|
||||
|
||||
const CDN_KIND_ITEMS = [
|
||||
{ value: 'plaintext', label: 'plaintext' },
|
||||
{ value: 'json', label: 'json' },
|
||||
] as const
|
||||
|
||||
interface ModuleCdnSourceDialogProps {
|
||||
open: boolean
|
||||
moduleId: string
|
||||
edit: CdnSource | null
|
||||
communities: BgpCommunity[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}
|
||||
|
||||
type CdnForm = CdnSourceCreate & { refresh_interval_sec?: number | null }
|
||||
|
||||
export function ModuleCdnSourceDialog({
|
||||
open,
|
||||
moduleId,
|
||||
edit,
|
||||
communities,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: ModuleCdnSourceDialogProps) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewItems, setPreviewItems] = useState<string[]>([])
|
||||
const [previewTotal, setPreviewTotal] = useState(0)
|
||||
const [previewTruncated, setPreviewTruncated] = useState(false)
|
||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||
const [previewOk, setPreviewOk] = useState(false)
|
||||
const [form, setForm] = useState<CdnForm>({
|
||||
url: '',
|
||||
source_kind: 'plaintext',
|
||||
prefix_path: '',
|
||||
community_id: null,
|
||||
})
|
||||
|
||||
const kindItems = useMemo(() => [...CDN_KIND_ITEMS], [])
|
||||
|
||||
function clearPreview() {
|
||||
setPreviewLoading(false)
|
||||
setPreviewItems([])
|
||||
setPreviewTotal(0)
|
||||
setPreviewTruncated(false)
|
||||
setPreviewError(null)
|
||||
setPreviewOk(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
clearPreview()
|
||||
return
|
||||
}
|
||||
setForm(
|
||||
edit
|
||||
? {
|
||||
url: edit.url,
|
||||
source_kind: normalizeCdnSourceKind(edit.source_kind),
|
||||
prefix_path: edit.prefix_path ?? '',
|
||||
community_id: edit.community_id,
|
||||
refresh_interval_sec: edit.refresh_interval_sec,
|
||||
}
|
||||
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null },
|
||||
)
|
||||
clearPreview()
|
||||
}, [open, edit])
|
||||
|
||||
async function previewCdn() {
|
||||
const urlTrim = form.url.trim()
|
||||
if (!urlTrim) {
|
||||
toast.error('Укажите URL')
|
||||
return
|
||||
}
|
||||
setPreviewLoading(true)
|
||||
setPreviewError(null)
|
||||
setPreviewOk(false)
|
||||
try {
|
||||
const res = await apiMutate<CdnPreviewResponse>(
|
||||
`/v1/modules/${moduleId}/cdn-sources/preview`,
|
||||
'POST',
|
||||
{
|
||||
url: urlTrim,
|
||||
source_kind: form.source_kind,
|
||||
prefix_path: form.prefix_path?.trim() ?? '',
|
||||
},
|
||||
)
|
||||
setPreviewItems(res.items)
|
||||
setPreviewTotal(res.total)
|
||||
setPreviewTruncated(res.truncated)
|
||||
setPreviewOk(true)
|
||||
} catch (e) {
|
||||
setPreviewError(e instanceof ApiError ? e.message : String(e))
|
||||
setPreviewItems([])
|
||||
setPreviewTotal(0)
|
||||
setPreviewTruncated(false)
|
||||
setPreviewOk(false)
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const urlTrim = form.url.trim()
|
||||
if (!urlTrim) {
|
||||
toast.error('Укажите URL')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const body = {
|
||||
...form,
|
||||
url: urlTrim,
|
||||
source_kind: form.source_kind,
|
||||
prefix_path: form.prefix_path?.trim() ?? '',
|
||||
}
|
||||
if (edit) {
|
||||
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body)
|
||||
toast.success('Источник обновлён')
|
||||
} else {
|
||||
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body)
|
||||
toast.success('Источник добавлен')
|
||||
}
|
||||
clearPreview()
|
||||
onOpenChange(false)
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-url">URL</Label>
|
||||
<Input
|
||||
id="cdn-url"
|
||||
placeholder="https://example.com/list.txt"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="cdn-kind"
|
||||
label="Тип источника"
|
||||
items={kindItems}
|
||||
value={form.source_kind}
|
||||
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
|
||||
<Input
|
||||
id="cdn-prefix-path"
|
||||
placeholder="напр. prefixes[] или data.items[].cidr"
|
||||
value={form.prefix_path ?? ''}
|
||||
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
|
||||
/>
|
||||
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="cdn-comm"
|
||||
label="Community"
|
||||
value={form.community_id ?? null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
|
||||
<Input
|
||||
id="cdn-interval"
|
||||
type="number"
|
||||
placeholder="3600"
|
||||
value={form.refresh_interval_sec ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({
|
||||
...s,
|
||||
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void previewCdn()}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
|
||||
</Button>
|
||||
{previewError ? (
|
||||
<span className="text-sm text-destructive">{previewError}</span>
|
||||
) : previewOk ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Всего: {previewTotal}
|
||||
{previewTruncated ? (
|
||||
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{previewItems.length > 0 ? (
|
||||
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
|
||||
{previewItems.map((item, i) => (
|
||||
<li key={`${i}-${item}`} className="py-0.5">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</LoadingButton>
|
||||
<LoadingButton loading={saving} onClick={() => void save()}>
|
||||
{edit ? 'Сохранить' : 'Добавить'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '@/types/api'
|
||||
|
||||
interface ModuleDomainEntryDialogProps {
|
||||
open: boolean
|
||||
moduleId: string
|
||||
edit: DomainEntry | null
|
||||
communities: BgpCommunity[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function ModuleDomainEntryDialog({
|
||||
open,
|
||||
moduleId,
|
||||
edit,
|
||||
communities,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: ModuleDomainEntryDialogProps) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<DomainEntryCreate>({ fqdn: '', community_id: null })
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setForm(
|
||||
edit
|
||||
? { fqdn: edit.fqdn, community_id: edit.community_id }
|
||||
: { fqdn: '', community_id: null },
|
||||
)
|
||||
}, [open, edit])
|
||||
|
||||
async function save() {
|
||||
if (!form.fqdn.trim()) {
|
||||
toast.error('Укажите FQDN')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const body = { fqdn: form.fqdn.trim(), community_id: form.community_id }
|
||||
if (edit) {
|
||||
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', body)
|
||||
toast.success('Домен обновлён')
|
||||
} else {
|
||||
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', body)
|
||||
toast.success('Домен добавлен')
|
||||
}
|
||||
onOpenChange(false)
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dom-fqdn">FQDN</Label>
|
||||
<Input
|
||||
id="dom-fqdn"
|
||||
placeholder="example.com"
|
||||
value={form.fqdn}
|
||||
onChange={(e) => setForm((s) => ({ ...s, fqdn: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="dom-comm"
|
||||
label="Community"
|
||||
value={form.community_id ?? null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</LoadingButton>
|
||||
<LoadingButton loading={saving} onClick={() => void save()}>
|
||||
{edit ? 'Сохранить' : 'Добавить'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { formatDateTime } from '@/lib/modules/display'
|
||||
import { communityLabel } from '@/lib/modules/helpers'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type {
|
||||
AsEntry,
|
||||
BgpCommunity,
|
||||
CdnSource,
|
||||
DomainEntry,
|
||||
IpRangeEntry,
|
||||
ModuleRow,
|
||||
} from '@/types/api'
|
||||
|
||||
type DeleteTarget =
|
||||
| { kind: 'domain'; entry: DomainEntry }
|
||||
| { kind: 'ip-range'; entry: IpRangeEntry }
|
||||
| { kind: 'cdn'; entry: CdnSource }
|
||||
| { kind: 'as'; entry: AsEntry }
|
||||
|
||||
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon-sm" type="button" onClick={onEdit} aria-label="Редактировать">
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
onClick={onDelete}
|
||||
aria-label="Удалить"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModuleEntriesGrid({
|
||||
mod,
|
||||
rows,
|
||||
communities,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
}: {
|
||||
mod: ModuleRow
|
||||
rows: Record<string, unknown>[]
|
||||
communities: BgpCommunity[]
|
||||
onEdit: (target: DeleteTarget) => void
|
||||
onDelete: (target: DeleteTarget) => void
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo(() => {
|
||||
if (mod.type === 'DOMAINS') {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'fqdn',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="FQDN" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
||||
<span className="font-mono text-sm">{row.original.fqdn}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
header: 'Community',
|
||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{communityLabel(row.original.community_id, communities)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'domain', entry: row.original })}
|
||||
onDelete={() => onDelete({ kind: 'domain', entry: row.original })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<DomainEntry>[]
|
||||
}
|
||||
|
||||
if (mod.type === 'IP_RANGES') {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'prefix',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="Префикс (CIDR)" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
||||
<span className="font-mono text-sm">{row.original.prefix}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
header: 'Community',
|
||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{communityLabel(row.original.community_id, communities)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'ip-range', entry: row.original })}
|
||||
onDelete={() => onDelete({ kind: 'ip-range', entry: row.original })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<IpRangeEntry>[]
|
||||
}
|
||||
|
||||
if (mod.type === 'CDN_CIDRS') {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'url',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="URL" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<span className="max-w-xs truncate font-mono text-xs">{row.original.url}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'source_kind',
|
||||
header: 'Тип',
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<span className="text-sm">{row.original.source_kind}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
header: 'Community',
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{communityLabel(row.original.community_id, communities)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row: CdnSource) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="Обновлено" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(row.original.last_refreshed_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'cdn', entry: row.original })}
|
||||
onDelete={() => onDelete({ kind: 'cdn', entry: row.original })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<CdnSource>[]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'asn',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="ASN" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<span className="font-mono text-sm">{row.original.asn}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'asn_name',
|
||||
header: 'Имя',
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<span className="text-sm">{row.original.asn_name ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'prefix_count',
|
||||
header: 'Префиксов',
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<span className="font-mono text-sm">{row.original.prefix_count ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
header: 'Community',
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{communityLabel(row.original.community_id, communities)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'as', entry: row.original })}
|
||||
onDelete={() => onDelete({ kind: 'as', entry: row.original })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<AsEntry>[]
|
||||
}, [communities, mod.type, onDelete, onEdit])
|
||||
|
||||
type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry
|
||||
const data = rows as unknown as RowType[]
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns: columns as ColumnDef<RowType>[],
|
||||
getSearchText: (row) => {
|
||||
const r = row as Record<string, unknown>
|
||||
return Object.values(r)
|
||||
.filter((v) => typeof v === 'string' || typeof v === 'number')
|
||||
.join(' ')
|
||||
},
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет записей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск записей…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type { DeleteTarget as ModuleEntryDeleteTarget }
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@evobgp/ui/components/alert-dialog'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
|
||||
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
|
||||
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
|
||||
import { ModuleDomainEntryDialog } from '@/components/modules/module-domain-entry-dialog'
|
||||
import { ModuleIpRangeEntryDialog } from '@/components/modules/module-ip-range-entry-dialog'
|
||||
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||
import type {
|
||||
AsEntry,
|
||||
BgpCommunity,
|
||||
CdnSource,
|
||||
DomainEntry,
|
||||
IpRangeEntry,
|
||||
ModuleRow,
|
||||
} from '@/types/api'
|
||||
|
||||
interface ModuleEntriesSectionProps {
|
||||
moduleId: string
|
||||
mod: ModuleRow
|
||||
items: Record<string, unknown>[]
|
||||
communities: BgpCommunity[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: Error | null
|
||||
onRetry: () => void
|
||||
onChanged: () => void | Promise<void>
|
||||
}
|
||||
|
||||
type DeleteTarget = ModuleEntryDeleteTarget
|
||||
|
||||
const CARD_META: Record<
|
||||
ModuleRow['type'],
|
||||
{ title: string; description: string; emptyTitle: string; emptyDescription: string }
|
||||
> = {
|
||||
DOMAINS: {
|
||||
title: 'Домены',
|
||||
description: 'FQDN для резолвинга через DoH.',
|
||||
emptyTitle: 'Нет доменов',
|
||||
emptyDescription: 'Добавьте FQDN для резолвинга.',
|
||||
},
|
||||
IP_RANGES: {
|
||||
title: 'IP-диапазоны',
|
||||
description: 'Статические CIDR для анонса.',
|
||||
emptyTitle: 'Нет диапазонов',
|
||||
emptyDescription: 'Добавьте CIDR.',
|
||||
},
|
||||
CDN_CIDRS: {
|
||||
title: 'CDN-источники',
|
||||
description: 'URL источников для скачивания списков CIDR.',
|
||||
emptyTitle: 'Нет источников',
|
||||
emptyDescription: 'Добавьте CDN-источник.',
|
||||
},
|
||||
AS_PREFIXES: {
|
||||
title: 'AS-записи',
|
||||
description: 'ASN для получения префиксов через RIPEstat.',
|
||||
emptyTitle: 'Нет записей',
|
||||
emptyDescription: 'Добавьте ASN.',
|
||||
},
|
||||
}
|
||||
|
||||
export function ModuleEntriesSection({
|
||||
moduleId,
|
||||
mod,
|
||||
items,
|
||||
communities,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
onChanged,
|
||||
}: ModuleEntriesSectionProps) {
|
||||
const meta = CARD_META[mod.type]
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const [editDomain, setEditDomain] = useState<DomainEntry | null>(null)
|
||||
const [editIpRange, setEditIpRange] = useState<IpRangeEntry | null>(null)
|
||||
const [editCdn, setEditCdn] = useState<CdnSource | null>(null)
|
||||
const [editAs, setEditAs] = useState<AsEntry | null>(null)
|
||||
|
||||
function openCreate() {
|
||||
setEditDomain(null)
|
||||
setEditIpRange(null)
|
||||
setEditCdn(null)
|
||||
setEditAs(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialogOpen(false)
|
||||
setEditDomain(null)
|
||||
setEditIpRange(null)
|
||||
setEditCdn(null)
|
||||
setEditAs(null)
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
const { kind, entry } = deleteTarget
|
||||
const pathByKind = {
|
||||
domain: `/v1/modules/${moduleId}/domain-entries/${entry.id}`,
|
||||
'ip-range': `/v1/modules/${moduleId}/ip-range-entries/${entry.id}`,
|
||||
cdn: `/v1/modules/${moduleId}/cdn-sources/${entry.id}`,
|
||||
as: `/v1/modules/${moduleId}/as-entries/${entry.id}`,
|
||||
} as const
|
||||
await apiMutate(pathByKind[kind], 'DELETE', undefined, { idempotent: false })
|
||||
toast.success('Удалено')
|
||||
setDeleteTarget(null)
|
||||
await onChanged()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : String(e))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
title={meta.title}
|
||||
description={meta.description}
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={openCreate}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle={meta.emptyTitle}
|
||||
emptyDescription={meta.emptyDescription}
|
||||
skeleton={<TableSkeleton rows={5} cols={3} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(rows) => (
|
||||
<ModuleEntriesGrid
|
||||
mod={mod}
|
||||
rows={rows}
|
||||
communities={communities}
|
||||
isLoading={isLoading}
|
||||
onEdit={(target) => {
|
||||
if (target.kind === 'domain') setEditDomain(target.entry)
|
||||
if (target.kind === 'ip-range') setEditIpRange(target.entry)
|
||||
if (target.kind === 'cdn') setEditCdn(target.entry)
|
||||
if (target.kind === 'as') setEditAs(target.entry)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
{mod.type === 'DOMAINS' ? (
|
||||
<ModuleDomainEntryDialog
|
||||
open={dialogOpen}
|
||||
moduleId={moduleId}
|
||||
edit={editDomain}
|
||||
communities={communities}
|
||||
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
|
||||
onSaved={onChanged}
|
||||
/>
|
||||
) : null}
|
||||
{mod.type === 'IP_RANGES' ? (
|
||||
<ModuleIpRangeEntryDialog
|
||||
open={dialogOpen}
|
||||
moduleId={moduleId}
|
||||
edit={editIpRange}
|
||||
communities={communities}
|
||||
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
|
||||
onSaved={onChanged}
|
||||
/>
|
||||
) : null}
|
||||
{mod.type === 'CDN_CIDRS' ? (
|
||||
<ModuleCdnSourceDialog
|
||||
open={dialogOpen}
|
||||
moduleId={moduleId}
|
||||
edit={editCdn}
|
||||
communities={communities}
|
||||
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
|
||||
onSaved={onChanged}
|
||||
/>
|
||||
) : null}
|
||||
{mod.type === 'AS_PREFIXES' ? (
|
||||
<ModuleAsEntryDialog
|
||||
open={dialogOpen}
|
||||
moduleId={moduleId}
|
||||
edit={editAs}
|
||||
communities={communities}
|
||||
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
|
||||
onSaved={onChanged}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить запись?</AlertDialogTitle>
|
||||
<AlertDialogDescription>{deleteDescription(deleteTarget)}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={() => void confirmDelete()}
|
||||
>
|
||||
{deleting ? 'Удаление…' : 'Удалить'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function deleteDescription(target: DeleteTarget | null): string {
|
||||
if (!target) return ''
|
||||
switch (target.kind) {
|
||||
case 'domain':
|
||||
return target.entry.fqdn
|
||||
case 'ip-range':
|
||||
return target.entry.prefix
|
||||
case 'cdn':
|
||||
return target.entry.url
|
||||
case 'as':
|
||||
return `AS${target.entry.asn}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '@/types/api'
|
||||
|
||||
interface ModuleIpRangeEntryDialogProps {
|
||||
open: boolean
|
||||
moduleId: string
|
||||
edit: IpRangeEntry | null
|
||||
communities: BgpCommunity[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function ModuleIpRangeEntryDialog({
|
||||
open,
|
||||
moduleId,
|
||||
edit,
|
||||
communities,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: ModuleIpRangeEntryDialogProps) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<IpRangeEntryCreate>({ prefix: '', community_id: '' })
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setForm(
|
||||
edit
|
||||
? { prefix: edit.prefix, community_id: edit.community_id }
|
||||
: { prefix: '', community_id: '' },
|
||||
)
|
||||
}, [open, edit])
|
||||
|
||||
async function save() {
|
||||
if (!form.prefix.trim() || !form.community_id) {
|
||||
toast.error('Укажите префикс и community')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const body = { prefix: form.prefix.trim(), community_id: form.community_id }
|
||||
if (edit) {
|
||||
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', body)
|
||||
toast.success('Диапазон обновлён')
|
||||
} else {
|
||||
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', body)
|
||||
toast.success('Диапазон добавлен')
|
||||
}
|
||||
onOpenChange(false)
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
|
||||
<Input
|
||||
id="ip-prefix"
|
||||
placeholder="203.0.113.0/24"
|
||||
value={form.prefix}
|
||||
onChange={(e) => setForm((s) => ({ ...s, prefix: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="ip-comm"
|
||||
label="Community (обязательно)"
|
||||
value={form.community_id || null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v ?? '' }))}
|
||||
communities={communities}
|
||||
placeholder="Выберите community"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</LoadingButton>
|
||||
<LoadingButton loading={saving} onClick={() => void save()}>
|
||||
{edit ? 'Сохранить' : 'Добавить'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
ArrowDownUp,
|
||||
Network,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Timer,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||
import {
|
||||
communityLabel,
|
||||
dohProfileLabel,
|
||||
moduleDohProfileIds,
|
||||
} from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
||||
|
||||
interface ModuleKpiCardsProps {
|
||||
mod: ModuleRow | null
|
||||
communities: BgpCommunity[]
|
||||
dohProfiles: DohProfile[]
|
||||
asEntries: AsEntry[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ModuleKpiCards({
|
||||
mod,
|
||||
communities,
|
||||
dohProfiles,
|
||||
asEntries,
|
||||
loading = false,
|
||||
}: ModuleKpiCardsProps) {
|
||||
if (loading || !mod) {
|
||||
return <SectionCardsSkeleton count={5} />
|
||||
}
|
||||
|
||||
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||
const dohIds = moduleDohProfileIds(mod)
|
||||
|
||||
const items: SectionCardItem[] = [
|
||||
{
|
||||
label: 'Приоритет',
|
||||
value: String(mod.priority ?? 0),
|
||||
hint: 'порядок в сборке ревизии',
|
||||
icon: <ArrowDownUp className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
label: 'Интервал',
|
||||
value: moduleIntervalLabel(mod),
|
||||
hint: 'refresh_interval_sec / cron',
|
||||
icon: <Timer className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
label: 'DoH',
|
||||
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
|
||||
hint:
|
||||
mod.type === 'DOMAINS'
|
||||
? dohIds.length
|
||||
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
|
||||
: 'Системный DNS'
|
||||
: 'не применимо',
|
||||
icon: <Network className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
label: 'Community по умолч.',
|
||||
value: communityLabel(mod.default_community_id, communities),
|
||||
hint: 'для записей без своего community',
|
||||
icon: <ShieldCheck className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
label: 'Последнее обновление',
|
||||
value: formatDateTime(mod.last_refreshed_at),
|
||||
hint:
|
||||
mod.type === 'AS_PREFIXES'
|
||||
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
|
||||
: 'время последнего refresh',
|
||||
icon: <RefreshCw className="size-3.5" />,
|
||||
},
|
||||
]
|
||||
|
||||
return <SectionCards items={items} />
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Boxes } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
|
||||
export function ModulesListGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: ModuleRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const columns = useMemo<ColumnDef<ModuleRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes className="size-4 text-muted-foreground" />
|
||||
<TruncatedText className="max-w-[280px] font-medium">{row.original.name}</TruncatedText>
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Название' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
|
||||
),
|
||||
meta: { headerTitle: 'Приоритет' },
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
accessorFn: (row) => (row.enabled ? 'enabled' : 'disabled'),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) =>
|
||||
row.original.enabled ? (
|
||||
<Badge variant="success">включён</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">выключен</Badge>
|
||||
),
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.last_refreshed_at
|
||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
sortingFn: (a, b) => {
|
||||
const av = a.original.last_refreshed_at ?? ''
|
||||
const bv = b.original.last_refreshed_at ?? ''
|
||||
return av.localeCompare(bv)
|
||||
},
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'включён' : 'выключен'}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет модулей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск модулей…"
|
||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { ReadyStatus } from '@/queries/monitoring'
|
||||
|
||||
const READY_CHECK_ICONS: Record<string, typeof Database> = {
|
||||
postgres: Database,
|
||||
store: HardDrive,
|
||||
jobs: ListTodo,
|
||||
}
|
||||
|
||||
interface ReadyCheckRow {
|
||||
id: string
|
||||
label: string
|
||||
subtitle?: string
|
||||
icon: typeof Database
|
||||
ok: boolean
|
||||
statusLabel: string
|
||||
variant: 'default' | 'destructive' | 'secondary'
|
||||
}
|
||||
|
||||
export function MonitoringReadyGrid({
|
||||
health,
|
||||
ready,
|
||||
}: {
|
||||
health?: { ok?: boolean; status?: string; error?: string } | null
|
||||
ready: ReadyStatus
|
||||
}) {
|
||||
const data = useMemo<ReadyCheckRow[]>(() => {
|
||||
const checks = ready.checks ?? {}
|
||||
const rows: ReadyCheckRow[] = [
|
||||
{
|
||||
id: 'liveness',
|
||||
label: 'Liveness',
|
||||
subtitle: '/v1/health',
|
||||
icon: HeartPulse,
|
||||
ok: health?.ok === true,
|
||||
statusLabel: health?.ok ? 'OK' : 'Ошибка',
|
||||
variant: health?.ok ? 'default' : 'destructive',
|
||||
},
|
||||
{
|
||||
id: 'readiness',
|
||||
label: 'Readiness',
|
||||
subtitle: '/v1/ready',
|
||||
icon: ShieldCheck,
|
||||
ok: ready.status === 'ok',
|
||||
statusLabel: ready.status ?? '—',
|
||||
variant: ready.status === 'ok' ? 'default' : 'secondary',
|
||||
},
|
||||
]
|
||||
for (const key of Object.keys(checks)) {
|
||||
const value = checks[key]
|
||||
const ok = typeof value === 'boolean' ? value : value?.ok !== false
|
||||
rows.push({
|
||||
id: key,
|
||||
label: key,
|
||||
icon: READY_CHECK_ICONS[key] ?? ListTodo,
|
||||
ok,
|
||||
statusLabel: ok ? 'OK' : 'Ошибка',
|
||||
variant: ok ? 'default' : 'destructive',
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}, [health?.ok, ready.checks, ready.status])
|
||||
|
||||
const columns = useMemo<ColumnDef<ReadyCheckRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'label',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Проверка" />,
|
||||
cell: ({ row }) => {
|
||||
const Icon = row.original.icon
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{row.original.label}</p>
|
||||
{row.original.subtitle ? (
|
||||
<p className="text-xs text-muted-foreground">{row.original.subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Проверка' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.variant}>{row.original.statusLabel}</Badge>
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.label} ${row.subtitle ?? ''} ${row.statusLabel}`,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: 20,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
showPagination={false}
|
||||
emptyMessage="Нет проверок"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск проверок…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
|
||||
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
interface NetworkPeersCardProps {
|
||||
items: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function NetworkPeersCard({
|
||||
items,
|
||||
speakers,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: NetworkPeersCardProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
title="Пиры"
|
||||
description="BGP-соседи и привязка к спикерам"
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||
<Plus />
|
||||
Добавить пира
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет пиров"
|
||||
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<PeerFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
speakers={speakers}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { PeerRow } from '@/types/api'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
|
||||
export function NetworkPeersGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: PeerRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<PeerRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.neighbor,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.name ?? row.original.neighbor}</span>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'neighbor',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Neighbor" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.neighbor}</span>,
|
||||
meta: { headerTitle: 'Neighbor' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'remote_asn',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
|
||||
),
|
||||
meta: { headerTitle: 'ASN' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'session_state',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusBadge status={row.original.session_state} />
|
||||
{row.original.session_mismatch ? (
|
||||
<Badge variant="warning" className="ml-1">
|
||||
mismatch
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет пиров"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск пиров…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
|
||||
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
interface NetworkSpeakersCardProps {
|
||||
items: SpeakerRow[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function NetworkSpeakersCard({
|
||||
items,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: NetworkSpeakersCardProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
title="Спикеры"
|
||||
description="BIRD-агенты на нодах tenant"
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||
<Plus />
|
||||
Добавить спикера
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет спикеров"
|
||||
emptyDescription="Добавьте первого BIRD-агента на ноде."
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
export function NetworkSpeakersGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: SpeakerRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'endpoint',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Endpoint" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.endpoint}</span>,
|
||||
meta: { headerTitle: 'Endpoint' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => <Badge variant="outline">{row.original.role}</Badge>,
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
enableSorting: false,
|
||||
header: 'Agent',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (live?.agent_ok === true) return <StatusBadge status="ok" label="online" />
|
||||
if (live?.agent_ok === false) return <StatusBadge status="error" label="offline" />
|
||||
return <Badge variant="outline">—</Badge>
|
||||
},
|
||||
meta: { headerTitle: 'Agent' },
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
enableSorting: false,
|
||||
header: 'BGP',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (!live) return '—'
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'BGP' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет спикеров"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск спикеров…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
||||
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { useCreatePeerMutation, useUpdatePeerMutation } from '@/queries/network'
|
||||
import type { BgpPeerCreate, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
interface PeerFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
speakers: SpeakerRow[]
|
||||
editTarget?: PeerRow | null
|
||||
}
|
||||
|
||||
function speakerLabel(s: SpeakerRow): string {
|
||||
if (s.role === 'master') {
|
||||
const host = s.agent_domain ?? s.endpoint
|
||||
return host ? `CP · ${host}` : 'CP (master)'
|
||||
}
|
||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||
}
|
||||
|
||||
export function PeerFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
speakers,
|
||||
editTarget = null,
|
||||
}: PeerFormDialogProps) {
|
||||
const createMutation = useCreatePeerMutation()
|
||||
const updateMutation = useUpdatePeerMutation()
|
||||
const saving = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [neighbor, setNeighbor] = useState('')
|
||||
const [remoteAsn, setRemoteAsn] = useState('')
|
||||
const [bgpSpeakerId, setBgpSpeakerId] = useState<string | null>(null)
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (editTarget) {
|
||||
setName(editTarget.name ?? '')
|
||||
setNeighbor(editTarget.neighbor)
|
||||
setRemoteAsn(String(editTarget.remote_asn ?? ''))
|
||||
setBgpSpeakerId(editTarget.bgp_speaker_id ?? null)
|
||||
setEnabled(editTarget.enabled !== false)
|
||||
} else {
|
||||
setName('')
|
||||
setNeighbor('')
|
||||
setRemoteAsn('')
|
||||
setBgpSpeakerId(null)
|
||||
setEnabled(true)
|
||||
}
|
||||
}, [editTarget, open])
|
||||
|
||||
const speakerItems = [
|
||||
{ value: '', label: 'Все спикеры' },
|
||||
...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })),
|
||||
]
|
||||
|
||||
async function save() {
|
||||
if (!neighbor.trim()) {
|
||||
toast.error('Укажите адрес соседа')
|
||||
return
|
||||
}
|
||||
const asn = Number(remoteAsn)
|
||||
if (!asn || asn <= 0) {
|
||||
toast.error('Remote ASN должен быть больше 0')
|
||||
return
|
||||
}
|
||||
const body: BgpPeerCreate = {
|
||||
name: name.trim() || undefined,
|
||||
neighbor: neighbor.trim(),
|
||||
remote_asn: asn,
|
||||
bgp_speaker_id: bgpSpeakerId || null,
|
||||
enabled,
|
||||
}
|
||||
try {
|
||||
if (editTarget) {
|
||||
await updateMutation.mutateAsync({ id: editTarget.id, body })
|
||||
} else {
|
||||
await createMutation.mutateAsync(body)
|
||||
}
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
|
||||
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="peer-name">Имя пира (опционально)</Label>
|
||||
<Input
|
||||
id="peer-name"
|
||||
placeholder="Core-RTR-1"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="peer-neighbor">Адрес соседа</Label>
|
||||
<Input
|
||||
id="peer-neighbor"
|
||||
placeholder="192.0.2.1"
|
||||
value={neighbor}
|
||||
onChange={(e) => setNeighbor(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="peer-asn">Remote ASN</Label>
|
||||
<Input
|
||||
id="peer-asn"
|
||||
type="number"
|
||||
placeholder="65000"
|
||||
value={remoteAsn}
|
||||
onChange={(e) => setRemoteAsn(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="peer-speaker"
|
||||
label="Спикер (опционально)"
|
||||
items={speakerItems}
|
||||
value={bgpSpeakerId ?? ''}
|
||||
onValueChange={(v) => setBgpSpeakerId(v || null)}
|
||||
placeholder="Все спикеры"
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<Label htmlFor="peer-enabled">Включён</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="peer-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={saving} onClick={save}>
|
||||
{editTarget ? 'Сохранить' : 'Создать'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { useCreateSpeakerMutation } from '@/queries/network'
|
||||
import type { BgpSpeakerCreate } from '@/types/api'
|
||||
|
||||
interface SpeakerFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function parseIpv4FromEndpoint(ep: string): string {
|
||||
try {
|
||||
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`)
|
||||
const host = u.hostname
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string): string {
|
||||
const meta: Record<string, string> = {}
|
||||
if (agentDomain.trim()) meta.agent_domain = agentDomain.trim()
|
||||
if (nodeIpv4.trim()) meta.node_ipv4 = nodeIpv4.trim()
|
||||
if (bgpSource.trim()) meta.bird_bgp_source_ipv4 = bgpSource.trim()
|
||||
return JSON.stringify(meta)
|
||||
}
|
||||
|
||||
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
|
||||
const createMutation = useCreateSpeakerMutation()
|
||||
|
||||
const [endpoint, setEndpoint] = useState('')
|
||||
const [role, setRole] = useState('replica')
|
||||
const [agentDomain, setAgentDomain] = useState('')
|
||||
const [nodeIpv4, setNodeIpv4] = useState('')
|
||||
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
|
||||
const [bgpSourceManual, setBgpSourceManual] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setEndpoint('')
|
||||
setRole('replica')
|
||||
setAgentDomain('')
|
||||
setNodeIpv4('')
|
||||
setBgpSourceIpv4('')
|
||||
setBgpSourceManual(false)
|
||||
}, [open])
|
||||
|
||||
function handleEndpointChange(value: string) {
|
||||
setEndpoint(value)
|
||||
const ip = parseIpv4FromEndpoint(value)
|
||||
if (ip && !nodeIpv4) {
|
||||
handleNodeIpv4Change(ip)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeIpv4Change(value: string) {
|
||||
setNodeIpv4(value)
|
||||
if (!bgpSourceManual) {
|
||||
setBgpSourceIpv4(value)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const ep =
|
||||
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
|
||||
if (!ep) {
|
||||
toast.error('Укажите endpoint или agent domain')
|
||||
return
|
||||
}
|
||||
const body: BgpSpeakerCreate = {
|
||||
endpoint: ep,
|
||||
role: role.trim() || 'replica',
|
||||
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync(body)
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Новый спикер</DialogTitle>
|
||||
<DialogDescription>BIRD-агент на ноде реплики или control plane</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-endpoint">Endpoint</Label>
|
||||
<Input
|
||||
id="speaker-endpoint"
|
||||
placeholder="https://node.example.com:8443"
|
||||
value={endpoint}
|
||||
onChange={(e) => handleEndpointChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="speaker-role"
|
||||
label="Роль"
|
||||
items={[
|
||||
{ value: 'replica', label: 'replica' },
|
||||
{ value: 'master', label: 'master (CP)' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-agent-domain">Agent domain</Label>
|
||||
<Input
|
||||
id="speaker-agent-domain"
|
||||
placeholder="bird-agent.example.com"
|
||||
value={agentDomain}
|
||||
onChange={(e) => setAgentDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-node-ipv4">Node IPv4</Label>
|
||||
<Input
|
||||
id="speaker-node-ipv4"
|
||||
placeholder="203.0.113.10"
|
||||
value={nodeIpv4}
|
||||
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
|
||||
<Input
|
||||
id="speaker-bgp-source"
|
||||
placeholder="203.0.113.10"
|
||||
value={bgpSourceIpv4}
|
||||
onChange={(e) => {
|
||||
setBgpSourceManual(true)
|
||||
setBgpSourceIpv4(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import type { JobRow } from '@/types/api'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
function StatusBadgeColored({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === 'succeeded'
|
||||
? 'text-success'
|
||||
: status === 'failed' || status === 'cancelled'
|
||||
? 'text-destructive'
|
||||
: 'text-info'
|
||||
return <span className={`text-sm font-medium ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
export function OperationsJobsGrid({
|
||||
items,
|
||||
nameById,
|
||||
qc,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: JobRow[]
|
||||
nameById: Map<string, string>
|
||||
qc: QueryClient
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
|
||||
onSuccess: () => {
|
||||
toast.success('Задача отменена')
|
||||
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
|
||||
})
|
||||
|
||||
const columns = useMemo<ColumnDef<JobRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'kind',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{row.original.kind}</span>
|
||||
{row.original.meta?.module_id ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{nameById.get(String(row.original.meta.module_id)) ??
|
||||
String(row.original.meta.module_id)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Вид' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadgeColored status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'created_at',
|
||||
accessorFn: (row) => row.created_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.original.created_at
|
||||
? new Date(row.original.created_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
},
|
||||
{
|
||||
id: 'finished_at',
|
||||
accessorFn: (row) => row.finished_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.original.finished_at
|
||||
? new Date(row.original.finished_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Завершена' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) =>
|
||||
row.original.status === 'running' || row.original.status === 'queued' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
onClick={() => cancelMutation.mutate(row.original.job_id)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
[cancelMutation, nameById],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => {
|
||||
const moduleName = row.meta?.module_id
|
||||
? (nameById.get(String(row.meta.module_id)) ?? String(row.meta.module_id))
|
||||
: ''
|
||||
return `${row.kind} ${row.status} ${row.job_id} ${moduleName}`
|
||||
},
|
||||
getRowId: (row) => row.job_id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск задач…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import type { RevisionRow } from '@/types/api'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export function OperationsRevisionsGrid({
|
||||
items,
|
||||
qc,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: RevisionRow[]
|
||||
qc: QueryClient
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const rollbackMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
|
||||
onSuccess: () => {
|
||||
toast.success('Откат выполнен')
|
||||
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
|
||||
})
|
||||
|
||||
const columns = useMemo<ColumnDef<RevisionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'id',
|
||||
accessorFn: (row) => row.id,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.id.slice(0, 12)}…</span>
|
||||
),
|
||||
meta: { headerTitle: 'ID' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'materialized_prefix_count',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Префиксов" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{row.original.materialized_prefix_count}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Префиксов' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" type="button" className="text-destructive">
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
|
||||
confirmLabel="Откатить"
|
||||
destructive
|
||||
onConfirm={() => rollbackMutation.mutate(row.original.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[rollbackMutation],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.id} ${row.materialized_prefix_count}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ревизий"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск ревизий…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertCircle, RefreshCwIcon } from 'lucide-react'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { EmptyState } from './empty-state'
|
||||
|
||||
interface QueryStateProps<T> {
|
||||
data: T | undefined
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error?: unknown
|
||||
empty?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
onRetry?: () => void
|
||||
skeleton?: ReactNode
|
||||
children: (data: T) => ReactNode
|
||||
}
|
||||
|
||||
export function QueryState<T>({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
empty,
|
||||
emptyTitle = 'Нет данных',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
onRetry,
|
||||
skeleton,
|
||||
children,
|
||||
}: QueryStateProps<T>) {
|
||||
if (isLoading) {
|
||||
return <>{skeleton ?? <DefaultSkeleton />}</>
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<AlertCircle className="size-8" />}
|
||||
title="Ошибка загрузки"
|
||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Повторить
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (empty || data == null) {
|
||||
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
}
|
||||
return <>{children(data)}</>
|
||||
}
|
||||
|
||||
function DefaultSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client"
|
||||
|
||||
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
|
||||
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
|
||||
|
||||
const inputVariants = cva(
|
||||
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
|
||||
default:
|
||||
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
|
||||
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Autocomplete = AutocompletePrimitive.Root
|
||||
|
||||
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteInput({
|
||||
className,
|
||||
size = "default",
|
||||
showClear = false,
|
||||
showTrigger = false,
|
||||
...props
|
||||
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
|
||||
VariantProps<typeof inputVariants> & {
|
||||
showClear?: boolean
|
||||
showTrigger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<AutocompletePrimitive.Input
|
||||
data-slot="autocomplete-input"
|
||||
data-size={size}
|
||||
className={cn(inputVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
{showTrigger && <AutocompleteTrigger />}
|
||||
{showClear && <AutocompleteClear />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteStatus({
|
||||
className,
|
||||
...props
|
||||
}: AutocompletePrimitive.Status.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Status
|
||||
data-slot="autocomplete-status"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteBackdrop({
|
||||
...props
|
||||
}: AutocompletePrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Backdrop
|
||||
data-slot="autocomplete-backdrop"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompletePositioner({
|
||||
className,
|
||||
...props
|
||||
}: AutocompletePrimitive.Positioner.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Positioner
|
||||
data-slot="autocomplete-positioner"
|
||||
className={cn("z-50 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteList({
|
||||
className,
|
||||
scrollAreaClassName,
|
||||
...props
|
||||
}: AutocompletePrimitive.List.Props & {
|
||||
scrollAreaClassName?: string
|
||||
scrollFade?: boolean
|
||||
scrollbarGutter?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
|
||||
scrollAreaClassName
|
||||
)}
|
||||
>
|
||||
<AutocompletePrimitive.List
|
||||
data-slot="autocomplete-list"
|
||||
className={cn(
|
||||
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteCollection({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Collection
|
||||
data-slot="autocomplete-collection"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteRow({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Row
|
||||
data-slot="autocomplete-row"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Item
|
||||
data-slot="autocomplete-item"
|
||||
className={cn(
|
||||
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AutocompleteContentProps extends React.ComponentProps<
|
||||
typeof AutocompletePrimitive.Popup
|
||||
> {
|
||||
align?: AutocompletePrimitive.Positioner.Props["align"]
|
||||
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
|
||||
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
|
||||
side?: AutocompletePrimitive.Positioner.Props["side"]
|
||||
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
|
||||
showBackdrop?: boolean
|
||||
}
|
||||
|
||||
function AutocompleteContent({
|
||||
className,
|
||||
children,
|
||||
showBackdrop = false,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
anchor,
|
||||
...props
|
||||
}: AutocompleteContentProps) {
|
||||
return (
|
||||
<AutocompletePortal>
|
||||
{showBackdrop && <AutocompleteBackdrop />}
|
||||
<AutocompletePositioner
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
anchor={anchor}
|
||||
>
|
||||
<div className="relative flex max-h-full">
|
||||
<AutocompletePrimitive.Popup
|
||||
data-slot="autocomplete-popup"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</AutocompletePrimitive.Popup>
|
||||
</div>
|
||||
</AutocompletePositioner>
|
||||
</AutocompletePortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteGroupLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
|
||||
return (
|
||||
<AutocompletePrimitive.GroupLabel
|
||||
data-slot="autocomplete-group-label"
|
||||
className={cn(
|
||||
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Empty
|
||||
data-slot="autocomplete-empty"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteClear({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Clear
|
||||
data-slot="autocomplete-clear"
|
||||
className={cn(
|
||||
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</AutocompletePrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Trigger
|
||||
data-slot="autocomplete-trigger"
|
||||
className={cn(
|
||||
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4 opacity-70" />
|
||||
</AutocompletePrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteArrow({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Separator
|
||||
data-slot="autocomplete-separator"
|
||||
className={cn(
|
||||
"bg-border my-1.5 h-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Autocomplete,
|
||||
AutocompleteValue,
|
||||
AutocompleteTrigger,
|
||||
AutocompleteInput,
|
||||
AutocompleteStatus,
|
||||
AutocompletePortal,
|
||||
AutocompleteBackdrop,
|
||||
AutocompletePositioner,
|
||||
AutocompleteContent,
|
||||
AutocompleteList,
|
||||
AutocompleteCollection,
|
||||
AutocompleteRow,
|
||||
AutocompleteItem,
|
||||
AutocompleteGroup,
|
||||
AutocompleteGroupLabel,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteClear,
|
||||
AutocompleteArrow,
|
||||
AutocompleteSeparator,
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
[
|
||||
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground",
|
||||
outline: "border-border bg-transparent dark:bg-input/32",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
info: "bg-info text-white",
|
||||
success: "bg-success text-white",
|
||||
warning: "bg-warning text-white",
|
||||
destructive: "bg-destructive text-white",
|
||||
focus: "bg-focus text-focus-foreground",
|
||||
invert: "bg-invert text-invert-foreground",
|
||||
"primary-light":
|
||||
"border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
|
||||
"warning-light":
|
||||
"border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
|
||||
"success-light":
|
||||
"border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
|
||||
"info-light":
|
||||
"border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
|
||||
"destructive-light":
|
||||
"border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
|
||||
"invert-light":
|
||||
"border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
|
||||
"focus-light":
|
||||
"border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
|
||||
"primary-outline":
|
||||
"bg-background border-border text-primary dark:bg-input/30",
|
||||
"warning-outline":
|
||||
"bg-background border-border text-warning-foreground dark:bg-input/30",
|
||||
"success-outline":
|
||||
"bg-background border-border text-success-foreground dark:bg-input/30",
|
||||
"info-outline":
|
||||
"bg-background border-border text-info-foreground dark:bg-input/30",
|
||||
"destructive-outline":
|
||||
"bg-background border-border text-destructive-foreground dark:bg-input/30",
|
||||
"invert-outline":
|
||||
"bg-background border-border text-invert-foreground dark:bg-input/30",
|
||||
"focus-outline":
|
||||
"bg-background border-border text-focus-foreground dark:bg-input/30",
|
||||
},
|
||||
size: {
|
||||
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
|
||||
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
|
||||
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
|
||||
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
|
||||
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
|
||||
},
|
||||
/** `default`: active style radius. `full`: pill radius. */
|
||||
radius: {
|
||||
default:
|
||||
"rounded-sm",
|
||||
full: "rounded-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
radius: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface BadgeProps extends useRender.ComponentProps<"span"> {
|
||||
variant?: VariantProps<typeof badgeVariants>["variant"]
|
||||
size?: VariantProps<typeof badgeVariants>["size"]
|
||||
radius?: VariantProps<typeof badgeVariants>["radius"]
|
||||
}
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
radius,
|
||||
render,
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const defaultProps = {
|
||||
"data-slot": "badge",
|
||||
className: cn(badgeVariants({ variant, size, radius, className })),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
render,
|
||||
props: mergeProps<"span">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants, type BadgeProps }
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}
|
||||
|
||||
function DataGridColumnFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchQuery) return options
|
||||
return options.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}, [options, searchQuery])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<CirclePlusIcon className="size-4" />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder={title}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
No results found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{filteredOptions.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<div className="bg-border -mx-1 my-1 h-px" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
onClick={() => column?.setFilterValue(undefined)}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
Clear filters
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnFilter, type DataGridColumnFilterProps }
|
||||
@@ -0,0 +1,343 @@
|
||||
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
useDataGrid,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnHeaderProps<
|
||||
TData,
|
||||
TValue,
|
||||
> extends HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
pinnable?: boolean
|
||||
filter?: ReactNode
|
||||
visibility?: boolean
|
||||
}
|
||||
|
||||
function DataGridColumnHeaderInner<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
icon,
|
||||
className,
|
||||
filter,
|
||||
visibility = false,
|
||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||
const { isLoading, table, props, recordCount } = useDataGrid()
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
const columnOrder = table.getState().columnOrder
|
||||
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
|
||||
const isSorted = column.getIsSorted()
|
||||
const isPinned = column.getIsPinned()
|
||||
const canSort = column.getCanSort()
|
||||
const canPin = column.getCanPin()
|
||||
const canResize = column.getCanResize()
|
||||
|
||||
const columnIndex = columnOrder.indexOf(column.id)
|
||||
const canMoveLeft = columnIndex > 0
|
||||
const canMoveRight = columnIndex < columnOrder.length - 1
|
||||
|
||||
const handleSort = () => {
|
||||
if (isSorted === "asc") {
|
||||
column.toggleSorting(true)
|
||||
} else if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const headerLabelClassName = cn(
|
||||
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
|
||||
className
|
||||
)
|
||||
|
||||
const headerButtonClassName = cn(
|
||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
|
||||
className
|
||||
)
|
||||
|
||||
const sortIcon =
|
||||
canSort &&
|
||||
(isSorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3.25" />
|
||||
) : isSorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.25" />
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" />
|
||||
))
|
||||
|
||||
const hasControls =
|
||||
props.tableLayout?.columnsMovable ||
|
||||
(props.tableLayout?.columnsVisibility && visibility) ||
|
||||
(props.tableLayout?.columnsPinnable && canPin) ||
|
||||
filter
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items: ReactNode[] = []
|
||||
let hasPreviousSection = false
|
||||
|
||||
// Filter section
|
||||
if (filter) {
|
||||
items.push(
|
||||
<DropdownMenuGroup key="group-filter">
|
||||
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Sort section
|
||||
if (canSort) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-sort" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="sort-asc"
|
||||
onClick={() => {
|
||||
if (isSorted === "asc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5!" />
|
||||
<span className="grow">Asc</span>
|
||||
{isSorted === "asc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="sort-desc"
|
||||
onClick={() => {
|
||||
if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(true)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5!" />
|
||||
<span className="grow">Desc</span>
|
||||
{isSorted === "desc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Pin section
|
||||
if (props.tableLayout?.columnsPinnable && canPin) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-pin" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="pin-left"
|
||||
onClick={() => column.pin(isPinned === "left" ? false : "left")}
|
||||
>
|
||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to left</span>
|
||||
{isPinned === "left" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="pin-right"
|
||||
onClick={() => column.pin(isPinned === "right" ? false : "right")}
|
||||
>
|
||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to right</span>
|
||||
{isPinned === "right" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Move section
|
||||
if (props.tableLayout?.columnsMovable) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-move" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="move-left"
|
||||
onClick={() => {
|
||||
if (columnIndex > 0) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex - 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
}}
|
||||
disabled={!canMoveLeft || isPinned !== false}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Left</span>
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="move-right"
|
||||
onClick={() => {
|
||||
if (columnIndex < columnOrder.length - 1) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex + 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
}}
|
||||
disabled={!canMoveRight || isPinned !== false}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Right</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Visibility section
|
||||
if (props.tableLayout?.columnsVisibility && visibility) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-visibility" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuSub key="visibility">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Settings2Icon className="size-3.5!" />
|
||||
<span>Columns</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent side="right">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((col) => col.getCanHide())
|
||||
.map((col) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.id}
|
||||
checked={col.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => col.toggleVisibility(!!value)}
|
||||
className="capitalize"
|
||||
>
|
||||
{getColumnHeaderLabel(col)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
filter,
|
||||
canSort,
|
||||
isSorted,
|
||||
column,
|
||||
props.tableLayout?.columnsPinnable,
|
||||
props.tableLayout?.columnsMovable,
|
||||
props.tableLayout?.columnsVisibility,
|
||||
canPin,
|
||||
isPinned,
|
||||
canMoveLeft,
|
||||
canMoveRight,
|
||||
visibility,
|
||||
table,
|
||||
columnIndex,
|
||||
columnOrder,
|
||||
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
||||
])
|
||||
|
||||
if (hasControls) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-between gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent className="w-40" align="start">
|
||||
{menuItems}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="-me-1 size-7 rounded-md"
|
||||
onClick={() => column.pin(false)}
|
||||
aria-label={`Unpin ${resolvedTitle} column`}
|
||||
title={`Unpin ${resolvedTitle} column`}
|
||||
>
|
||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
onClick={handleSort}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={headerLabelClassName}>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DataGridColumnHeader = memo(
|
||||
DataGridColumnHeaderInner
|
||||
) as typeof DataGridColumnHeaderInner
|
||||
|
||||
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { ReactElement } from "react"
|
||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
||||
import { Table } from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
|
||||
function DataGridColumnVisibility<TData>({
|
||||
table,
|
||||
trigger,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
trigger: ReactElement<Record<string, unknown>>
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={trigger} />
|
||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-medium">
|
||||
Toggle Columns
|
||||
</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{getColumnHeaderLabel(column)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnVisibility }
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, { ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
SelectMenu,
|
||||
} from "@/components/select-field"
|
||||
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
interface DataGridPaginationProps {
|
||||
sizes?: number[]
|
||||
sizesInfo?: string
|
||||
sizesLabel?: string
|
||||
sizesDescription?: string
|
||||
sizesSkeleton?: ReactNode
|
||||
more?: boolean
|
||||
moreLimit?: number
|
||||
info?: string
|
||||
infoSkeleton?: ReactNode
|
||||
className?: string
|
||||
rowsPerPageLabel?: string
|
||||
previousPageLabel?: string
|
||||
nextPageLabel?: string
|
||||
ellipsisText?: string
|
||||
}
|
||||
|
||||
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
const { table, recordCount, isLoading } = useDataGrid()
|
||||
|
||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesLabel: "Show",
|
||||
sizesDescription: "per page",
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
more: false,
|
||||
info: "{from} - {to} of {count}",
|
||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||
rowsPerPageLabel: "Rows per page",
|
||||
previousPageLabel: "Go to previous page",
|
||||
nextPageLabel: "Go to next page",
|
||||
ellipsisText: "...",
|
||||
}
|
||||
|
||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
||||
|
||||
const btnBaseClasses = "size-7 p-0 text-sm"
|
||||
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
|
||||
const pageIndex = table.getState().pagination.pageIndex
|
||||
const pageSize = table.getState().pagination.pageSize
|
||||
const from = pageIndex * pageSize + 1
|
||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||
const pageCount = table.getPageCount()
|
||||
|
||||
// Replace placeholders in paginationInfo
|
||||
const paginationInfo = mergedProps?.info
|
||||
? mergedProps.info
|
||||
.replace("{from}", from.toString())
|
||||
.replace("{to}", to.toString())
|
||||
.replace("{count}", recordCount.toString())
|
||||
: `${from} - ${to} of ${recordCount}`
|
||||
|
||||
// Pagination limit logic
|
||||
const paginationMoreLimit = mergedProps?.moreLimit || 5
|
||||
|
||||
// Determine the start and end of the pagination group
|
||||
const currentGroupStart =
|
||||
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
|
||||
const currentGroupEnd = Math.min(
|
||||
currentGroupStart + paginationMoreLimit,
|
||||
pageCount
|
||||
)
|
||||
|
||||
// Render page buttons based on the current group
|
||||
const renderPageButtons = () => {
|
||||
const buttons = []
|
||||
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key={i}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
||||
"bg-accent text-accent-foreground": pageIndex === i,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (pageIndex !== i) {
|
||||
table.setPageIndex(i)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
// Render a "previous" ellipsis button if there are previous pages to show
|
||||
const renderEllipsisPrevButton = () => {
|
||||
if (currentGroupStart > 0) {
|
||||
return (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Render a "next" ellipsis button if there are more pages to show after the current group
|
||||
const renderEllipsisNextButton = () => {
|
||||
if (currentGroupEnd < pageCount) {
|
||||
return (
|
||||
<Button
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid-pagination"
|
||||
className={cn(
|
||||
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
|
||||
mergedProps?.className
|
||||
)}
|
||||
>
|
||||
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.sizesSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{mergedProps.rowsPerPageLabel}
|
||||
</div>
|
||||
<SelectMenu
|
||||
items={
|
||||
mergedProps?.sizes?.map((size: number) => ({
|
||||
value: `${size}`,
|
||||
label: `${size}`,
|
||||
})) ?? []
|
||||
}
|
||||
value={`${pageSize}`}
|
||||
triggerClassName="w-14"
|
||||
size="sm"
|
||||
side="top"
|
||||
contentClassName="min-w-18"
|
||||
onValueChange={(value) => {
|
||||
if (!value) return
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.infoSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
|
||||
{paginationInfo}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="order-1 flex items-center space-x-1 sm:order-2">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{mergedProps.previousPageLabel}
|
||||
</span>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
{renderEllipsisPrevButton()}
|
||||
|
||||
{renderPageButtons()}
|
||||
|
||||
{renderEllipsisNextButton()}
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">{mergedProps.nextPageLabel}</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridPagination, type DataGridPaginationProps }
|
||||
@@ -0,0 +1,421 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
PointerEvent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
const MIN_THUMB_SIZE = 24
|
||||
const FALLBACK_SCROLLBAR_SIZE = 12
|
||||
|
||||
const INITIAL_METRICS = {
|
||||
hasVerticalOverflow: false,
|
||||
headerHeight: 0,
|
||||
horizontalScrollbarSize: 0,
|
||||
thumbHeight: 0,
|
||||
thumbTop: 0,
|
||||
trackHeight: 0,
|
||||
} as const
|
||||
|
||||
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
|
||||
|
||||
type ScrollbarMetrics = {
|
||||
hasVerticalOverflow: boolean
|
||||
headerHeight: number
|
||||
horizontalScrollbarSize: number
|
||||
thumbHeight: number
|
||||
thumbTop: number
|
||||
trackHeight: number
|
||||
}
|
||||
|
||||
type ObservedElements = {
|
||||
header: HTMLElement | null
|
||||
horizontalScrollbar: HTMLElement | null
|
||||
table: HTMLElement | null
|
||||
tableViewport: HTMLElement | null
|
||||
}
|
||||
|
||||
type DataGridScrollAreaProps = Omit<
|
||||
ScrollAreaPrimitive.Root.Props,
|
||||
"children"
|
||||
> & {
|
||||
children: ReactNode
|
||||
orientation?: DataGridScrollAreaOrientation
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
|
||||
return (
|
||||
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
|
||||
next.headerHeight === prev.headerHeight &&
|
||||
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
|
||||
next.thumbHeight === prev.thumbHeight &&
|
||||
next.thumbTop === prev.thumbTop &&
|
||||
next.trackHeight === prev.trackHeight
|
||||
)
|
||||
}
|
||||
|
||||
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-header-height",
|
||||
`${metrics.headerHeight}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-thumb-height",
|
||||
`${metrics.thumbHeight}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-thumb-top",
|
||||
`${metrics.thumbTop}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-track-height",
|
||||
`${metrics.trackHeight}px`
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridScrollArea({
|
||||
children,
|
||||
className,
|
||||
orientation = "both",
|
||||
...props
|
||||
}: DataGridScrollAreaProps) {
|
||||
const { props: dataGridProps } = useDataGrid()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
startScrollTop: number
|
||||
startY: number
|
||||
} | null>(null)
|
||||
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
|
||||
const observedElementsRef = useRef<ObservedElements>({
|
||||
header: null,
|
||||
horizontalScrollbar: null,
|
||||
table: null,
|
||||
tableViewport: null,
|
||||
})
|
||||
|
||||
const showHorizontal = orientation !== "vertical"
|
||||
const showVertical = orientation !== "horizontal"
|
||||
const usesCustomVerticalScrollbar =
|
||||
showVertical && !!dataGridProps.tableLayout?.headerSticky
|
||||
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
|
||||
useState(false)
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
dragRef.current = null
|
||||
document.body.style.userSelect = ""
|
||||
document.body.style.webkitUserSelect = ""
|
||||
}, [])
|
||||
|
||||
const resetMetrics = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
|
||||
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
||||
applyMetrics(container, INITIAL_METRICS)
|
||||
metricsRef.current = INITIAL_METRICS
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
|
||||
}, [])
|
||||
|
||||
const syncCustomVerticalScrollbar = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!container || !viewport || !usesCustomVerticalScrollbar) {
|
||||
resetMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
const { header, horizontalScrollbar } = observedElementsRef.current
|
||||
const headerHeight = header?.getBoundingClientRect().height ?? 0
|
||||
const viewportHeight = viewport.clientHeight
|
||||
const viewportWidth = viewport.clientWidth
|
||||
const scrollHeight = viewport.scrollHeight
|
||||
const scrollWidth = viewport.scrollWidth
|
||||
const hasHorizontalOverflow =
|
||||
showHorizontal && scrollWidth > viewportWidth + 0.5
|
||||
const horizontalScrollbarSize = hasHorizontalOverflow
|
||||
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
|
||||
: 0
|
||||
const trackHeight = Math.max(
|
||||
0,
|
||||
viewportHeight - headerHeight - horizontalScrollbarSize
|
||||
)
|
||||
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
|
||||
|
||||
let nextMetrics: ScrollbarMetrics
|
||||
|
||||
if (trackHeight === 0 || maxScroll === 0) {
|
||||
nextMetrics = {
|
||||
hasVerticalOverflow: false,
|
||||
headerHeight,
|
||||
horizontalScrollbarSize,
|
||||
thumbHeight: trackHeight,
|
||||
thumbTop: 0,
|
||||
trackHeight,
|
||||
}
|
||||
} else {
|
||||
const bodyContentHeight = Math.max(
|
||||
trackHeight,
|
||||
scrollHeight - headerHeight
|
||||
)
|
||||
const thumbHeight = clamp(
|
||||
trackHeight * (trackHeight / bodyContentHeight),
|
||||
MIN_THUMB_SIZE,
|
||||
trackHeight
|
||||
)
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
const thumbTop =
|
||||
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
|
||||
|
||||
nextMetrics = {
|
||||
hasVerticalOverflow: true,
|
||||
headerHeight,
|
||||
horizontalScrollbarSize,
|
||||
thumbHeight,
|
||||
thumbTop,
|
||||
trackHeight,
|
||||
}
|
||||
}
|
||||
|
||||
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
|
||||
applyMetrics(container, nextMetrics)
|
||||
metricsRef.current = nextMetrics
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) =>
|
||||
prev === nextMetrics.hasVerticalOverflow
|
||||
? prev
|
||||
: nextMetrics.hasVerticalOverflow
|
||||
)
|
||||
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!container || !viewport) return
|
||||
|
||||
if (!usesCustomVerticalScrollbar) {
|
||||
resetMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
observedElementsRef.current = {
|
||||
header: container.querySelector(
|
||||
'[data-slot="data-grid-table"] thead'
|
||||
) as HTMLElement | null,
|
||||
horizontalScrollbar: container.querySelector(
|
||||
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
||||
) as HTMLElement | null,
|
||||
table: container.querySelector(
|
||||
'[data-slot="data-grid-table"]'
|
||||
) as HTMLElement | null,
|
||||
tableViewport: container.querySelector(
|
||||
'[data-slot="data-grid-table-viewport"]'
|
||||
) as HTMLElement | null,
|
||||
}
|
||||
|
||||
let frame = 0
|
||||
|
||||
const scheduleSync = () => {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
||||
}
|
||||
|
||||
scheduleSync()
|
||||
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
||||
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(scheduleSync)
|
||||
|
||||
observer?.observe(viewport)
|
||||
observedElementsRef.current.header &&
|
||||
observer?.observe(observedElementsRef.current.header)
|
||||
observedElementsRef.current.table &&
|
||||
observer?.observe(observedElementsRef.current.table)
|
||||
observedElementsRef.current.tableViewport &&
|
||||
observer?.observe(observedElementsRef.current.tableViewport)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
observer?.disconnect()
|
||||
viewport.removeEventListener("scroll", scheduleSync)
|
||||
clearDragState()
|
||||
}
|
||||
}, [
|
||||
clearDragState,
|
||||
resetMetrics,
|
||||
syncCustomVerticalScrollbar,
|
||||
usesCustomVerticalScrollbar,
|
||||
])
|
||||
|
||||
const scrollToThumbOffset = (nextThumbTop: number) => {
|
||||
const viewport = viewportRef.current
|
||||
const { thumbHeight, trackHeight } = metricsRef.current
|
||||
|
||||
if (!viewport) return
|
||||
|
||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
|
||||
if (maxScroll === 0 || maxThumbTop === 0) {
|
||||
viewport.scrollTop = 0
|
||||
return
|
||||
}
|
||||
|
||||
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
|
||||
viewport.scrollTop = ratio * maxScroll
|
||||
}
|
||||
|
||||
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startScrollTop: viewport.scrollTop,
|
||||
startY: event.clientY,
|
||||
}
|
||||
|
||||
document.body.style.userSelect = "none"
|
||||
document.body.style.webkitUserSelect = "none"
|
||||
}
|
||||
|
||||
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current
|
||||
const dragState = dragRef.current
|
||||
const { thumbHeight, trackHeight } = metricsRef.current
|
||||
|
||||
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||
|
||||
if (maxThumbTop === 0 || maxScroll === 0) return
|
||||
|
||||
const deltaY = event.clientY - dragState.startY
|
||||
const nextScrollTop =
|
||||
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
|
||||
|
||||
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
|
||||
}
|
||||
|
||||
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return
|
||||
clearDragState()
|
||||
}
|
||||
|
||||
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const { thumbHeight } = metricsRef.current
|
||||
|
||||
if (event.target !== event.currentTarget) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const offsetY = event.clientY - rect.top - thumbHeight / 2
|
||||
|
||||
scrollToThumbOffset(offsetY)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="data-grid-scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full"
|
||||
>
|
||||
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Content>
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
{showHorizontal && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
|
||||
{showVertical && !usesCustomVerticalScrollbar && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="vertical"
|
||||
orientation="vertical"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
</ScrollAreaPrimitive.Root>
|
||||
|
||||
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border absolute end-px w-2",
|
||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
||||
"rounded-full"
|
||||
)}
|
||||
onLostPointerCapture={clearDragState}
|
||||
onPointerCancel={handleThumbPointerUp}
|
||||
onPointerDown={handleThumbPointerDown}
|
||||
onPointerMove={handleThumbPointerMove}
|
||||
onPointerUp={handleThumbPointerUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridScrollArea }
|
||||
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
createContext,
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type Modifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { GripHorizontalIcon } from "lucide-react"
|
||||
|
||||
// Context to share sortable listeners from row to handle
|
||||
type SortableContextValue = ReturnType<typeof useSortable>
|
||||
const SortableRowContext = createContext<Pick<
|
||||
SortableContextValue,
|
||||
"attributes" | "listeners"
|
||||
> | null>(null)
|
||||
|
||||
function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
const context = useContext(SortableRowContext)
|
||||
|
||||
if (!context) {
|
||||
// Fallback if context is not available (shouldn't happen in normal usage)
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
||||
const {
|
||||
transform,
|
||||
transition,
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
} = useSortable({
|
||||
id: row.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
position: "relative",
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||
<DataGridTableBodyRow
|
||||
row={row}
|
||||
dndRef={setNodeRef}
|
||||
dndStyle={style}
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
</SortableRowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRows<TData>({
|
||||
handleDragEnd,
|
||||
dataIds,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
dataIds: UniqueIdentifier[]
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingRow) return
|
||||
|
||||
const { body, documentElement } = document
|
||||
const previousBodyCursor = body.style.cursor
|
||||
const previousDocumentCursor = documentElement.style.cursor
|
||||
|
||||
body.style.cursor = "grabbing"
|
||||
documentElement.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
body.style.cursor = previousBodyCursor
|
||||
documentElement.style.cursor = previousDocumentCursor
|
||||
}
|
||||
}, [isDraggingRow])
|
||||
|
||||
const modifiers = useMemo(() => {
|
||||
const restrictToTableContainer: Modifier = ({
|
||||
transform,
|
||||
draggingNodeRect,
|
||||
}) => {
|
||||
if (!tableContainerRef.current || !draggingNodeRect) {
|
||||
return transform
|
||||
}
|
||||
|
||||
const containerRect = tableContainerRef.current.getBoundingClientRect()
|
||||
const { x, y } = transform
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left
|
||||
const maxX = containerRect.right - draggingNodeRect.right
|
||||
const minY = containerRect.top - draggingNodeRect.top
|
||||
const maxY = containerRect.bottom - draggingNodeRect.bottom
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.max(minX, Math.min(maxX, x)),
|
||||
y: Math.max(minY, Math.min(maxY, y)),
|
||||
}
|
||||
}
|
||||
|
||||
return [restrictToVerticalAxis, restrictToTableContainer]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id={useId()}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingRow(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingRow(false)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingRow(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={tableContainerRef}
|
||||
className={
|
||||
isDraggingRow
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return <DataGridTableDndRow row={row} key={row.id} />
|
||||
})}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
Fragment,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
Modifier,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
Cell,
|
||||
flexRender,
|
||||
Header,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
|
||||
function DataGridTableDndHeader<TData>({
|
||||
header,
|
||||
}: {
|
||||
header: Header<TData, unknown>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const { column } = header
|
||||
|
||||
// Check if column ordering is enabled for this column
|
||||
const canOrder =
|
||||
(column.columnDef as { enableColumnOrdering?: boolean })
|
||||
.enableColumnOrdering !== false
|
||||
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: header.column.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
position: "relative",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
whiteSpace: "nowrap",
|
||||
width: props.tableLayout?.columnsResizable
|
||||
? `calc(var(--header-${header.id}-size) * 1px)`
|
||||
: header.column.getSize(),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell
|
||||
header={header}
|
||||
dndStyle={style}
|
||||
dndRef={setNodeRef}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-0.5">
|
||||
{canOrder && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<span className="grow truncate">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</div>
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
||||
const { props } = useDataGrid()
|
||||
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
||||
id: cell.column.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
position: "relative",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
width: props.tableLayout?.columnsResizable
|
||||
? `calc(var(--col-${cell.column.id}-size) * 1px)`
|
||||
: cell.column.getSize(),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDnd<TData>({
|
||||
handleDragEnd,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingColumn) return
|
||||
|
||||
const { body, documentElement } = document
|
||||
const previousBodyCursor = body.style.cursor
|
||||
const previousDocumentCursor = documentElement.style.cursor
|
||||
|
||||
body.style.cursor = "grabbing"
|
||||
documentElement.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
body.style.cursor = previousBodyCursor
|
||||
documentElement.style.cursor = previousDocumentCursor
|
||||
}
|
||||
}, [isDraggingColumn])
|
||||
|
||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
||||
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const edgeOffset = 0
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||
const maxX =
|
||||
containerRect.right -
|
||||
draggingNodeRect.left -
|
||||
draggingNodeRect.width +
|
||||
edgeOffset
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: 0, // Lock vertical movement
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
id={useId()}
|
||||
modifiers={[restrictToTableBounds]}
|
||||
onDragCancel={() => setIsDraggingColumn(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingColumn(false)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingColumn(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={containerRef}
|
||||
className={
|
||||
isDraggingColumn
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
key={header.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<SortableContext
|
||||
key={cell.id}
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<DataGridTableDndCell cell={cell} />
|
||||
</SortableContext>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && (
|
||||
<DataGridTableBodyRowExpandded row={row} />
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDnd }
|
||||
@@ -0,0 +1,490 @@
|
||||
import {
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRenderedRow,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridTableRowSections,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
|
||||
import {
|
||||
useVirtualizer,
|
||||
VirtualItem,
|
||||
Virtualizer,
|
||||
VirtualizerOptions,
|
||||
} from "@tanstack/react-virtual"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Spinner } from "@evobgp/ui/components/spinner"
|
||||
|
||||
type DataGridTableVirtualScrollElements = {
|
||||
containerElement: HTMLDivElement | null
|
||||
scrollElement: HTMLElement | null
|
||||
}
|
||||
|
||||
type DataGridTableVirtualizerInstance = Virtualizer<
|
||||
HTMLElement,
|
||||
HTMLTableRowElement
|
||||
>
|
||||
|
||||
type DataGridTableVirtualizerOptions<TData> = Omit<
|
||||
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
||||
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
||||
> & {
|
||||
estimateSize?: (index: number, row: Row<TData>) => number
|
||||
getItemKey?: (index: number, row: Row<TData>) => string | number
|
||||
getScrollElement?: (
|
||||
elements: DataGridTableVirtualScrollElements
|
||||
) => HTMLElement | null
|
||||
}
|
||||
|
||||
interface DataGridTableVirtualProps<TData> {
|
||||
height?: number | string
|
||||
estimateSize?: number
|
||||
overscan?: number
|
||||
footerContent?: ReactNode
|
||||
renderHeader?: boolean
|
||||
onFetchMore?: () => void
|
||||
isFetchingMore?: boolean
|
||||
hasMore?: boolean
|
||||
fetchMoreOffset?: number
|
||||
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
||||
}
|
||||
|
||||
interface VirtualBodyProps<TData> {
|
||||
table: Table<TData>
|
||||
columnCount: number
|
||||
topRows: Row<TData>[]
|
||||
centerRows: Row<TData>[]
|
||||
bottomRows: Row<TData>[]
|
||||
virtualItems: VirtualItem[]
|
||||
totalSize: number
|
||||
isVirtualizationEnabled: boolean
|
||||
isInfiniteMode: boolean
|
||||
isFetchingMore: boolean
|
||||
hasMore?: boolean
|
||||
loadingMoreMessage: ReactNode
|
||||
allRowsLoadedMessage: ReactNode
|
||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||
}
|
||||
|
||||
function DataGridTableVirtualSpacer({
|
||||
columnCount,
|
||||
height,
|
||||
}: {
|
||||
columnCount: number
|
||||
height: number
|
||||
}) {
|
||||
if (height <= 0) return null
|
||||
|
||||
return (
|
||||
<tr aria-hidden="true">
|
||||
<td colSpan={columnCount} style={{ height, padding: 0 }} />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualStatusRow({
|
||||
children,
|
||||
className,
|
||||
columnCount,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
columnCount: number
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columnCount}
|
||||
className={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualBody<TData>({
|
||||
table,
|
||||
columnCount,
|
||||
topRows,
|
||||
centerRows,
|
||||
bottomRows,
|
||||
virtualItems,
|
||||
totalSize,
|
||||
isVirtualizationEnabled,
|
||||
isInfiniteMode,
|
||||
isFetchingMore,
|
||||
hasMore,
|
||||
loadingMoreMessage,
|
||||
allRowsLoadedMessage,
|
||||
measureRowRef,
|
||||
}: VirtualBodyProps<TData>) {
|
||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||
|
||||
if (!totalRows) return <DataGridTableEmpty />
|
||||
|
||||
const hasCenterRows = centerRows.length > 0
|
||||
const showFetchingRow = isInfiniteMode && isFetchingMore
|
||||
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
|
||||
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
|
||||
const leadingSpacerHeight =
|
||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
||||
? (virtualItems[0]?.start ?? 0)
|
||||
: 0
|
||||
const trailingSpacerHeight =
|
||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
||||
? Math.max(
|
||||
0,
|
||||
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
||||
)
|
||||
: 0
|
||||
|
||||
const renderedRows: ReactNode[] = []
|
||||
|
||||
topRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pinnedBoundary={
|
||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (isVirtualizationEnabled) {
|
||||
if (leadingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-start"
|
||||
columnCount={columnCount}
|
||||
height={leadingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
virtualItems.forEach((virtualRow) => {
|
||||
const row = centerRows[virtualRow.index]
|
||||
|
||||
if (!row) return
|
||||
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowRef={measureRowRef}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (trailingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-end"
|
||||
columnCount={columnCount}
|
||||
height={trailingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
centerRows.forEach((row) => {
|
||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
||||
})
|
||||
}
|
||||
|
||||
if (showFetchingRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-loading"
|
||||
columnCount={columnCount}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-4 opacity-60" />
|
||||
{loadingMoreMessage}
|
||||
</div>
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
if (showCompleteRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-complete"
|
||||
columnCount={columnCount}
|
||||
className="py-3 text-xs"
|
||||
>
|
||||
{allRowsLoadedMessage}
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
bottomRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pinnedBoundary={
|
||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
||||
? "bottom"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return <>{renderedRows}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized virtual body: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> element,
|
||||
* so the browser handles width changes without React re-renders.
|
||||
*/
|
||||
const MemoizedVirtualBody = memo(
|
||||
DataGridTableVirtualBody,
|
||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
||||
) as typeof DataGridTableVirtualBody
|
||||
|
||||
function DataGridTableVirtual<TData>({
|
||||
height,
|
||||
estimateSize = 48,
|
||||
overscan = 10,
|
||||
footerContent,
|
||||
renderHeader = true,
|
||||
onFetchMore,
|
||||
isFetchingMore = false,
|
||||
hasMore,
|
||||
fetchMoreOffset = 0,
|
||||
virtualizerOptions,
|
||||
}: DataGridTableVirtualProps<TData>) {
|
||||
const { table, props } = useDataGrid()
|
||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||
table,
|
||||
props.tableLayout?.rowsPinnable
|
||||
)
|
||||
const columnCount =
|
||||
table.getVisibleFlatColumns().length +
|
||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
||||
const isInfiniteMode = typeof onFetchMore === "function"
|
||||
const [viewportElements, setViewportElements] =
|
||||
useState<DataGridTableVirtualScrollElements>({
|
||||
containerElement: null,
|
||||
scrollElement: null,
|
||||
})
|
||||
|
||||
const {
|
||||
estimateSize: customEstimateSize,
|
||||
getItemKey: customGetItemKey,
|
||||
getScrollElement: customGetScrollElement,
|
||||
measureElement: customMeasureElement,
|
||||
overscan: customOverscan,
|
||||
...virtualizerOptionsRest
|
||||
} = virtualizerOptions ?? {}
|
||||
|
||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||
const loadingMoreMessage =
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||
const allRowsLoadedMessage =
|
||||
props.allRowsLoadedMessage || "All records loaded"
|
||||
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
containerElement: node,
|
||||
scrollElement:
|
||||
(node?.closest(
|
||||
'[data-slot="scroll-area-viewport"]'
|
||||
) as HTMLElement | null) ?? node,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const usesExternalScrollArea =
|
||||
viewportElements.scrollElement !== null &&
|
||||
viewportElements.scrollElement !== viewportElements.containerElement
|
||||
|
||||
const resolveScrollElement = useCallback(() => {
|
||||
if (customGetScrollElement) {
|
||||
return customGetScrollElement(viewportElements)
|
||||
}
|
||||
|
||||
return viewportElements.scrollElement
|
||||
}, [customGetScrollElement, viewportElements])
|
||||
|
||||
const resolveItemKey = useCallback(
|
||||
(index: number) => {
|
||||
const row = centerRows[index]
|
||||
|
||||
if (!row) return index
|
||||
|
||||
return customGetItemKey?.(index, row) ?? row.id ?? index
|
||||
},
|
||||
[centerRows, customGetItemKey]
|
||||
)
|
||||
|
||||
const resolveEstimateSize = useCallback(
|
||||
(index: number) => {
|
||||
const row = centerRows[index]
|
||||
|
||||
return row
|
||||
? (customEstimateSize?.(index, row) ?? estimateSize)
|
||||
: estimateSize
|
||||
},
|
||||
[centerRows, customEstimateSize, estimateSize]
|
||||
)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: centerRows.length,
|
||||
getScrollElement: resolveScrollElement,
|
||||
getItemKey: resolveItemKey,
|
||||
estimateSize: resolveEstimateSize,
|
||||
overscan: customOverscan ?? overscan,
|
||||
measureElement: customMeasureElement,
|
||||
...virtualizerOptionsRest,
|
||||
}) as DataGridTableVirtualizerInstance
|
||||
|
||||
const virtualItems = isVirtualizationEnabled
|
||||
? virtualizer.getVirtualItems()
|
||||
: []
|
||||
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
|
||||
const measureRowRef =
|
||||
isVirtualizationEnabled && customMeasureElement
|
||||
? virtualizer.measureElement
|
||||
: undefined
|
||||
const resolvedFetchMoreOffset = useMemo(
|
||||
() => Math.max(0, fetchMoreOffset),
|
||||
[fetchMoreOffset]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isVirtualizationEnabled ||
|
||||
!isInfiniteMode ||
|
||||
hasMore === false ||
|
||||
isFetchingMore
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastItem = virtualItems[virtualItems.length - 1]
|
||||
if (!lastItem) return
|
||||
|
||||
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
||||
onFetchMore?.()
|
||||
}
|
||||
}, [
|
||||
centerRows.length,
|
||||
hasMore,
|
||||
isFetchingMore,
|
||||
isInfiniteMode,
|
||||
isVirtualizationEnabled,
|
||||
onFetchMore,
|
||||
resolvedFetchMoreOffset,
|
||||
virtualItems,
|
||||
])
|
||||
|
||||
return (
|
||||
<DataGridTableViewport
|
||||
viewportRef={handleViewportRef}
|
||||
className={!usesExternalScrollArea ? "block" : undefined}
|
||||
style={
|
||||
usesExternalScrollArea
|
||||
? undefined
|
||||
: { height, overflow: "auto", position: "relative" }
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, hIndex) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={hIndex}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
</DataGridTableHead>
|
||||
)}
|
||||
|
||||
{renderHeader &&
|
||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
<MemoizedVirtualBody
|
||||
table={table}
|
||||
columnCount={columnCount}
|
||||
topRows={topRows}
|
||||
centerRows={centerRows}
|
||||
bottomRows={bottomRows}
|
||||
virtualItems={virtualItems}
|
||||
totalSize={totalSize}
|
||||
isVirtualizationEnabled={isVirtualizationEnabled}
|
||||
isInfiniteMode={isInfiniteMode}
|
||||
isFetchingMore={isFetchingMore}
|
||||
hasMore={hasMore}
|
||||
loadingMoreMessage={loadingMoreMessage}
|
||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
||||
measureRowRef={measureRowRef}
|
||||
/>
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableVirtual }
|
||||
export type {
|
||||
DataGridTableVirtualProps,
|
||||
DataGridTableVirtualScrollElements,
|
||||
DataGridTableVirtualizerOptions,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react"
|
||||
import {
|
||||
Column,
|
||||
ColumnFiltersState,
|
||||
RowData,
|
||||
SortingState,
|
||||
Table,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
headerTitle?: string
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
skeleton?: ReactNode
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
}
|
||||
}
|
||||
|
||||
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
|
||||
export function getColumnHeaderLabel<TData, TValue>(
|
||||
column: Column<TData, TValue>
|
||||
): string {
|
||||
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
|
||||
if (typeof meta?.headerTitle === "string") return meta.headerTitle
|
||||
const defHeader = column.columnDef.header
|
||||
if (typeof defHeader === "string") return defHeader
|
||||
return String(column.id)
|
||||
}
|
||||
|
||||
export type DataGridApiFetchParams = {
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
sorting?: SortingState
|
||||
filters?: ColumnFiltersState
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export type DataGridApiResponse<T> = {
|
||||
data: T[]
|
||||
empty: boolean
|
||||
pagination: {
|
||||
total: number
|
||||
page: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DataGridContextProps<TData extends object> {
|
||||
props: DataGridProps<TData>
|
||||
table: Table<TData>
|
||||
recordCount: number
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export type DataGridRequestParams = {
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
sorting?: SortingState
|
||||
columnFilters?: ColumnFiltersState
|
||||
}
|
||||
|
||||
export interface DataGridProps<TData extends object> {
|
||||
className?: string
|
||||
table?: Table<TData>
|
||||
recordCount: number
|
||||
children?: ReactNode
|
||||
onRowClick?: (row: TData) => void
|
||||
isLoading?: boolean
|
||||
loadingMode?: "skeleton" | "spinner"
|
||||
loadingMessage?: ReactNode | string
|
||||
fetchingMoreMessage?: ReactNode | string
|
||||
allRowsLoadedMessage?: ReactNode | string
|
||||
emptyMessage?: ReactNode | string
|
||||
tableLayout?: {
|
||||
dense?: boolean
|
||||
cellBorder?: boolean
|
||||
rowBorder?: boolean
|
||||
rowRounded?: boolean
|
||||
stripped?: boolean
|
||||
headerBackground?: boolean
|
||||
headerBorder?: boolean
|
||||
headerSticky?: boolean
|
||||
width?: "auto" | "fixed"
|
||||
columnsVisibility?: boolean
|
||||
columnsResizable?: boolean
|
||||
columnsResizeMode?: "onChange" | "onEnd"
|
||||
columnsPinnable?: boolean
|
||||
columnsMovable?: boolean
|
||||
columnsDraggable?: boolean
|
||||
rowsDraggable?: boolean
|
||||
rowsPinnable?: boolean
|
||||
}
|
||||
tableClassNames?: {
|
||||
base?: string
|
||||
header?: string
|
||||
headerRow?: string
|
||||
headerSticky?: string
|
||||
body?: string
|
||||
bodyRow?: string
|
||||
footer?: string
|
||||
edgeCell?: string
|
||||
}
|
||||
}
|
||||
|
||||
const DataGridContext = createContext<
|
||||
|
||||
DataGridContextProps<any> | undefined
|
||||
>(undefined)
|
||||
|
||||
function useDataGrid() {
|
||||
const context = useContext(DataGridContext)
|
||||
if (!context) {
|
||||
throw new Error("useDataGrid must be used within a DataGridProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function DataGridProvider<TData extends object>({
|
||||
children,
|
||||
table,
|
||||
...props
|
||||
}: DataGridProps<TData> & { table: Table<TData> }) {
|
||||
const tableState = table.getState()
|
||||
const resolvedColumnsResizeMode =
|
||||
props.tableLayout?.columnsResizeMode ?? "onEnd"
|
||||
|
||||
// Keep resize mode aligned with the DataGrid contract every render so
|
||||
// consumer-level useReactTable options cannot flip it back between drags.
|
||||
if (props.tableLayout?.columnsResizable) {
|
||||
table.options.columnResizeMode = resolvedColumnsResizeMode
|
||||
}
|
||||
|
||||
// Memoize context value so consumers don't re-render during column resize.
|
||||
// Column sizing state is intentionally excluded from deps -- CSS variables
|
||||
// on the <table> element handle width updates without React re-renders.
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
props,
|
||||
table,
|
||||
recordCount: props.recordCount,
|
||||
isLoading: props.isLoading || false,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
table,
|
||||
props.recordCount,
|
||||
props.isLoading,
|
||||
props.loadingMode,
|
||||
props.loadingMessage,
|
||||
props.fetchingMoreMessage,
|
||||
props.allRowsLoadedMessage,
|
||||
props.emptyMessage,
|
||||
props.onRowClick,
|
||||
props.className,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(props.tableLayout),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(props.tableClassNames),
|
||||
tableState.sorting,
|
||||
tableState.pagination,
|
||||
tableState.columnFilters,
|
||||
tableState.rowSelection,
|
||||
tableState.expanded,
|
||||
tableState.columnVisibility,
|
||||
tableState.columnOrder,
|
||||
tableState.columnPinning,
|
||||
tableState.globalFilter,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<DataGridContext.Provider value={value}>
|
||||
{children}
|
||||
</DataGridContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGrid<TData extends object>({
|
||||
children,
|
||||
table,
|
||||
...props
|
||||
}: DataGridProps<TData>) {
|
||||
const defaultProps: Partial<DataGridProps<TData>> = {
|
||||
loadingMode: "skeleton",
|
||||
tableLayout: {
|
||||
dense: false,
|
||||
cellBorder: false,
|
||||
rowBorder: true,
|
||||
rowRounded: false,
|
||||
stripped: false,
|
||||
headerSticky: false,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
width: "fixed",
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsResizeMode: "onEnd",
|
||||
columnsPinnable: false,
|
||||
columnsMovable: false,
|
||||
columnsDraggable: false,
|
||||
rowsDraggable: false,
|
||||
rowsPinnable: false,
|
||||
},
|
||||
tableClassNames: {
|
||||
base: "",
|
||||
header: "",
|
||||
headerRow: "",
|
||||
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
|
||||
body: "",
|
||||
bodyRow: "",
|
||||
footer: "",
|
||||
edgeCell: "",
|
||||
},
|
||||
}
|
||||
|
||||
const mergedProps: DataGridProps<TData> = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
tableLayout: {
|
||||
...defaultProps.tableLayout,
|
||||
...(props.tableLayout || {}),
|
||||
},
|
||||
tableClassNames: {
|
||||
...defaultProps.tableClassNames,
|
||||
...(props.tableClassNames || {}),
|
||||
},
|
||||
}
|
||||
|
||||
// Ensure table is provided
|
||||
if (!table) {
|
||||
throw new Error('DataGrid requires a "table" prop')
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridProvider table={table} {...mergedProps}>
|
||||
{children}
|
||||
</DataGridProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridContainer({
|
||||
children,
|
||||
className,
|
||||
border = true,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
border?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid"
|
||||
className={cn(
|
||||
"w-full overflow-hidden",
|
||||
border &&
|
||||
"border-border rounded-lg border",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user