Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 44608,
|
"pid": 48400,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||||
"startedAt": 1781240018712
|
"startedAt": 1783060842523
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 разбит по сложности; можно остановиться после базовых и продолжить инкрементально |
|
||||||
@@ -32,23 +32,28 @@ alwaysApply: true
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Web UI (`web/`)
|
## Web UI (`apps/web/` + `packages/ui/`)
|
||||||
|
|
||||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||||
|------------|-------------|------------------|-------|
|
|------------|-------------|------------------|-------|
|
||||||
| Svelte | `/websites/svelte_dev` | ^5.54 | runes, компоненты, реактивность |
|
| React | `/facebook/react` | ^19.2 | hooks, components, JSX |
|
||||||
| SvelteKit | `/sveltejs/kit` | ^2.50 | routing, `load`, adapters, SSR |
|
| 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 |
|
| Vite | `/vitejs/vite/v7.3.1` | ^7.3.1 | dev server, build, plugins |
|
||||||
| TypeScript | `/microsoft/typescript/v5.9.3` | ^5.9.3 | типы, strict, tsconfig |
|
| TypeScript | `/microsoft/typescript/v5.9.3` | ^5.9.3 | типы, strict, tsconfig |
|
||||||
| Tailwind CSS | `/tailwindlabs/tailwindcss.com` | ^4.1 | v4, `@tailwindcss/vite`, утилиты |
|
| Tailwind CSS | `/tailwindlabs/tailwindcss.com` | ^4.1 | v4, `@tailwindcss/vite`, утилиты |
|
||||||
| shadcn-svelte | `/websites/shadcn-svelte` | CLI | примитивы `ui/core`, theming |
|
| shadcn/ui (React) | MCP `plugin-shadcn-shadcn` + https://ui.shadcn.com/docs | base-nova | примитивы `@evobgp/ui/components/*` |
|
||||||
| Bits UI | `/llmstxt/bits-ui_llms_txt` | ^2.17 | headless-примитивы под shadcn |
|
| ReUI | https://reui.io/llms.txt + MCP с `registries: ["@reui"]` | registry | enterprise: data-grid, filters, autocomplete |
|
||||||
| sveltekit-superforms | `/ciscoheat/sveltekit-superforms` | ^2.30 | формы, server actions |
|
| react-hook-form | `/react-hook-form` | ^7.60 | формы, controller |
|
||||||
| Formsnap | `/svecosystem/formsnap` | ^2.0 | доступные поля форм |
|
| Zod | `/websites/zod_dev_v4` | ^3.25 / ^4 (apps/web) | схемы валидации |
|
||||||
| Zod | `/websites/zod_dev_v4` | ^4.4 | схемы валидации |
|
| recharts | `/recharts/recharts` | 3.8.0 | графики через shadcn `Chart` |
|
||||||
| TanStack Table | `/websites/tanstack_table` | table-core ^8.21 | `AppDataTable`, колонки, сортировка |
|
| 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).
|
1. **Контракт HTTP** — `docs/openapi.yaml` (не Context7).
|
||||||
2. **Context7** — синтаксис и API библиотек из таблицы.
|
2. **Context7** — синтаксис и API библиотек из таблицы.
|
||||||
3. **Локальные docs** — `docs/`, `web/README.md`, `AGENTS.md`.
|
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 /golang/go/go1_24_6 net/http ServeMux pattern matching
|
||||||
/docs /websites/pkg_go_dev_github_com_jackc_pgx_v5 pool acquire rows
|
/docs /websites/pkg_go_dev_github_com_jackc_pgx_v5 pool acquire rows
|
||||||
/docs /llmstxt/bird_xmsl_dev_llms_txt filter bgp import
|
/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**, синхронная нумерация.
|
**DEP-03** | MUST | Миграции схемы — пары `.up.sql`/`.down.sql` для **postgres** и **sqlite**, синхронная нумерация.
|
||||||
*Проверка:* `migrations/postgres/`, `migrations/sqlite/`.
|
*Проверка:* `migrations/postgres/`, `migrations/sqlite/`.
|
||||||
|
|
||||||
**DEP-04** | MUST | Web UI-библиотеки — только экосистема shadcn-svelte/bits-ui (см. `web-shadcn.mdc`).
|
**DEP-04** | MUST | Web UI-библиотеки — только экосистема shadcn/ui (React) + ReUI (см. `web-shadcn.mdc`).
|
||||||
*Проверка:* `web/package.json` review.
|
*Проверка:* `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`.
|
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||||
*Проверка:* CI job `bird2`.
|
*Проверка:* 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.
|
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
||||||
|
|
||||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||||
@@ -207,13 +207,13 @@ alwaysApply: true
|
|||||||
| OpenAPI / problem+json | `docs/openapi.yaml`, RFC 9457 |
|
| OpenAPI / problem+json | `docs/openapi.yaml`, RFC 9457 |
|
||||||
| Svelte / Kit | https://svelte.dev/docs , https://kit.svelte.dev/docs |
|
| Svelte / Kit | https://svelte.dev/docs , https://kit.svelte.dev/docs |
|
||||||
| shadcn-svelte | https://shadcn-svelte.com/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 |
|
| Prometheus Go | https://pkg.go.dev/github.com/prometheus/client_golang |
|
||||||
|
|
||||||
**DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией.
|
**DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией.
|
||||||
**DOC-SYNC-02** | NEVER | Устаревшие примеры (Svelte 4 `export let`, deprecated pgx).
|
**DOC-SYNC-02** | NEVER | Устаревшие примеры (Svelte 4 `export let`, deprecated pgx).
|
||||||
**DOC-SYNC-03** | MUST | Конфликт docs: **OpenAPI (HTTP)** → **код** → обзорные `docs/`; `.cursor/plans/` не контракт.
|
**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/...`.
|
**DOC-SYNC-05** | MUST | BIRD — официальная документация BIRD2 + `networking-bird.mdc` + `go test ./internal/birdfmt/...`.
|
||||||
|
|
||||||
Приоритет при сомнениях — **официальные источники**, не блоги и не «память модели».
|
Приоритет при сомнениях — **официальные источники**, не блоги и не «память модели».
|
||||||
@@ -230,7 +230,7 @@ alwaysApply: true
|
|||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./... -race -count=1
|
go test ./... -race -count=1
|
||||||
npx @redocly/cli lint docs/openapi.yaml
|
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)
|
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ alwaysApply: false
|
|||||||
| Параметры BIRD tenant | Global settings: `bird_router_id`, `bird_local_asn`, … (`docs/manual.md`) |
|
| Параметры BIRD tenant | Global settings: `bird_router_id`, `bird_local_asn`, … (`docs/manual.md`) |
|
||||||
| BGP peers | `BGPPeer` + `ParsePeerNeighbor` |
|
| 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
|
## 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-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
|
**DOC-SYNC-09** | MUST | CIDR — https://pkg.go.dev/net/netip ; примеры — RFC 5737, 3849
|
||||||
|
|
||||||
|
|||||||
@@ -1,126 +1,131 @@
|
|||||||
---
|
---
|
||||||
description: EvoBGP WebUI — shadcn-svelte, Svelte 5, слои ui/core|patterns|app
|
description: EvoBGP WebUI — React 19, shadcn/ui (base-nova), ReUI, TanStack Router/Query
|
||||||
globs:
|
globs:
|
||||||
- web/**
|
- apps/web/**
|
||||||
|
- packages/ui/**
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# Web UI — shadcn-svelte
|
# Web UI — React + shadcn/ui + ReUI
|
||||||
|
|
||||||
**Источник правды:** https://shadcn-svelte.com/docs (не React shadcn/ui, не Legacy 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-задачей
|
||||||
|
|
||||||
Общие правила Go/API: `.cursor/rules/engineering.mdc`. Локальная карта: `web/README.md`.
|
Общие правила Go/API: `.cursor/rules/engineering.mdc`. Стек ID: `.cursor/rules/context7-stack.mdc`.
|
||||||
|
|
||||||
## Слои UI
|
## Слои UI
|
||||||
|
|
||||||
| Слой | Путь | Назначение |
|
| Слой | Путь | Назначение |
|
||||||
|------|------|------------|
|
|------|------|------------|
|
||||||
| Примитивы | `src/lib/ui/core/` | shadcn-svelte (только CLI `add`) |
|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||||
| Паттерны | `src/lib/ui/patterns/` | FormField, AppDataTable, ConfirmDialog, EmptyState |
|
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||||
| App chrome | `src/lib/ui/app/` | Layout, PageHeader, `notify` |
|
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
|
||||||
| Legacy | `src/lib/components/ui/` | Re-export; **не добавлять новые файлы** |
|
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
||||||
|
|
||||||
Тема: `src/routes/layout.css`, `src/lib/ui/app/tokens.md`. CLI из `web/`: `npx shadcn-svelte@latest add <component> -y -o`.
|
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
**WEB-01** | MUST | Перед новым UI — проверить https://shadcn-svelte.com/docs/components; использовать компонент, не HTML+CSS с нуля.
|
**WEB-01** | MUST | Перед новым UI — MCP `plugin-shadcn-shadcn`: `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`. Только после — JSX.
|
||||||
*Rationale:* Open Code + единый дизайн.
|
*Rationale:* единый источник правды и API.
|
||||||
*Проверка:* review; нет голых `<button class=…>`.
|
*Проверка:* review; нет самописных примитивов, если есть registry item.
|
||||||
|
|
||||||
**WEB-02** | MUST | Отсутствующий примитив — `npx shadcn-svelte@latest add <component> -y -o` → `src/lib/ui/core/`.
|
**WEB-02** | MUST | Отсутствующий shadcn-примитив — `pnpm dlx shadcn@latest add <component>` (из `apps/web`). ReUI — `pnpm dlx shadcn@latest add @reui/<name>`.
|
||||||
*Rationale:* Distribution через CLI и `components.json`.
|
*Проверка:* файлы в `packages/ui/src/components/` (для shadcn) или `apps/web/src/components/reui/` (для ReUI).
|
||||||
*Проверка:* файлы только в `ui/core`.
|
|
||||||
|
|
||||||
**WEB-03** | NEVER | Альтернативные UI-kit'ы (Material, Vuetify, DaisyUI-only без shadcn-примитива).
|
**WEB-03** | NEVER | Альтернативные UI-kit'ы (Material, Vuetify, Tabler, Bootstrap утилиты).
|
||||||
*Проверка:* `package.json` review.
|
*Проверка:* `apps/web/package.json` review.
|
||||||
|
|
||||||
**WEB-04** | MUST | Комозиция по docs: все sub-компоненты (`DialogHeader`, `TableRow`, `Field`, …).
|
**WEB-04** | MUST | Композиция по docs: использовать под-компоненты (`CardHeader`, `TableRow`, `TabsList`, …).
|
||||||
*Проверка:* сверка со страницей компонента в docs.
|
*Проверка:* сверка с shadcn/ReUI docs.
|
||||||
|
|
||||||
**WEB-05** | MUST | Формы — Formsnap + `sveltekit-superforms`; UI в `ui/patterns/form`, не ad-hoc валидация на странице.
|
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||||
*Проверка:* https://shadcn-svelte.com/docs/components/form
|
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||||
|
|
||||||
**WEB-06** | MUST | Таблицы — Data Table + `@tanstack/table-core`; на страницах — `AppDataTable` из patterns.
|
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
|
||||||
*Проверка:* https://shadcn-svelte.com/docs/components/data-table
|
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
|
||||||
|
|
||||||
**WEB-07** | MUST | Toast — Sonner через `notify` из `ui/app/toast.js`.
|
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||||
*Проверка:* https://shadcn-svelte.com/docs/components/sonner
|
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||||
|
|
||||||
**WEB-08** | MUST | Иконки — `@lucide/svelte` (`components.json` → `iconLibrary: lucide`).
|
**WEB-08** | MUST | Иконки — `lucide-react` (`components.json` → `iconLibrary: lucide`).
|
||||||
*Проверка:* imports.
|
*Проверка:* imports; нет `@tabler/icons-react`, `@lucide/svelte`.
|
||||||
|
|
||||||
**WEB-09** | MUST | Цвета — CSS-переменные `layout.css` и токены `tokens.md`; не hex/rgb на страницах.
|
**WEB-09** | MUST | Цвета — CSS-переменные `globals.css` и ReUI semantic токены (`variant="success"/"info"/"warning"`); не hex/rgb на страницах.
|
||||||
*Проверка:* grep `#[0-9a-f]{3,6}` в `routes/`.
|
*Проверка:* grep `#[0-9a-f]{3,6}` в `apps/web/src/routes/`.
|
||||||
|
|
||||||
**WEB-10** | SHOULD | Кастомизация — правка `ui/core` (Open Code), не `!important` поверх API.
|
**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.
|
*Проверка:* review.
|
||||||
|
|
||||||
**WEB-11** | MUST | `routes/**` — композиция `ui/core` + `ui/patterns` + `ui/app`; не копировать целые примитивы shadcn в route.
|
**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.
|
*Проверка:* review.
|
||||||
|
|
||||||
**WEB-12** | NEVER | Примеры React shadcn/ui или Svelte 4 Legacy без адаптации под https://shadcn-svelte.com/docs/migration/svelte-5
|
**WEB-18** | SHOULD | Повторяемая комбинация core (≥2 раза) — вынести в `apps/web/src/components/`.
|
||||||
*Проверка:* `npm run check`.
|
|
||||||
|
|
||||||
**WEB-13** | MUST | Реактивность — Svelte 5 runes (`$state`, `$derived`, `$effect`); не `export let` для локального state страниц.
|
**WEB-19** | MUST | **После любого изменения `apps/web/**` или `packages/ui/**`** — перед завершением задачи агент **обязан**:
|
||||||
*Проверка:* `npm run check`; Svelte MCP.
|
|
||||||
|
|
||||||
**WEB-14** | SHOULD | Нетривиальный UI — прочитать страницу компонента (props, a11y).
|
|
||||||
*Проверка:* PR description.
|
|
||||||
|
|
||||||
**WEB-15** | MUST | Сомнения — https://shadcn-svelte.com/llms.txt , Svelte MCP, `npm run check`.
|
|
||||||
*Проверка:* локально.
|
|
||||||
|
|
||||||
**WEB-19** | MUST | **После любого изменения `web/**`** — перед завершением задачи агент **обязан** выполнить в `web/`:
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run check
|
pnpm --filter @evobgp/web run typecheck
|
||||||
npm run lint
|
pnpm --filter @evobgp/web run lint
|
||||||
|
pnpm --filter @evobgp/web run build
|
||||||
```
|
```
|
||||||
Если `npm run lint` падает (Prettier) — **сначала** `npx prettier --write <изменённые файлы>` или `npx prettier --write .`, затем снова `npm run check` и `npm run lint`. Не сдавать PR/ответ, пока обе команды не exit 0.
|
Все три команды должны exit 0. Не сдавать PR/ответ, пока все три не пройдут.
|
||||||
*Rationale:* CI job `web` = `check` + `prettier --check`; `svelte-check` не ловит форматирование.
|
*Rationale:* CI job `web` = typecheck + lint + build.
|
||||||
*Проверка:* CI job `web`; pre-commit hook `prettier-web`.
|
*Проверка:* CI job `web`.
|
||||||
|
|
||||||
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из patterns, не `window.confirm`.
|
**WEB-20** | MUST | Роутинг — TanStack Router (file-based `apps/web/src/routes/`); типобезопасные `createFileRoute`, `useSearch`, `Link`. Не `react-router-dom`.
|
||||||
*Проверка:* review.
|
*Проверка:* `tsr generate` в `build`/`typecheck` скриптах.
|
||||||
|
|
||||||
**WEB-17** | MUST | Пустые списки — `EmptyState` из patterns.
|
**WEB-21** | MUST | Data fetching — TanStack Query (`useQuery`, `useMutation`, `queryOptions`); query-key factories в `apps/web/src/queries/`. Mutations invalidate keys, не refetch вручную.
|
||||||
*Проверка:* review.
|
*Проверка:* review `queries/*.ts`.
|
||||||
|
|
||||||
**WEB-18** | SHOULD | Повторяемая комбинация core (≥2 раза) — вынести в `ui/patterns/`.
|
**WEB-22** | MUST | Legacy Svelte — в `web-legacy-svelte/` (archive). Не использовать импорты оттуда в новом коде; только как референс при миграции роутов.
|
||||||
*Проверка:* review.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation Sync (Web)
|
## Documentation Sync (Web)
|
||||||
|
|
||||||
**DOC-SYNC-06** | MUST | UI — первично https://shadcn-svelte.com/docs; при конфликте с блогами/Stack Overflow побеждает официальная страница компонента.
|
**DOC-SYNC-06** | MUST | UI — первично MCP + shadcn/ui docs (React) + ReUI docs (Base UI); при конфликте с блогами/Stack Overflow побеждает официальная страница.
|
||||||
**DOC-SYNC-07** | MUST | Перед `add` — сверить Installation/Theming с `web/components.json` и `src/routes/layout.css`.
|
**DOC-SYNC-07** | MUST | Перед `add` — сверить Installation/Theming с `apps/web/components.json`, `packages/ui/components.json` и `packages/ui/src/styles/globals.css`.
|
||||||
|
|
||||||
Tailwind v4: https://shadcn-svelte.com/docs/migration/tailwind-v4
|
Tailwind v4 + base-nova: https://ui.shadcn.com/docs/migration/tailwind-v4
|
||||||
|
ReUI semantic tokens: https://reui.io/docs/styling
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Enforcement
|
## Enforcement
|
||||||
|
|
||||||
**Обязательный финальный шаг агента при правках `web/**`:** `npm run check` **и** `npm run lint` (см. **WEB-19**). Только `check` недостаточно.
|
**Обязательный финальный шаг агента при правках `apps/web/**` или `packages/ui/**`** (см. **WEB-19**):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd web
|
pnpm --filter @evobgp/web run typecheck
|
||||||
npm run check
|
pnpm --filter @evobgp/web run lint
|
||||||
npm run lint
|
pnpm --filter @evobgp/web run build
|
||||||
# при warn/fail lint:
|
|
||||||
npx prettier --write .
|
|
||||||
npm run check
|
|
||||||
npm run lint
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**PR checklist `web/**`:**
|
**PR checklist `apps/web/**` / `packages/ui/**`:**
|
||||||
- [ ] `npm run check` — exit 0
|
- [ ] `pnpm --filter @evobgp/web run typecheck` — exit 0
|
||||||
- [ ] `npm run lint` (prettier --check) — exit 0
|
- [ ] `pnpm --filter @evobgp/web run lint` — exit 0
|
||||||
- [ ] `ui/core` / `ui/patterns`, не дубли примитивов
|
- [ ] `pnpm --filter @evobgp/web run build` — exit 0
|
||||||
- [ ] Новые примитивы через shadcn CLI
|
- [ ] shadcn-примитивы в `packages/ui/src/components/`, ReUI в `apps/web/src/components/reui/`
|
||||||
|
- [ ] Новые примитивы через shadcn CLI (`@shadcn` или `@reui`)
|
||||||
|
- [ ] Импорты: `@evobgp/ui/components/*` для shadcn, `@/components/reui/*` для ReUI
|
||||||
- [ ] Ссылка на docs компонента (если новый паттерн)
|
- [ ] Ссылка на docs компонента (если новый паттерн)
|
||||||
|
|
||||||
**CI:** job `web` — `npm run check` + `npm run lint`.
|
**CI:** job `web` — `typecheck` + `lint` + `build`.
|
||||||
|
|||||||
+10
-1
@@ -1,10 +1,19 @@
|
|||||||
{
|
{
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"svelte": {
|
"shadcn": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"context7-plugin": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"claude-plugins-official/gopls-lsp": {
|
"claude-plugins-official/gopls-lsp": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
},
|
||||||
|
"claude-plugins-official/typescript-lsp": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"svelte": {
|
||||||
|
"enabled": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ description: Context7 lookup для стека EvoBGP — использоват
|
|||||||
|
|
||||||
## Workflow
|
## 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`.
|
2. Найди строку в таблице `context7-stack.mdc`.
|
||||||
3. Вызови `query-docs` с `libraryId` из таблицы и полным вопросом пользователя.
|
3. Вызови `query-docs` с `libraryId` из таблицы и полным вопросом пользователя.
|
||||||
4. `resolve-library-id` — только если библиотеки нет в таблице или нужна другая major-версия.
|
4. `resolve-library-id` — только если библиотеки нет в таблице или нужна другая major-версия.
|
||||||
@@ -18,14 +18,17 @@ description: Context7 lookup для стека EvoBGP — использоват
|
|||||||
|
|
||||||
| Задача | libraryId |
|
| Задача | libraryId |
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| Svelte 5 runes | `/websites/svelte_dev` |
|
| React 19 hooks | `/facebook/react` |
|
||||||
| SvelteKit load/forms | `/sveltejs/kit` |
|
| TanStack Router | `/tanstack/router` |
|
||||||
| shadcn-svelte компонент | `/websites/shadcn-svelte` |
|
| 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` |
|
| pgx pool/query | `/websites/pkg_go_dev_github_com_jackc_pgx_v5` |
|
||||||
| Go net/http | `/golang/go/go1_24_6` |
|
| Go net/http | `/golang/go/go1_24_6` |
|
||||||
| OpenAPI lint | `/redocly/redocly-cli` |
|
| OpenAPI lint | `/redocly/redocly-cli` |
|
||||||
| BIRD config | `/llmstxt/bird_xmsl_dev_llms_txt` |
|
| BIRD config | `/llmstxt/bird_xmsl_dev_llms_txt` |
|
||||||
| Tailwind v4 | `/tailwindlabs/tailwindcss.com` |
|
| Tailwind v4 | `/tailwindlabs/tailwindcss.com` |
|
||||||
| Zod 4 schema | `/websites/zod_dev_v4` |
|
| Zod schema | `/websites/zod_dev_v4` |
|
||||||
|
| recharts | `/recharts/recharts` |
|
||||||
|
|
||||||
Полный список и версии — в `context7-stack.mdc`.
|
Полный список и версии — в `context7-stack.mdc`.
|
||||||
|
|||||||
+11
-11
@@ -98,9 +98,9 @@ jobs:
|
|||||||
openapi=true
|
openapi=true
|
||||||
go=true
|
go=true
|
||||||
;;
|
;;
|
||||||
web/README.md|web/components.json)
|
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||||
;;
|
;;
|
||||||
web/*)
|
apps/web/*|packages/ui/*|packages/shared/*)
|
||||||
web=true
|
web=true
|
||||||
;;
|
;;
|
||||||
deploy/bird/*)
|
deploy/bird/*)
|
||||||
@@ -126,7 +126,7 @@ jobs:
|
|||||||
docs/*)
|
docs/*)
|
||||||
go=true
|
go=true
|
||||||
;;
|
;;
|
||||||
package.json|package-lock.json|.releaserc.json)
|
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
||||||
full_pipeline=true
|
full_pipeline=true
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -169,16 +169,16 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: npm
|
- name: Enable pnpm via corepack
|
||||||
cache-dependency-path: web/package-lock.json
|
run: corepack enable
|
||||||
- name: npm ci, check, lint
|
- name: pnpm install, typecheck, lint, build
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
cd web
|
pnpm install --frozen-lockfile
|
||||||
npm ci
|
pnpm --filter @evobgp/web run typecheck
|
||||||
npm run check
|
pnpm --filter @evobgp/web run lint
|
||||||
npm run lint
|
pnpm --filter @evobgp/web run build
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
go:
|
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/
|
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)
|
# Generated by deploy/docker/write-bake-override.sh (CI/local bake)
|
||||||
deploy/docker/docker-bake.override.hcl
|
deploy/docker/docker-bake.override.hcl
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
## С чего начать (минимум чтения)
|
## С чего начать (минимум чтения)
|
||||||
|
|
||||||
0. **Инженерные правила** — при изменении кода следовать [.cursor/rules/engineering.mdc](.cursor/rules/engineering.mdc); для `web/` — [.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).
|
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)** — оглавление и роли читателя.
|
1. **[docs/README.md](docs/README.md)** — оглавление и роли читателя.
|
||||||
2. **[docs/architecture.md](docs/architecture.md)** — компоненты `cmd/`, карта `internal/`, потоки данных (одного этого файла обычно достаточно для ориентации).
|
2. **[docs/architecture.md](docs/architecture.md)** — компоненты `cmd/`, карта `internal/`, потоки данных (одного этого файла обычно достаточно для ориентации).
|
||||||
3. Задача-специфично: [docs/api.md](docs/api.md), [docs/access.md](docs/access.md), [web/README.md](web/README.md) — только если меняете API, доступ или фронт.
|
3. Задача-специфично: [docs/api.md](docs/api.md), [docs/access.md](docs/access.md) — только если меняете API или доступ.
|
||||||
|
|
||||||
Источник правды по HTTP-контракту: **[docs/openapi.yaml](docs/openapi.yaml)**. Не дублируйте длинные фрагменты спецификации в ответах — ссылайтесь на путь и тег/операцию.
|
Источник правды по HTTP-контракту: **[docs/openapi.yaml](docs/openapi.yaml)**. Не дублируйте длинные фрагменты спецификации в ответах — ссылайтесь на путь и тег/операцию.
|
||||||
|
|
||||||
@@ -57,14 +57,18 @@
|
|||||||
|
|
||||||
Пользовательская документация в `docs/` — преимущественно на русском. Комментарии и имена в коде — в существующем стиле репозитория.
|
Пользовательская документация в `docs/` — преимущественно на русском. Комментарии и имена в коде — в существующем стиле репозитория.
|
||||||
|
|
||||||
## Svelte / фронтенд
|
## Frontend (React + shadcn/ui + ReUI)
|
||||||
|
|
||||||
При правках `web/**/*.svelte` или Svelte-модулей следуйте [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc) (**WEB-19**): перед завершением задачи **обязательно**:
|
При правках `apps/web/**` или `packages/ui/**` следуйте [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc) (**WEB-19**): перед завершением задачи **обязательно**:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd web
|
pnpm --filter @evobgp/web run typecheck
|
||||||
npm run check
|
pnpm --filter @evobgp/web run lint
|
||||||
npm run lint
|
pnpm --filter @evobgp/web run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Если `lint` падает — `npx prettier --write .` и повторить обе команды. CI job `web` не пропускает без этого.
|
Все три команды должны exit 0. CI job `web` не пропускает без этого.
|
||||||
|
|
||||||
|
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
|
||||||
|
|
||||||
|
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 стека.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$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>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"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,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,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,172 @@
|
|||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Boxes,
|
||||||
|
Network,
|
||||||
|
Cog,
|
||||||
|
ListChecks,
|
||||||
|
Activity,
|
||||||
|
Settings,
|
||||||
|
BookText,
|
||||||
|
KeyRound,
|
||||||
|
ServerCog,
|
||||||
|
} 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: '/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,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,98 @@
|
|||||||
|
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":
|
||||||
|
"bg-primary/10 border-none text-primary dark:bg-primary/20",
|
||||||
|
"warning-light":
|
||||||
|
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20",
|
||||||
|
"success-light":
|
||||||
|
"bg-success/10 border-none text-success-foreground dark:bg-success/20",
|
||||||
|
"info-light":
|
||||||
|
"bg-info/10 border-none text-info-foreground dark:bg-info/20",
|
||||||
|
"destructive-light":
|
||||||
|
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20",
|
||||||
|
"invert-light":
|
||||||
|
"bg-invert/10 border-none text-foreground dark:bg-invert/20",
|
||||||
|
"focus-light":
|
||||||
|
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20",
|
||||||
|
"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`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */
|
||||||
|
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,228 @@
|
|||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@evobgp/ui/components/select"
|
||||||
|
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>
|
||||||
|
<Select
|
||||||
|
items={mergedProps?.sizes?.map((size: number) => ({
|
||||||
|
value: `${size}`,
|
||||||
|
label: `${size}`,
|
||||||
|
}))}
|
||||||
|
value={`${pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newPageSize = Number(value)
|
||||||
|
table.setPageSize(newPageSize)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-14" size="sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top" className="min-w-18">
|
||||||
|
{mergedProps?.sizes?.map((size: number) => (
|
||||||
|
<SelectItem key={size} value={`${size}`}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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
@@ -0,0 +1,258 @@
|
|||||||
|
import { createContext, ReactNode, useContext, useId } from "react"
|
||||||
|
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
|
||||||
|
import { cva, VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import { Label } from "@evobgp/ui/components/label"
|
||||||
|
import { MinusIcon, PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const NumberFieldContext = createContext<{
|
||||||
|
fieldId: string
|
||||||
|
size: "sm" | "default" | "lg"
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const numberFieldGroupVariants = cva(
|
||||||
|
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "h-7 text-sm",
|
||||||
|
default:
|
||||||
|
"h-8 text-sm",
|
||||||
|
lg: "h-9 text-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const numberFieldButtonVariants = cva(
|
||||||
|
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
default:
|
||||||
|
"px-2 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const numberFieldInputVariants = cva(
|
||||||
|
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "px-2 py-0.5",
|
||||||
|
default:
|
||||||
|
"px-2.5 py-1",
|
||||||
|
lg: "px-2.5 py-1.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function NumberField({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Root.Props &
|
||||||
|
VariantProps<typeof numberFieldGroupVariants>) {
|
||||||
|
const generatedId = useId()
|
||||||
|
const fieldId = id ?? generatedId
|
||||||
|
const sizeValue = size ?? "default"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldContext.Provider value={{ fieldId, size: sizeValue }}>
|
||||||
|
<NumberFieldPrimitive.Root
|
||||||
|
className={cn("flex w-full flex-col items-start gap-2", className)}
|
||||||
|
data-size={sizeValue}
|
||||||
|
data-slot="number-field"
|
||||||
|
id={fieldId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</NumberFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldGroup({
|
||||||
|
className,
|
||||||
|
size: sizeProp,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Group.Props &
|
||||||
|
Partial<VariantProps<typeof numberFieldGroupVariants>>) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldGroup must be used within a NumberField component."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const size = sizeProp ?? context.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.Group
|
||||||
|
className={cn(numberFieldGroupVariants({ size }), className)}
|
||||||
|
data-slot="number-field-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldDecrement({
|
||||||
|
className,
|
||||||
|
size: sizeProp,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Decrement.Props &
|
||||||
|
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldDecrement must be used within a NumberField component."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const size = sizeProp ?? context.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.Decrement
|
||||||
|
className={cn(
|
||||||
|
numberFieldButtonVariants({ size }),
|
||||||
|
"rounded-s-lg border-e-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="number-field-decrement"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<MinusIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</NumberFieldPrimitive.Decrement>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldIncrement({
|
||||||
|
className,
|
||||||
|
size: sizeProp,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Increment.Props &
|
||||||
|
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
||||||
|
children?: ReactNode
|
||||||
|
}) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldIncrement must be used within a NumberField component."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const size = sizeProp ?? context.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.Increment
|
||||||
|
className={cn(
|
||||||
|
numberFieldButtonVariants({ size }),
|
||||||
|
"rounded-e-lg border-s-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="number-field-increment"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<PlusIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</NumberFieldPrimitive.Increment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldInput({
|
||||||
|
className,
|
||||||
|
size: sizeProp,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Input.Props &
|
||||||
|
Partial<VariantProps<typeof numberFieldInputVariants>>) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldInput must be used within a NumberField component."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const size = sizeProp ?? context.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.Input
|
||||||
|
className={cn(numberFieldInputVariants({ size }), className)}
|
||||||
|
data-slot="number-field-input"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldScrubArea({
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.ScrubArea.Props & {
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.ScrubArea
|
||||||
|
className={cn("flex cursor-ew-resize", className)}
|
||||||
|
data-slot="number-field-scrub-area"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Label className="cursor-ew-resize" htmlFor={context.fieldId}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
|
||||||
|
<CursorGrowIcon />
|
||||||
|
</NumberFieldPrimitive.ScrubAreaCursor>
|
||||||
|
</NumberFieldPrimitive.ScrubArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CursorGrowIcon(props: React.ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
fill="black"
|
||||||
|
height="14"
|
||||||
|
stroke="white"
|
||||||
|
viewBox="0 0 24 14"
|
||||||
|
width="26"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NumberField,
|
||||||
|
NumberFieldScrubArea,
|
||||||
|
NumberFieldDecrement,
|
||||||
|
NumberFieldIncrement,
|
||||||
|
NumberFieldGroup,
|
||||||
|
NumberFieldInput,
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { ReactElement, ReactNode } from 'react'
|
||||||
|
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
|
|
||||||
|
export interface SectionCardItem {
|
||||||
|
label: ReactNode
|
||||||
|
value: string | number | ReactElement
|
||||||
|
hint?: ReactNode
|
||||||
|
icon?: ReactNode
|
||||||
|
badge?: ReactNode
|
||||||
|
variant?: 'default' | 'warning' | 'destructive'
|
||||||
|
active?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||||
|
default: '',
|
||||||
|
warning: 'border-warning/50',
|
||||||
|
destructive: 'border-destructive/50',
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionGridClass(count: number): string {
|
||||||
|
if (count <= 1) return 'grid-cols-1'
|
||||||
|
if (count === 2) return 'sm:grid-cols-2'
|
||||||
|
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
|
||||||
|
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4'
|
||||||
|
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5'
|
||||||
|
if (count === 6) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6'
|
||||||
|
return 'sm:grid-cols-2 lg:grid-cols-3'
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALUE_VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||||
|
default: '',
|
||||||
|
warning: 'text-warning-foreground',
|
||||||
|
destructive: 'text-destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
|
||||||
|
{items.map((item, idx) => {
|
||||||
|
const clickable = Boolean(item.onClick)
|
||||||
|
const content = (
|
||||||
|
<CardContent className="flex items-start gap-2.5 px-3 py-2.5">
|
||||||
|
{item.icon ? (
|
||||||
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{typeof item.label === 'string' ? (
|
||||||
|
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
|
||||||
|
)}
|
||||||
|
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 text-lg font-semibold tabular-nums',
|
||||||
|
VALUE_VARIANT_CLASS[item.variant ?? 'default'],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
{item.hint ? (
|
||||||
|
typeof item.hint === 'string' ? (
|
||||||
|
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={typeof item.label === 'string' ? item.label : idx}
|
||||||
|
className={cn(
|
||||||
|
'gap-0',
|
||||||
|
VARIANT_CLASS[item.variant ?? 'default'],
|
||||||
|
item.active && 'border-primary ring-1 ring-primary/30',
|
||||||
|
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
|
||||||
|
)}
|
||||||
|
onClick={item.onClick}
|
||||||
|
role={clickable ? 'button' : undefined}
|
||||||
|
tabIndex={clickable ? 0 : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
clickable
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
item.onClick?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { SectionCards } from './section-cards'
|
||||||
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||||
|
|
||||||
|
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<SectionCards
|
||||||
|
items={Array.from({ length: count }, (_, i) => ({
|
||||||
|
icon: <Skeleton className="size-4 rounded-sm" key={`icon-${i}`} />,
|
||||||
|
label: <Skeleton className="h-3 w-20" key={`label-${i}`} />,
|
||||||
|
value: <Skeleton className="h-5 w-16" key={`value-${i}`} />,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||||
|
return (
|
||||||
|
<Card className="gap-0">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex gap-2 border-b p-3">
|
||||||
|
{Array.from({ length: cols }).map((_, i) => (
|
||||||
|
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: rows }).map((_, r) => (
|
||||||
|
<div className="flex gap-2 border-b p-3" key={`r-${r}`}>
|
||||||
|
{Array.from({ length: cols }).map((_, c) => (
|
||||||
|
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
|
||||||
|
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||||
|
active: 'success',
|
||||||
|
ok: 'success',
|
||||||
|
paid: 'success',
|
||||||
|
established: 'success',
|
||||||
|
succeeded: 'success',
|
||||||
|
healthy: 'success',
|
||||||
|
paused: 'secondary',
|
||||||
|
disabled: 'secondary',
|
||||||
|
archived: 'outline',
|
||||||
|
error: 'destructive',
|
||||||
|
failed: 'destructive',
|
||||||
|
running: 'info',
|
||||||
|
queued: 'info',
|
||||||
|
overdue: 'warning',
|
||||||
|
stale: 'warning',
|
||||||
|
warning: 'warning',
|
||||||
|
mismatch: 'warning',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||||
|
const variant = STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
|
||||||
|
return <Badge variant={variant}>{label ?? status}</Badge>
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextThemesProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
storageKey="evobgp-theme"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NextThemesProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@evobgp/ui/components/tooltip'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
|
interface TruncatedTextProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
as?: 'span' | 'p' | 'div'
|
||||||
|
/** Явный текст подсказки, если children — не строка. */
|
||||||
|
tooltip?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TruncatedText({ children, className, as: Tag = 'span', tooltip }: TruncatedTextProps) {
|
||||||
|
const tip =
|
||||||
|
tooltip ??
|
||||||
|
(typeof children === 'string' || typeof children === 'number' ? String(children) : null)
|
||||||
|
|
||||||
|
if (!tip) {
|
||||||
|
return <Tag className={cn('truncate', className)}>{children}</Tag>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={<Tag className={cn('truncate', className)} />}>{children}</TooltipTrigger>
|
||||||
|
<TooltipContent>{tip}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import type {
|
||||||
|
AsEntriesResponse,
|
||||||
|
CdnSourcesResponse,
|
||||||
|
DomainEntriesResponse,
|
||||||
|
IpRangeEntriesResponse,
|
||||||
|
JobRow,
|
||||||
|
ModuleType,
|
||||||
|
RevisionPrefix,
|
||||||
|
RevisionPrefixesResponse,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
|
||||||
|
|
||||||
|
export type Problem = {
|
||||||
|
type?: string
|
||||||
|
title?: string
|
||||||
|
status?: number
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
return window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string | null): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||||
|
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeHeaders(init?: RequestInit, extraHeaders?: Record<string, string>): Headers {
|
||||||
|
const h = new Headers(init?.headers)
|
||||||
|
if (!h.has('Accept')) h.set('Accept', 'application/json')
|
||||||
|
const t = getToken()
|
||||||
|
if (t && !h.has('Authorization')) h.set('Authorization', `Bearer ${t}`)
|
||||||
|
if (extraHeaders) {
|
||||||
|
for (const [k, v] of Object.entries(extraHeaders)) {
|
||||||
|
if (!h.has(k)) h.set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotency keys: `crypto.randomUUID()` exists only in secure contexts (HTTPS / localhost).
|
||||||
|
* Over plain HTTP to a LAN IP it is often undefined — use getRandomValues or a fallback.
|
||||||
|
*/
|
||||||
|
function newIdempotencyKey(): string {
|
||||||
|
const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined
|
||||||
|
if (c?.randomUUID) return c.randomUUID()
|
||||||
|
if (c?.getRandomValues) {
|
||||||
|
const buf = new Uint8Array(16)
|
||||||
|
c.getRandomValues(buf)
|
||||||
|
buf[6] = (buf[6]! & 0x0f) | 0x40
|
||||||
|
buf[8] = (buf[8]! & 0x3f) | 0x80
|
||||||
|
const hex = [...buf].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||||
|
}
|
||||||
|
return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
public readonly problem?: Problem,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
if (typeof window === 'undefined') throw new Error('API is only available in the browser')
|
||||||
|
return fetch(path, { ...init, headers: mergeHeaders(init) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET / DELETE без тела */
|
||||||
|
export async function apiJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await apiFetch(path, init)
|
||||||
|
return parseResponse<T>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */
|
||||||
|
export async function apiMutate<T = void>(
|
||||||
|
path: string,
|
||||||
|
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
||||||
|
body?: unknown,
|
||||||
|
opts?: { idempotent?: boolean },
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||||
|
if (opts?.idempotent !== false) {
|
||||||
|
headers['Idempotency-Key'] = newIdempotencyKey()
|
||||||
|
}
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers: mergeHeaders({ headers }, headers),
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
return parseResponse<T>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseResponse<T>(res: Response): Promise<T> {
|
||||||
|
if (res.status === 204 || res.status === 205) return undefined as T
|
||||||
|
const text = await res.text()
|
||||||
|
if (!res.ok) {
|
||||||
|
let problem: Problem | undefined
|
||||||
|
let detail = `HTTP ${res.status}`
|
||||||
|
try {
|
||||||
|
problem = JSON.parse(text) as Problem
|
||||||
|
detail = problem.detail ?? problem.title ?? detail
|
||||||
|
} catch {
|
||||||
|
if (text) detail = text
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, detail, problem)
|
||||||
|
}
|
||||||
|
if (!text) return undefined as T
|
||||||
|
return JSON.parse(text) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalJobStatuses = new Set(['succeeded', 'failed', 'cancelled'])
|
||||||
|
|
||||||
|
/** Ожидает завершения фоновой задачи (poll GET /v1/jobs/{id}). */
|
||||||
|
export async function waitForJob(
|
||||||
|
jobId: string,
|
||||||
|
opts?: { pollMs?: number; timeoutMs?: number },
|
||||||
|
): Promise<JobRow> {
|
||||||
|
const pollMs = opts?.pollMs ?? 400
|
||||||
|
const timeoutMs = opts?.timeoutMs ?? 120000
|
||||||
|
const deadline = Date.now() + timeoutMs
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const j = await apiJSON<JobRow>(`/v1/jobs/${jobId}`)
|
||||||
|
if (terminalJobStatuses.has(j.status)) return j
|
||||||
|
await new Promise((r) => setTimeout(r, pollMs))
|
||||||
|
}
|
||||||
|
throw new Error(`Таймаут ожидания задачи ${jobId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPageAll<T>(path: string, limit = 500): Promise<T[]> {
|
||||||
|
const items: T[] = []
|
||||||
|
let cursor: string | null = null
|
||||||
|
while (true) {
|
||||||
|
const [basePath, rawQuery = ''] = path.split('?')
|
||||||
|
const query = new URLSearchParams(rawQuery)
|
||||||
|
if (!query.has('limit')) query.set('limit', String(limit))
|
||||||
|
if (cursor) query.set('cursor', cursor)
|
||||||
|
else query.delete('cursor')
|
||||||
|
const page = await apiJSON<{ items?: T[]; next_cursor?: string | null; has_more?: boolean }>(
|
||||||
|
`${basePath}?${query.toString()}`,
|
||||||
|
)
|
||||||
|
items.push(...(page.items ?? []))
|
||||||
|
if (!page.has_more || !page.next_cursor) break
|
||||||
|
cursor = page.next_cursor
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRevisionPrefixesAll(revisionId: string): Promise<RevisionPrefix[]> {
|
||||||
|
const items = await apiPageAll<RevisionPrefix>(`/v1/revisions/${revisionId}/prefixes`)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRevisionPrefixesResponse(
|
||||||
|
revisionId: string,
|
||||||
|
cursor?: string | null,
|
||||||
|
limit = 500,
|
||||||
|
): Promise<RevisionPrefixesResponse> {
|
||||||
|
const query = new URLSearchParams({ limit: String(limit) })
|
||||||
|
if (cursor) query.set('cursor', cursor)
|
||||||
|
return apiJSON<RevisionPrefixesResponse>(`/v1/revisions/${revisionId}/prefixes?${query.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) {
|
||||||
|
if (moduleType === 'DOMAINS') {
|
||||||
|
const entries = await apiPageAll<DomainEntriesResponse['items'][number]>(
|
||||||
|
`/v1/modules/${moduleId}/domain-entries`,
|
||||||
|
)
|
||||||
|
return { domains: entries }
|
||||||
|
}
|
||||||
|
if (moduleType === 'AS_PREFIXES') {
|
||||||
|
const entries = await apiPageAll<AsEntriesResponse['items'][number]>(
|
||||||
|
`/v1/modules/${moduleId}/as-entries`,
|
||||||
|
)
|
||||||
|
return { asns: entries }
|
||||||
|
}
|
||||||
|
if (moduleType === 'CDN_CIDRS') {
|
||||||
|
const entries = await apiPageAll<CdnSourcesResponse['items'][number]>(
|
||||||
|
`/v1/modules/${moduleId}/cdn-sources`,
|
||||||
|
)
|
||||||
|
return { cdnSources: entries }
|
||||||
|
}
|
||||||
|
if (moduleType === 'IP_RANGES') {
|
||||||
|
const entries = await apiPageAll<IpRangeEntriesResponse['items'][number]>(
|
||||||
|
`/v1/modules/${moduleId}/ip-range-entries`,
|
||||||
|
)
|
||||||
|
return { ipRanges: entries }
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
createRouter as tanstackCreateRouter,
|
||||||
|
rootRouteId,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { routeTree } from '../routeTree.gen'
|
||||||
|
import { queryClient } from './queryClient'
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: ReturnType<typeof createRouter>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRouter(opts?: { context?: { queryClient: QueryClient } }) {
|
||||||
|
return tanstackCreateRouter({
|
||||||
|
routeTree,
|
||||||
|
context: opts?.context ?? { queryClient },
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
scrollRestoration: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { rootRouteId }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
|
import { Toaster } from '@evobgp/ui/components/sonner'
|
||||||
|
|
||||||
|
import '@evobgp/ui/globals.css'
|
||||||
|
|
||||||
|
import { queryClient } from '@/lib/queryClient'
|
||||||
|
import { createRouter } from '@/lib/router'
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider'
|
||||||
|
|
||||||
|
const router = createRouter({ context: { queryClient } })
|
||||||
|
|
||||||
|
const rootEl = document.getElementById('root')
|
||||||
|
if (!rootEl) throw new Error('Root element #root not found')
|
||||||
|
|
||||||
|
createRoot(rootEl).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import { apiJSON } from '@/lib/api-client'
|
||||||
|
import type { ApiKey, ApiKeysResponse } from '@/types/api'
|
||||||
|
|
||||||
|
export const apiKeysKeys = {
|
||||||
|
all: ['api-keys'] as const,
|
||||||
|
list: () => [...apiKeysKeys.all, 'list'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiKeysQueryOptions() {
|
||||||
|
return queryOptions<ApiKey[]>({
|
||||||
|
queryKey: apiKeysKeys.list(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const page = await apiJSON<ApiKeysResponse>('/v1/api-keys?limit=500')
|
||||||
|
return page.items ?? []
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import { apiJSON } from '@/lib/api-client'
|
||||||
|
import type { AuthSession } from '@/types/api'
|
||||||
|
|
||||||
|
export const authKeys = {
|
||||||
|
all: ['auth'] as const,
|
||||||
|
session: () => [...authKeys.all, 'session'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authSessionQueryOptions() {
|
||||||
|
return queryOptions<AuthSession>({
|
||||||
|
queryKey: authKeys.session(),
|
||||||
|
queryFn: () => apiJSON<AuthSession>('/v1/auth/session'),
|
||||||
|
retry: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import { apiJSON } from '@/lib/api-client'
|
||||||
|
import type { CommunitiesResponse, DohProfilesResponse } from '@/types/api'
|
||||||
|
|
||||||
|
export const directoriesKeys = {
|
||||||
|
all: ['directories'] as const,
|
||||||
|
communities: () => [...directoriesKeys.all, 'communities'] as const,
|
||||||
|
doh: () => [...directoriesKeys.all, 'doh'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function directoriesCommunitiesQueryOptions() {
|
||||||
|
return queryOptions<CommunitiesResponse>({
|
||||||
|
queryKey: directoriesKeys.communities(),
|
||||||
|
queryFn: () => apiJSON<CommunitiesResponse>('/v1/communities?limit=200'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function directoriesDohQueryOptions() {
|
||||||
|
return queryOptions<DohProfilesResponse>({
|
||||||
|
queryKey: directoriesKeys.doh(),
|
||||||
|
queryFn: () => apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import { apiJSON } from '@/lib/api-client'
|
||||||
|
import type {
|
||||||
|
ModuleRow,
|
||||||
|
ModulesResponse,
|
||||||
|
Page,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
export const modulesKeys = {
|
||||||
|
all: ['modules'] as const,
|
||||||
|
list: () => [...modulesKeys.all, 'list'] as const,
|
||||||
|
detail: (id: string) => [...modulesKeys.all, 'detail', id] as const,
|
||||||
|
domainEntries: (id: string) => [...modulesKeys.all, 'domain-entries', id] as const,
|
||||||
|
asEntries: (id: string) => [...modulesKeys.all, 'as-entries', id] as const,
|
||||||
|
cdnSources: (id: string) => [...modulesKeys.all, 'cdn-sources', id] as const,
|
||||||
|
ipRangeEntries: (id: string) => [...modulesKeys.all, 'ip-range-entries', id] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modulesListQueryOptions() {
|
||||||
|
return queryOptions<ModulesResponse>({
|
||||||
|
queryKey: modulesKeys.list(),
|
||||||
|
queryFn: () => apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleDetailQueryOptions(id: string) {
|
||||||
|
return queryOptions<ModuleRow>({
|
||||||
|
queryKey: modulesKeys.detail(id),
|
||||||
|
queryFn: () => apiJSON<ModuleRow>(`/v1/modules/${id}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModuleEntriesPage = Page<Record<string, unknown>>
|
||||||
|
|
||||||
|
export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
|
||||||
|
const pathByType: Record<ModuleRow['type'], string> = {
|
||||||
|
DOMAINS: `/v1/modules/${id}/domain-entries?limit=500`,
|
||||||
|
AS_PREFIXES: `/v1/modules/${id}/as-entries?limit=500`,
|
||||||
|
CDN_CIDRS: `/v1/modules/${id}/cdn-sources?limit=500`,
|
||||||
|
IP_RANGES: `/v1/modules/${id}/ip-range-entries?limit=500`,
|
||||||
|
}
|
||||||
|
const path = pathByType[type]
|
||||||
|
const keyByType: Record<ModuleRow['type'], readonly string[]> = {
|
||||||
|
DOMAINS: modulesKeys.domainEntries(id),
|
||||||
|
AS_PREFIXES: modulesKeys.asEntries(id),
|
||||||
|
CDN_CIDRS: modulesKeys.cdnSources(id),
|
||||||
|
IP_RANGES: modulesKeys.ipRangeEntries(id),
|
||||||
|
}
|
||||||
|
return queryOptions<ModuleEntriesPage>({
|
||||||
|
queryKey: keyByType[type],
|
||||||
|
queryFn: () => apiJSON<ModuleEntriesPage>(path),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import { apiFetch, apiJSON, apiMutate } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export interface HealthStatus {
|
||||||
|
ok: boolean
|
||||||
|
status?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadyStatus {
|
||||||
|
status?: string
|
||||||
|
checks?: Record<string, boolean | { ok?: boolean; error?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionInfo {
|
||||||
|
version?: string
|
||||||
|
app?: string
|
||||||
|
git_sha?: string
|
||||||
|
build_time?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const monitoringKeys = {
|
||||||
|
all: ['monitoring'] as const,
|
||||||
|
health: () => [...monitoringKeys.all, 'health'] as const,
|
||||||
|
ready: () => [...monitoringKeys.all, 'ready'] as const,
|
||||||
|
version: () => [...monitoringKeys.all, 'version'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHealth(): Promise<HealthStatus> {
|
||||||
|
const res = await apiFetch('/v1/health', { method: 'GET' })
|
||||||
|
let body: { status?: string } = {}
|
||||||
|
try {
|
||||||
|
body = (await res.json()) as { status?: string }
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return { ok: res.ok, status: body.status, error: res.ok ? undefined : `HTTP ${res.status}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monitoringHealthQueryOptions() {
|
||||||
|
return queryOptions<HealthStatus>({
|
||||||
|
queryKey: monitoringKeys.health(),
|
||||||
|
queryFn: fetchHealth,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monitoringReadyQueryOptions() {
|
||||||
|
return queryOptions<ReadyStatus>({
|
||||||
|
queryKey: monitoringKeys.ready(),
|
||||||
|
queryFn: () => apiJSON<ReadyStatus>('/v1/ready'),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monitoringVersionQueryOptions() {
|
||||||
|
return queryOptions<VersionInfo>({
|
||||||
|
queryKey: monitoringKeys.version(),
|
||||||
|
queryFn: () => apiJSON<VersionInfo>('/v1/version'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLog(endpoint: string): Promise<string> {
|
||||||
|
const res = await apiFetch(endpoint, { method: 'GET' })
|
||||||
|
return await res.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearLog(endpoint: string): Promise<void> {
|
||||||
|
await apiMutate(endpoint, 'DELETE', {})
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import { apiJSON } from '@/lib/api-client'
|
||||||
|
import type { BirdStatus, PeersResponse, SpeakersResponse } from '@/types/api'
|
||||||
|
|
||||||
|
export const NETWORK_AUTO_REFRESH_MS = 30_000
|
||||||
|
|
||||||
|
export const networkKeys = {
|
||||||
|
all: ['network'] as const,
|
||||||
|
peers: () => [...networkKeys.all, 'peers'] as const,
|
||||||
|
speakers: () => [...networkKeys.all, 'speakers'] as const,
|
||||||
|
bird: () => [...networkKeys.all, 'bird'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function networkPeersQueryOptions() {
|
||||||
|
return queryOptions<PeersResponse>({
|
||||||
|
queryKey: networkKeys.peers(),
|
||||||
|
queryFn: () => apiJSON<PeersResponse>('/v1/peers?limit=200&live=1'),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function networkSpeakersQueryOptions() {
|
||||||
|
return queryOptions<SpeakersResponse>({
|
||||||
|
queryKey: networkKeys.speakers(),
|
||||||
|
queryFn: () => apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1'),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function networkBirdQueryOptions() {
|
||||||
|
return queryOptions<BirdStatus>({
|
||||||
|
queryKey: networkKeys.bird(),
|
||||||
|
queryFn: () => apiJSON<BirdStatus>('/v1/bird/status'),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import { apiJSON } from '@/lib/api-client'
|
||||||
|
import type { JobsResponse, RevisionsResponse, RevisionDiff } from '@/types/api'
|
||||||
|
|
||||||
|
export const operationsKeys = {
|
||||||
|
all: ['operations'] as const,
|
||||||
|
revisions: () => [...operationsKeys.all, 'revisions'] as const,
|
||||||
|
jobs: (params?: { status?: string; kind?: string }) =>
|
||||||
|
[...operationsKeys.all, 'jobs', params ?? {}] as const,
|
||||||
|
diff: (a: string, b: string) => [...operationsKeys.all, 'diff', a, b] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operationsRevisionsQueryOptions() {
|
||||||
|
return queryOptions<RevisionsResponse>({
|
||||||
|
queryKey: operationsKeys.revisions(),
|
||||||
|
queryFn: () => apiJSON<RevisionsResponse>('/v1/revisions?limit=100'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operationsJobsQueryOptions(params?: { status?: string; kind?: string }) {
|
||||||
|
return queryOptions<JobsResponse>({
|
||||||
|
queryKey: operationsKeys.jobs(params),
|
||||||
|
queryFn: () => {
|
||||||
|
const sp = new URLSearchParams({ limit: '200' })
|
||||||
|
if (params?.status) sp.set('status', params.status)
|
||||||
|
if (params?.kind) sp.set('kind', params.kind)
|
||||||
|
return apiJSON<JobsResponse>(`/v1/jobs?${sp.toString()}`)
|
||||||
|
},
|
||||||
|
staleTime: 10_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operationsDiffQueryOptions(a: string, b: string) {
|
||||||
|
return queryOptions<RevisionDiff>({
|
||||||
|
queryKey: operationsKeys.diff(a, b),
|
||||||
|
queryFn: () => apiJSON<RevisionDiff>(`/v1/revisions/${a}/diff/${b}`),
|
||||||
|
enabled: Boolean(a) && Boolean(b),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import { apiJSON } from '@/lib/api-client'
|
||||||
|
import type {
|
||||||
|
JobRow,
|
||||||
|
JobsResponse,
|
||||||
|
ModuleRow,
|
||||||
|
ModulesResponse,
|
||||||
|
PeerRow,
|
||||||
|
PeersResponse,
|
||||||
|
RevisionRow,
|
||||||
|
RevisionsResponse,
|
||||||
|
SpeakerRow,
|
||||||
|
SpeakersResponse,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
export const overviewKeys = {
|
||||||
|
all: ['overview'] as const,
|
||||||
|
modules: () => [...overviewKeys.all, 'modules'] as const,
|
||||||
|
peers: () => [...overviewKeys.all, 'peers'] as const,
|
||||||
|
speakers: () => [...overviewKeys.all, 'speakers'] as const,
|
||||||
|
revisions: () => [...overviewKeys.all, 'revisions'] as const,
|
||||||
|
jobs: () => [...overviewKeys.all, 'jobs'] as const,
|
||||||
|
health: () => [...overviewKeys.all, 'health'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overviewModulesQueryOptions() {
|
||||||
|
return queryOptions<ModulesResponse>({
|
||||||
|
queryKey: overviewKeys.modules(),
|
||||||
|
queryFn: () => apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overviewPeersQueryOptions() {
|
||||||
|
return queryOptions<PeersResponse>({
|
||||||
|
queryKey: overviewKeys.peers(),
|
||||||
|
queryFn: () => apiJSON<PeersResponse>('/v1/peers?limit=200&live=1'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overviewSpeakersQueryOptions() {
|
||||||
|
return queryOptions<SpeakersResponse>({
|
||||||
|
queryKey: overviewKeys.speakers(),
|
||||||
|
queryFn: () => apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overviewRevisionsQueryOptions() {
|
||||||
|
return queryOptions<RevisionsResponse>({
|
||||||
|
queryKey: overviewKeys.revisions(),
|
||||||
|
queryFn: () => apiJSON<RevisionsResponse>('/v1/revisions?limit=10'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overviewJobsQueryOptions() {
|
||||||
|
return queryOptions<JobsResponse>({
|
||||||
|
queryKey: overviewKeys.jobs(),
|
||||||
|
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=10'),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overviewHealthQueryOptions() {
|
||||||
|
return queryOptions<boolean>({
|
||||||
|
queryKey: overviewKeys.health(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch('/v1/health')
|
||||||
|
return res.ok
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selectors / helpers
|
||||||
|
export type NetworkMetrics = {
|
||||||
|
peersTotal: number
|
||||||
|
peersEnabled: number
|
||||||
|
peersEstablished: number
|
||||||
|
peersMismatch: number
|
||||||
|
speakersTotal: number
|
||||||
|
speakersOnline: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateNetworkMetrics(
|
||||||
|
peers: PeerRow[],
|
||||||
|
speakers: SpeakerRow[],
|
||||||
|
): NetworkMetrics {
|
||||||
|
const peersEnabled = peers.filter((p) => p.enabled !== false).length
|
||||||
|
const peersEstablished = peers.filter((p) => p.session_state === 'Established').length
|
||||||
|
const peersMismatch = peers.filter((p) => p.session_mismatch).length
|
||||||
|
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
|
||||||
|
return {
|
||||||
|
peersTotal: peers.length,
|
||||||
|
peersEnabled,
|
||||||
|
peersEstablished,
|
||||||
|
peersMismatch,
|
||||||
|
speakersTotal: speakers.length,
|
||||||
|
speakersOnline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runningJobCount(jobs: JobRow[]): number {
|
||||||
|
return jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleNameById(modules: ModuleRow[]): Map<string, string> {
|
||||||
|
return new Map(modules.map((m) => [m.id, m.name]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recentRevisions(revisions: RevisionRow[], n = 10): RevisionRow[] {
|
||||||
|
return revisions.slice(0, n)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import { apiJSON } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export type AppSettings = Record<string, unknown>
|
||||||
|
|
||||||
|
export const BIRD_SETTING_KEYS = [
|
||||||
|
'bird_router_id',
|
||||||
|
'bird_local_ipv4',
|
||||||
|
'bird_local_ipv6',
|
||||||
|
'bird_local_asn',
|
||||||
|
'bird_bgp_source_ipv4',
|
||||||
|
'bird_bgp_source_ipv6',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const
|
||||||
|
|
||||||
|
export const RUNTIME_LOGS_SETTING_KEYS = [
|
||||||
|
'runtime_logs_auto_enabled',
|
||||||
|
'runtime_logs_max_file_mb',
|
||||||
|
'runtime_logs_auto_schedule',
|
||||||
|
'runtime_logs_auto_mode',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const KNOWN_SETTING_KEYS = [
|
||||||
|
...BIRD_SETTING_KEYS,
|
||||||
|
...REVISION_SETTING_KEYS,
|
||||||
|
...RUNTIME_LOGS_SETTING_KEYS,
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type KnownSettingKey = (typeof KNOWN_SETTING_KEYS)[number]
|
||||||
|
export type BirdSettingKey = (typeof BIRD_SETTING_KEYS)[number]
|
||||||
|
export type RevisionSettingKey = (typeof REVISION_SETTING_KEYS)[number]
|
||||||
|
export type RuntimeLogsSettingKey = (typeof RUNTIME_LOGS_SETTING_KEYS)[number]
|
||||||
|
|
||||||
|
export const NUMERIC_SETTING_KEYS = new Set<KnownSettingKey>([
|
||||||
|
'bird_local_asn',
|
||||||
|
'revision_retention_minutes',
|
||||||
|
'runtime_logs_max_file_mb',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>(['runtime_logs_auto_enabled'])
|
||||||
|
|
||||||
|
export const settingsKeys = {
|
||||||
|
all: ['settings'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function settingsQueryOptions() {
|
||||||
|
return queryOptions<AppSettings>({
|
||||||
|
queryKey: settingsKeys.all,
|
||||||
|
queryFn: () => apiJSON<AppSettings>('/v1/settings'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseKnownValue(key: KnownSettingKey, value: unknown): string {
|
||||||
|
if (NUMERIC_SETTING_KEYS.has(key)) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartitionedSettings {
|
||||||
|
bird: Partial<Record<BirdSettingKey, string>>
|
||||||
|
revision: Partial<Record<RevisionSettingKey, string>>
|
||||||
|
runtimeLogs: Partial<Record<RuntimeLogsSettingKey, string>>
|
||||||
|
additional: { id: number; key: string; value: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partitionSettings(settings: AppSettings): PartitionedSettings {
|
||||||
|
const bird: Partial<Record<BirdSettingKey, string>> = {}
|
||||||
|
const revision: Partial<Record<RevisionSettingKey, string>> = {}
|
||||||
|
const runtimeLogs: Partial<Record<RuntimeLogsSettingKey, string>> = {}
|
||||||
|
const additional: { id: number; key: string; value: string }[] = []
|
||||||
|
let id = 1
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
if ((BIRD_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||||
|
bird[key as BirdSettingKey] = parseKnownValue(key as KnownSettingKey, value)
|
||||||
|
} else if (key === 'revision_retention_minutes') {
|
||||||
|
revision.revision_retention_minutes = parseKnownValue(
|
||||||
|
key as RevisionSettingKey,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
} else if ((RUNTIME_LOGS_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||||
|
const rk = key as RuntimeLogsSettingKey
|
||||||
|
if (rk === 'runtime_logs_auto_enabled') {
|
||||||
|
runtimeLogs.runtime_logs_auto_enabled =
|
||||||
|
value === true || value === 1 || value === 'true' || value === '1'
|
||||||
|
? 'true'
|
||||||
|
: 'false'
|
||||||
|
} else if (rk === 'runtime_logs_auto_mode') {
|
||||||
|
const m = String(value ?? '').trim()
|
||||||
|
runtimeLogs.runtime_logs_auto_mode = m === 'delete' ? 'delete' : m === 'truncate' ? 'truncate' : ''
|
||||||
|
} else {
|
||||||
|
runtimeLogs[rk] = parseKnownValue(rk, value)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
additional.push({
|
||||||
|
id: id++,
|
||||||
|
key,
|
||||||
|
value: typeof value === 'string' ? value : String(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bird, revision, runtimeLogs, additional }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPayload(
|
||||||
|
keys: readonly KnownSettingKey[],
|
||||||
|
form: Record<string, string>,
|
||||||
|
): Record<string, string | number | boolean> {
|
||||||
|
const payload: Record<string, string | number | boolean> = {}
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = String(form[key] ?? '').trim()
|
||||||
|
if (BOOLEAN_SETTING_KEYS.has(key)) {
|
||||||
|
payload[key] = value === 'true'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!value) continue
|
||||||
|
if (NUMERIC_SETTING_KEYS.has(key)) payload[key] = Number(value)
|
||||||
|
else payload[key] = value
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||||
|
import { AppShell } from '@/components/layout/app-shell'
|
||||||
|
|
||||||
|
interface RouterContext {
|
||||||
|
queryClient: import('@tanstack/react-query').QueryClient
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
|
component: RootComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RootComponent() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Outlet />
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth')({
|
||||||
|
beforeLoad: () => {
|
||||||
|
const token =
|
||||||
|
typeof window !== 'undefined' ? window.localStorage.getItem('evobgp_api_token') : null
|
||||||
|
if (!token) {
|
||||||
|
throw redirect({ to: '/settings' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: AuthLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AuthLayout() {
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@evobgp/ui/components/table'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
|
import { apiKeysQueryOptions } from '@/queries/api-keys'
|
||||||
|
import { apiMutate } from '@/lib/api-client'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import type { ApiKeyCreated } from '@/types/api'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Copy } from 'lucide-react'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/access')({
|
||||||
|
component: AccessComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AccessComponent() {
|
||||||
|
const sessionQuery = useQuery(authSessionQueryOptions())
|
||||||
|
const session = sessionQuery.data ?? null
|
||||||
|
const isOperator = session?.role === 'operator'
|
||||||
|
|
||||||
|
const keysQuery = useQuery({
|
||||||
|
...apiKeysQueryOptions(),
|
||||||
|
enabled: isOperator,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-4xl flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Права доступа"
|
||||||
|
description="API-ключи control plane и текущая сессия Bearer-токена."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{session ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Текущая сессия</CardTitle>
|
||||||
|
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Tenant</p>
|
||||||
|
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Роль</p>
|
||||||
|
<p className="font-mono">{session.role}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isOperator ? (
|
||||||
|
<ApiKeysCard
|
||||||
|
items={keysQuery.data ?? []}
|
||||||
|
isLoading={keysQuery.isLoading}
|
||||||
|
isError={keysQuery.isError}
|
||||||
|
error={keysQuery.error}
|
||||||
|
onRetry={() => keysQuery.refetch()}
|
||||||
|
/>
|
||||||
|
) : session ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||||
|
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
|
||||||
|
<span className="font-mono">{session.role}</span>.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApiKeysCard({
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
items: import('@/types/api').ApiKey[]
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
error: unknown
|
||||||
|
onRetry: () => void
|
||||||
|
}) {
|
||||||
|
const [revealedToken, setRevealedToken] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const revoke = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }),
|
||||||
|
onSuccess: () => toast.success('Ключ отозван'),
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rotate = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiMutate<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
|
||||||
|
idempotent: false,
|
||||||
|
}),
|
||||||
|
onSuccess: (created) => {
|
||||||
|
toast.success('Ключ ротирован')
|
||||||
|
setRevealedToken(created.token)
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function copyToken() {
|
||||||
|
if (!revealedToken) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(revealedToken)
|
||||||
|
toast.success('Скопировано')
|
||||||
|
} catch {
|
||||||
|
toast.error('Не удалось скопировать')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="text-base">API-ключи</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Управление ключами tenant. Полный токен показывается только при создании и ротации.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
|
||||||
|
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<QueryState
|
||||||
|
data={items}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
empty={items.length === 0}
|
||||||
|
emptyTitle="Нет ключей"
|
||||||
|
emptyDescription="Ключи можно создать через API."
|
||||||
|
onRetry={onRetry}
|
||||||
|
>
|
||||||
|
{(data) => (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Имя</TableHead>
|
||||||
|
<TableHead>Роль</TableHead>
|
||||||
|
<TableHead>Префикс</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
<TableHead className="w-24" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((k) => (
|
||||||
|
<TableRow key={k.id}>
|
||||||
|
<TableCell className="font-medium">{k.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{k.role}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">{k.prefix}…</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{k.revoked_at ? (
|
||||||
|
<span className="text-sm text-destructive">отозван</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">активен</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<ConfirmDialog
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
title="Ротировать"
|
||||||
|
disabled={!!k.revoked_at}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="Ротировать ключ?"
|
||||||
|
description="Старый токен перестанет работать сразу."
|
||||||
|
confirmLabel="Ротировать"
|
||||||
|
onConfirm={() => rotate.mutate(k.id)}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive"
|
||||||
|
disabled={!!k.revoked_at}
|
||||||
|
title="Отозвать"
|
||||||
|
>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="Отозвать API-ключ?"
|
||||||
|
description={`${k.name} (${k.prefix}…)`}
|
||||||
|
confirmLabel="Отозвать"
|
||||||
|
destructive
|
||||||
|
onConfirm={() => revoke.mutate(k.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{revealedToken ? (
|
||||||
|
<div className="flex flex-col gap-3 border-t p-4">
|
||||||
|
<div className="text-sm font-medium">Новый токен (сохраните сейчас):</div>
|
||||||
|
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||||
|
{revealedToken}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={copyToken}>
|
||||||
|
<Copy /> Копировать
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setRevealedToken(null)}>
|
||||||
|
Готово
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQueries } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
GitBranch,
|
||||||
|
Info,
|
||||||
|
Network,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Radio,
|
||||||
|
RefreshCw,
|
||||||
|
Activity,
|
||||||
|
Share2,
|
||||||
|
Tags,
|
||||||
|
Gauge,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||||
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
|
|
||||||
|
import {
|
||||||
|
aggregateNetworkMetrics,
|
||||||
|
moduleNameById,
|
||||||
|
overviewHealthQueryOptions,
|
||||||
|
overviewJobsQueryOptions,
|
||||||
|
overviewModulesQueryOptions,
|
||||||
|
overviewPeersQueryOptions,
|
||||||
|
overviewRevisionsQueryOptions,
|
||||||
|
overviewSpeakersQueryOptions,
|
||||||
|
runningJobCount,
|
||||||
|
} from '@/queries/overview'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/dashboard')({
|
||||||
|
component: DashboardComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function DashboardComponent() {
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||||
|
|
||||||
|
const results = useQueries({
|
||||||
|
queries: [
|
||||||
|
overviewHealthQueryOptions(),
|
||||||
|
overviewModulesQueryOptions(),
|
||||||
|
overviewPeersQueryOptions(),
|
||||||
|
overviewSpeakersQueryOptions(),
|
||||||
|
overviewRevisionsQueryOptions(),
|
||||||
|
overviewJobsQueryOptions(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const [healthQ, modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
|
||||||
|
const initialLoading =
|
||||||
|
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading
|
||||||
|
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
|
||||||
|
|
||||||
|
if (!lastUpdated && !initialLoading && results.every((r) => r.isSuccess || r.isError)) {
|
||||||
|
setTimeout(() => setLastUpdated(new Date()), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function refetchAll() {
|
||||||
|
setLastUpdated(null)
|
||||||
|
results.forEach((r) => r.refetch())
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules = modulesQ.data?.items ?? []
|
||||||
|
const peers = peersQ.data?.items ?? []
|
||||||
|
const speakers = speakersQ.data?.items ?? []
|
||||||
|
const revisions = revisionsQ.data?.items ?? []
|
||||||
|
const jobs = jobsQ.data?.items ?? []
|
||||||
|
const modulesHasMore = modulesQ.data?.has_more ?? false
|
||||||
|
const peersHasMore = peersQ.data?.has_more ?? false
|
||||||
|
const speakersHasMore = speakersQ.data?.has_more ?? false
|
||||||
|
const revisionsHasMore = revisionsQ.data?.has_more ?? false
|
||||||
|
|
||||||
|
const net = aggregateNetworkMetrics(peers, speakers)
|
||||||
|
const running = runningJobCount(jobs)
|
||||||
|
const nameById = moduleNameById(modules)
|
||||||
|
|
||||||
|
const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix)
|
||||||
|
|
||||||
|
const items: SectionCardItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Модули',
|
||||||
|
value: initialLoading ? '—' : String(modules.length),
|
||||||
|
icon: <Boxes className="size-4" />,
|
||||||
|
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
|
||||||
|
onClick: () => window.location.assign('/modules'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Пиры',
|
||||||
|
value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||||
|
icon: <GitBranch className="size-4" />,
|
||||||
|
hint: countBadge(peers.length, peersHasMore, 'Established / включённых'),
|
||||||
|
badge: net.peersMismatch > 0 ? `mismatch ${net.peersMismatch}` : undefined,
|
||||||
|
variant: net.peersMismatch > 0 ? 'warning' : 'default',
|
||||||
|
onClick: () => window.location.assign('/network?tab=peers'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Спикеры',
|
||||||
|
value: initialLoading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||||
|
icon: <Radio className="size-4" />,
|
||||||
|
hint: countBadge(speakers.length, speakersHasMore, 'online / всего'),
|
||||||
|
variant: net.speakersOnline < net.speakersTotal ? 'warning' : 'default',
|
||||||
|
onClick: () => window.location.assign('/network?tab=overview'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ревизии',
|
||||||
|
value: initialLoading ? '—' : String(revisions.length),
|
||||||
|
icon: <Activity className="size-4" />,
|
||||||
|
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
|
||||||
|
onClick: () => window.location.assign('/operations'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Активных задач',
|
||||||
|
value: initialLoading ? '—' : String(running),
|
||||||
|
icon: <Clock className="size-4" />,
|
||||||
|
hint: 'queued и running',
|
||||||
|
onClick: () => window.location.assign('/operations?tab=jobs'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Обзор"
|
||||||
|
description={
|
||||||
|
lastUpdated
|
||||||
|
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||||
|
: 'Состояние панели управления EvoBGP.'
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
|
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert className="border-info/30 bg-info/5">
|
||||||
|
<Info className="text-info" />
|
||||||
|
<AlertTitle>Панель управления EvoBGP</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Сводка по модулям, сети и фоновым задачам. BGP и ноды — «Сеть», префиксы — «Модули»,
|
||||||
|
деплой — «Операции», здоровье API — «Мониторинг».
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<HealthAlert
|
||||||
|
loading={healthQ.isLoading}
|
||||||
|
ok={healthQ.data === true}
|
||||||
|
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} />}
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<RecentJobsCard jobs={jobs} nameById={nameById} loading={refreshing} />
|
||||||
|
<RecentRevisionsCard revisions={revisions} loading={refreshing} />
|
||||||
|
<NetworkStatusCard peers={peers} speakers={speakers} loading={refreshing} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Быстрые действия</CardTitle>
|
||||||
|
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2 p-4">
|
||||||
|
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Создать модуль
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/directories')}>
|
||||||
|
<Tags className="size-4" />
|
||||||
|
Добавить community
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=overview')}>
|
||||||
|
<Network className="size-4" />
|
||||||
|
Сеть
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=peers')}>
|
||||||
|
<Share2 className="size-4" />
|
||||||
|
Добавить пира
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/operations')}>
|
||||||
|
<Play className="size-4" />
|
||||||
|
Деплой (Apply)
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/monitoring')}>
|
||||||
|
<Gauge className="size-4" />
|
||||||
|
Мониторинг
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthAlert({
|
||||||
|
loading,
|
||||||
|
ok,
|
||||||
|
loadError,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
ok: boolean | undefined
|
||||||
|
loadError: string | null
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<Skeleton className="size-5 rounded-full" />
|
||||||
|
<AlertTitle>Проверка API…</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Запрос к <code className="text-xs">/v1/health</code>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ok && !loadError) {
|
||||||
|
return (
|
||||||
|
<Alert className="border-success/30 bg-success/5">
|
||||||
|
<CheckCircle className="text-success" />
|
||||||
|
<AlertTitle>API работает</AlertTitle>
|
||||||
|
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ok && loadError) {
|
||||||
|
return (
|
||||||
|
<Alert className="border-warning/30 bg-warning/5">
|
||||||
|
<Info className="text-warning" />
|
||||||
|
<AlertTitle>API доступен, данные не загружены</AlertTitle>
|
||||||
|
<AlertDescription>{loadError}. Проверьте Bearer-токен в «Настройках».</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive" className="border-destructive/30 bg-destructive/5">
|
||||||
|
<XCircle className="text-destructive" />
|
||||||
|
<AlertTitle>API недоступен</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080) и в dev работает
|
||||||
|
прокси Vite.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentJobsCard({
|
||||||
|
jobs,
|
||||||
|
nameById,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
jobs: import('@/types/api').JobRow[]
|
||||||
|
nameById: Map<string, string>
|
||||||
|
loading: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Недавние задачи</CardTitle>
|
||||||
|
<CardDescription>Последние фоновые операции</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
{loading && jobs.length === 0 ? (
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
) : jobs.length === 0 ? (
|
||||||
|
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет задач</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{jobs.slice(0, 8).map((j) => (
|
||||||
|
<li
|
||||||
|
key={j.job_id}
|
||||||
|
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="truncate font-mono text-xs text-muted-foreground">{j.kind}</span>
|
||||||
|
<span className="truncate text-xs">
|
||||||
|
{j.meta?.module_id ? nameById.get(String(j.meta.module_id)) ?? '' : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
j.status === 'succeeded'
|
||||||
|
? 'text-xs text-success'
|
||||||
|
: j.status === 'failed'
|
||||||
|
? 'text-xs text-destructive'
|
||||||
|
: 'text-xs text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{j.status}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentRevisionsCard({
|
||||||
|
revisions,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
revisions: import('@/types/api').RevisionRow[]
|
||||||
|
loading: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Последние ревизии</CardTitle>
|
||||||
|
<CardDescription>История конфигураций</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
{loading && revisions.length === 0 ? (
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
) : revisions.length === 0 ? (
|
||||||
|
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет ревизий</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{revisions.slice(0, 8).map((r) => (
|
||||||
|
<li
|
||||||
|
key={r.id}
|
||||||
|
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<span className="truncate font-mono text-xs text-muted-foreground">{r.id.slice(0, 10)}…</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(r.created_at).toLocaleString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NetworkStatusCard({
|
||||||
|
peers,
|
||||||
|
speakers,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
peers: import('@/types/api').PeerRow[]
|
||||||
|
speakers: import('@/types/api').SpeakerRow[]
|
||||||
|
loading: boolean
|
||||||
|
}) {
|
||||||
|
const m = aggregateNetworkMetrics(peers, speakers)
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Состояние сети</CardTitle>
|
||||||
|
<CardDescription>BGP-сессии и спикеры</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
{loading && peers.length === 0 && speakers.length === 0 ? (
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2 px-1 py-1 text-sm">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
variant = 'default',
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
variant?: 'default' | 'warning'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className={variant === 'warning' ? 'font-medium text-warning-foreground' : 'font-medium tabular-nums'}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||||
|
import { Badge } from '@evobgp/ui/components/badge'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@evobgp/ui/components/table'
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||||
|
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/directories')({
|
||||||
|
component: DirectoriesComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function DirectoriesComponent() {
|
||||||
|
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||||
|
const dohQ = useQuery(directoriesDohQueryOptions())
|
||||||
|
|
||||||
|
const communities = communitiesQ.data?.items ?? []
|
||||||
|
const dohProfiles = dohQ.data?.items ?? []
|
||||||
|
const loading = communitiesQ.isLoading || dohQ.isLoading
|
||||||
|
|
||||||
|
const items: SectionCardItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Сообщества BGP',
|
||||||
|
value: communities.length,
|
||||||
|
icon: <Tags className="size-4" />,
|
||||||
|
hint: 'теги префиксов в AS- и CDN-модулях',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'DoH профили',
|
||||||
|
value: dohProfiles.length,
|
||||||
|
icon: <Globe className="size-4" />,
|
||||||
|
hint: 'резолвинг доменных модулей',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Справочники',
|
||||||
|
value: 'Общие',
|
||||||
|
icon: <BookText className="size-4" />,
|
||||||
|
hint: 'используются всеми модулями tenant',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Справочники"
|
||||||
|
description="Сообщества BGP и DoH-профили для резолвинга доменов"
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void communitiesQ.refetch()
|
||||||
|
void dohQ.refetch()
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert className="border-info/30 bg-info/5">
|
||||||
|
<Info className="text-info" />
|
||||||
|
<AlertTitle>О справочниках</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили — в
|
||||||
|
доменных модулях для DNS-over-HTTPS резолвинга.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||||
|
|
||||||
|
<Tabs defaultValue="communities">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="communities">Сообщества BGP</TabsTrigger>
|
||||||
|
<TabsTrigger value="doh">DoH профили</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="communities" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Сообщества BGP</CardTitle>
|
||||||
|
<CardDescription>Теги для префиксов в фильтрах BIRD</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<QueryState
|
||||||
|
data={communities}
|
||||||
|
isLoading={communitiesQ.isLoading}
|
||||||
|
isError={communitiesQ.isError}
|
||||||
|
error={communitiesQ.error}
|
||||||
|
empty={communities.length === 0}
|
||||||
|
emptyTitle="Нет сообществ"
|
||||||
|
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||||
|
onRetry={() => communitiesQ.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Название</TableHead>
|
||||||
|
<TableHead>Значение</TableHead>
|
||||||
|
<TableHead>Тип</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((c) => (
|
||||||
|
<TableRow key={c.id}>
|
||||||
|
<TableCell className="font-medium">{c.title}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{c.community}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">community</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="doh" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">DoH профили</CardTitle>
|
||||||
|
<CardDescription>Резолверы DNS-over-HTTPS для доменных модулей</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<QueryState
|
||||||
|
data={dohProfiles}
|
||||||
|
isLoading={dohQ.isLoading}
|
||||||
|
isError={dohQ.isError}
|
||||||
|
error={dohQ.error}
|
||||||
|
empty={dohProfiles.length === 0}
|
||||||
|
emptyTitle="Нет DoH профилей"
|
||||||
|
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||||
|
onRetry={() => dohQ.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Название</TableHead>
|
||||||
|
<TableHead>URL</TableHead>
|
||||||
|
<TableHead>По умолчанию</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="font-medium">{p.name ?? p.url}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{p.url}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">—</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@evobgp/ui/components/table'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { moduleDetailQueryOptions, moduleEntriesQueryOptions } from '@/queries/modules'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/modules/$moduleId')({
|
||||||
|
component: ModuleDetailComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ModuleDetailComponent() {
|
||||||
|
const { moduleId } = Route.useParams()
|
||||||
|
const detail = useQuery(moduleDetailQueryOptions(moduleId))
|
||||||
|
const mod = detail.data
|
||||||
|
|
||||||
|
const entriesQuery = useQuery({
|
||||||
|
...moduleEntriesQueryOptions(moduleId, mod?.type ?? 'DOMAINS'),
|
||||||
|
enabled: !!mod,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title={mod?.name ?? moduleId}
|
||||||
|
description={mod ? `Тип: ${mod.type}` : 'Загрузка модуля…'}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
|
||||||
|
<ArrowLeft />
|
||||||
|
К списку
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void detail.refetch()
|
||||||
|
void entriesQuery.refetch()
|
||||||
|
}}
|
||||||
|
disabled={detail.isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={detail.isFetching ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QueryState
|
||||||
|
data={mod}
|
||||||
|
isLoading={detail.isLoading}
|
||||||
|
isError={detail.isError}
|
||||||
|
error={detail.error}
|
||||||
|
skeleton={<TableSkeleton rows={4} cols={2} />}
|
||||||
|
onRetry={() => detail.refetch()}
|
||||||
|
>
|
||||||
|
{(m) => (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Параметры</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
|
||||||
|
<Field label="ID" value={<code className="font-mono text-xs">{m.id}</code>} />
|
||||||
|
<Field label="Тип" value={<Badge variant="outline">{m.type}</Badge>} />
|
||||||
|
<Field label="Приоритет" value={<span className="font-mono">{m.priority}</span>} />
|
||||||
|
<Field
|
||||||
|
label="Состояние"
|
||||||
|
value={
|
||||||
|
m.enabled ? (
|
||||||
|
<StatusBadge status="active" label="включён" />
|
||||||
|
) : (
|
||||||
|
<StatusBadge status="paused" label="выключен" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Интервал"
|
||||||
|
value={
|
||||||
|
m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Cron"
|
||||||
|
value={m.cron_expr ? <code className="font-mono text-xs">{m.cron_expr}</code> : '—'}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Последний рефреш"
|
||||||
|
value={
|
||||||
|
m.last_refreshed_at
|
||||||
|
? new Date(m.last_refreshed_at).toLocaleString('ru-RU')
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Маршрутные списки</CardTitle>
|
||||||
|
<CardDescription>Источник префиксов для модуля</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<QueryState
|
||||||
|
data={entriesQuery.data?.items}
|
||||||
|
isLoading={entriesQuery.isLoading}
|
||||||
|
isError={entriesQuery.isError}
|
||||||
|
error={entriesQuery.error}
|
||||||
|
empty={(entriesQuery.data?.items?.length ?? 0) === 0}
|
||||||
|
emptyTitle="Записей нет"
|
||||||
|
emptyDescription="Добавьте записи через API или создание ревизии."
|
||||||
|
skeleton={<TableSkeleton rows={5} cols={2} />}
|
||||||
|
onRetry={() => entriesQuery.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => <EntriesTable items={items} moduleType={m.type} />}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span>{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntriesTable({
|
||||||
|
items,
|
||||||
|
moduleType,
|
||||||
|
}: {
|
||||||
|
items: Record<string, unknown>[]
|
||||||
|
moduleType: string
|
||||||
|
}) {
|
||||||
|
const primary = ENTRY_PRIMARY_KEY[moduleType as keyof typeof ENTRY_PRIMARY_KEY] ?? 'id'
|
||||||
|
const secondary = ENTRY_SECONDARY_KEY[moduleType as keyof typeof ENTRY_SECONDARY_KEY] ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{primary}</TableHead>
|
||||||
|
{secondary ? <TableHead>{secondary}</TableHead> : null}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item, idx) => {
|
||||||
|
const id = String(item.id ?? idx)
|
||||||
|
const primaryVal = String(item[primary] ?? '—')
|
||||||
|
return (
|
||||||
|
<TableRow key={id}>
|
||||||
|
<TableCell className="font-mono text-sm">{primaryVal}</TableCell>
|
||||||
|
{secondary ? (
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{String(item[secondary] ?? '—')}
|
||||||
|
</TableCell>
|
||||||
|
) : null}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTRY_PRIMARY_KEY = {
|
||||||
|
DOMAINS: 'fqdn',
|
||||||
|
AS_PREFIXES: 'asn',
|
||||||
|
CDN_CIDRS: 'url',
|
||||||
|
IP_RANGES: 'prefix',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const ENTRY_SECONDARY_KEY = {
|
||||||
|
DOMAINS: 'community_id',
|
||||||
|
AS_PREFIXES: 'community_id',
|
||||||
|
CDN_CIDRS: 'community_id',
|
||||||
|
IP_RANGES: 'community_id',
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Link, createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Boxes, Plus, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@evobgp/ui/components/table'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
|
import { modulesListQueryOptions } from '@/queries/modules'
|
||||||
|
import type { ModuleRow } from '@/types/api'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/modules/')({
|
||||||
|
component: ModulesListComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ModulesListComponent() {
|
||||||
|
const query = useQuery(modulesListQueryOptions())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Модули"
|
||||||
|
description="Маршрутные списки: AS, CDN, домены, IP-диапазоны"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => query.refetch()}
|
||||||
|
disabled={query.isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={query.isFetching ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between border-b py-3">
|
||||||
|
<CardTitle className="text-base">Все модули</CardTitle>
|
||||||
|
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||||
|
<Plus />
|
||||||
|
Создать
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<QueryState
|
||||||
|
data={query.data?.items}
|
||||||
|
isLoading={query.isLoading}
|
||||||
|
isError={query.isError}
|
||||||
|
error={query.error}
|
||||||
|
empty={query.data?.items?.length === 0}
|
||||||
|
emptyTitle="Нет модулей"
|
||||||
|
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
|
||||||
|
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||||
|
onRetry={() => query.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => <ModulesTable items={items} />}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModulesTable({ items }: { items: ModuleRow[] }) {
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Название</TableHead>
|
||||||
|
<TableHead>Тип</TableHead>
|
||||||
|
<TableHead>Приоритет</TableHead>
|
||||||
|
<TableHead>Состояние</TableHead>
|
||||||
|
<TableHead>Обновлено</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((m) => (
|
||||||
|
<TableRow
|
||||||
|
key={m.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/40"
|
||||||
|
onClick={() => (window.location.href = `/modules/${m.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Boxes className="size-4 text-muted-foreground" />
|
||||||
|
<TruncatedText className="max-w-[280px]">{m.name}</TruncatedText>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{m.type}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm tabular-nums">{m.priority}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{m.enabled ? (
|
||||||
|
<Badge variant="success">включён</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">выключен</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/modules/new')({
|
||||||
|
component: NewModuleComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function NewModuleComponent() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Новый модуль"
|
||||||
|
description="Создание модуля — через API или будущая форма"
|
||||||
|
/>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col gap-3 py-6 text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Форма создания модуля будет добавлена позже. Сейчас модули можно создать через API:
|
||||||
|
</p>
|
||||||
|
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||||
|
{`POST /v1/modules
|
||||||
|
{ "type": "DOMAINS", "name": "Мой список" }`}
|
||||||
|
</pre>
|
||||||
|
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
|
||||||
|
Назад к списку
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Activity, AlertTriangle, Bird, Database, Gauge, HardDrive, HeartPulse, Info, ListTodo, RefreshCw, ShieldCheck } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||||
|
import { Badge } from '@evobgp/ui/components/badge'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
|
import { Separator } from '@evobgp/ui/components/separator'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@evobgp/ui/components/table'
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||||
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
|
|
||||||
|
import {
|
||||||
|
monitoringHealthQueryOptions,
|
||||||
|
monitoringReadyQueryOptions,
|
||||||
|
monitoringVersionQueryOptions,
|
||||||
|
type ReadyStatus,
|
||||||
|
type VersionInfo,
|
||||||
|
} from '@/queries/monitoring'
|
||||||
|
import { networkBirdQueryOptions } from '@/queries/network'
|
||||||
|
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/monitoring')({
|
||||||
|
component: MonitoringComponent,
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
tab: (search.tab === 'postgres' || search.tab === 'runtime-logs' ? search.tab : 'system') as
|
||||||
|
| 'system'
|
||||||
|
| 'postgres'
|
||||||
|
| 'runtime-logs',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function MonitoringComponent() {
|
||||||
|
const search = useSearch({ from: '/_auth/monitoring' })
|
||||||
|
const healthQ = useQuery(monitoringHealthQueryOptions())
|
||||||
|
const readyQ = useQuery(monitoringReadyQueryOptions())
|
||||||
|
const versionQ = useQuery(monitoringVersionQueryOptions())
|
||||||
|
const birdQ = useQuery(networkBirdQueryOptions())
|
||||||
|
const jobsQ = useQuery(operationsJobsQueryOptions())
|
||||||
|
|
||||||
|
const refreshing =
|
||||||
|
healthQ.isFetching ||
|
||||||
|
readyQ.isFetching ||
|
||||||
|
versionQ.isFetching ||
|
||||||
|
birdQ.isFetching ||
|
||||||
|
jobsQ.isFetching
|
||||||
|
|
||||||
|
const jobs = jobsQ.data?.items ?? []
|
||||||
|
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||||
|
const failed = jobs.filter((j) =>
|
||||||
|
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||||
|
).length
|
||||||
|
|
||||||
|
const versionText = formatVersion(versionQ.data)
|
||||||
|
|
||||||
|
const items: SectionCardItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Общий статус',
|
||||||
|
value: overallStatusLabel({ health: healthQ.data, ready: readyQ.data, jobsFailed: failed }),
|
||||||
|
icon: <Gauge className="size-4" />,
|
||||||
|
hint: overallHint({ health: healthQ.data, jobsFailed: failed }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'BGP сессии',
|
||||||
|
value: birdQ.data
|
||||||
|
? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}`
|
||||||
|
: '—',
|
||||||
|
icon: <Bird className="size-4" />,
|
||||||
|
hint: birdQ.data?.birdc_configured
|
||||||
|
? 'Established / total на API-хосте'
|
||||||
|
: 'birdc не настроен',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Задачи',
|
||||||
|
value: running,
|
||||||
|
icon: <Activity className="size-4" />,
|
||||||
|
hint: `активных из ${jobs.length}`,
|
||||||
|
variant: failed > 0 ? 'warning' : 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Версия',
|
||||||
|
value: versionText,
|
||||||
|
icon: <Gauge className="size-4" />,
|
||||||
|
hint: versionQ.data?.git_sha ?? versionQ.data?.build_time ?? 'GET /v1/version',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function refetchAll() {
|
||||||
|
void healthQ.refetch()
|
||||||
|
void readyQ.refetch()
|
||||||
|
void versionQ.refetch()
|
||||||
|
void birdQ.refetch()
|
||||||
|
void jobsQ.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedJobs = jobs
|
||||||
|
.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Мониторинг"
|
||||||
|
description="Состояние API, BGP и задач для диагностики инцидентов"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
|
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue={search.tab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="system">Система</TabsTrigger>
|
||||||
|
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
|
||||||
|
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="system" className="mt-4 flex flex-col gap-6">
|
||||||
|
{refreshing ? <SectionCardsSkeleton count={4} /> : <SectionCards items={items} />}
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Доступность и готовность</CardTitle>
|
||||||
|
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<QueryState
|
||||||
|
data={readyQ.data}
|
||||||
|
isLoading={readyQ.isLoading}
|
||||||
|
isError={readyQ.isError}
|
||||||
|
error={readyQ.error}
|
||||||
|
skeleton={<div className="h-40" />}
|
||||||
|
onRetry={() => readyQ.refetch()}
|
||||||
|
>
|
||||||
|
{(ready) => <ReadyTable health={healthQ.data} ready={ready} />}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Bird className="size-4" />
|
||||||
|
BGP на API-хосте
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>GET /v1/bird/status</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<QueryState
|
||||||
|
data={birdQ.data}
|
||||||
|
isLoading={birdQ.isLoading}
|
||||||
|
isError={birdQ.isError}
|
||||||
|
error={birdQ.error}
|
||||||
|
skeleton={<div className="h-40" />}
|
||||||
|
onRetry={() => birdQ.refetch()}
|
||||||
|
>
|
||||||
|
{(bird) => <BirdSummary bird={bird} />}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Activity className="size-4" />
|
||||||
|
Задачи
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Последние 100 задач · GET /v1/jobs</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm">
|
||||||
|
<Metric label="Активных" value={running} />
|
||||||
|
<Metric
|
||||||
|
label="С ошибками"
|
||||||
|
value={failed}
|
||||||
|
valueClass={failed > 0 ? 'text-warning' : 'text-success'}
|
||||||
|
/>
|
||||||
|
<Metric label="В выборке" value={jobs.length} />
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
{failedJobs.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium">Последние ошибки</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{failedJobs.map((job) => (
|
||||||
|
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="font-medium">{job.kind}</p>
|
||||||
|
<Badge variant="destructive">{job.status}</Badge>
|
||||||
|
</div>
|
||||||
|
{job.error ? (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{job.error.slice(0, 120)}
|
||||||
|
{job.error.length > 120 ? '…' : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Критичных сбоев в последних 100 задачах нет.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<AlertTriangle className="size-4 text-muted-foreground" />
|
||||||
|
Что проверять при деградации
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Короткая шпаргалка для triage</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Alert>
|
||||||
|
<HeartPulse className="size-4" />
|
||||||
|
<AlertTitle>API недоступен</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Если <code className="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс
|
||||||
|
API и его логи.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Alert>
|
||||||
|
<Database className="size-4" />
|
||||||
|
<AlertTitle>Readiness не «Готов»</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Сначала <code className="text-xs">postgres</code>, затем{' '}
|
||||||
|
<code className="text-xs">store</code> и <code className="text-xs">jobs</code> в checks.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Alert>
|
||||||
|
<Bird className="size-4" />
|
||||||
|
<AlertTitle>Низкий ratio BGP</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Проверьте <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Alert>
|
||||||
|
<ListTodo className="size-4" />
|
||||||
|
<AlertTitle>Ошибки задач</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Откройте Операции и проверьте последние неуспешные jobs.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="postgres" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">PostgreSQL</CardTitle>
|
||||||
|
<CardDescription>Статус соединения и пул</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Alert>
|
||||||
|
<Database className="size-4" />
|
||||||
|
<AlertTitle>Статус готовности</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
PostgreSQL-соединение отображается в readiness-проверке на вкладке «Система» (check{' '}
|
||||||
|
<code className="text-xs">postgres</code>).
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="runtime-logs" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Файловые логи</CardTitle>
|
||||||
|
<CardDescription>Логи API и pipeline</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Alert>
|
||||||
|
<Info className="size-4" />
|
||||||
|
<AlertTitle>Логи на сервере</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Файловые логи настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
|
||||||
|
управляются tenant-settings на странице «Настройки BIRD».
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value, valueClass }: { label: string; value: number | string; valueClass?: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{label}</p>
|
||||||
|
<p className={`text-2xl font-bold tabular-nums ${valueClass ?? ''}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVersion(version?: VersionInfo | null): string {
|
||||||
|
if (!version) return '—'
|
||||||
|
return version.version ?? version.app ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverallInput {
|
||||||
|
health?: { ok?: boolean } | null
|
||||||
|
ready?: ReadyStatus | null
|
||||||
|
jobsFailed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function overallStatusLabel(input: OverallInput): string {
|
||||||
|
if (!input.health?.ok) return 'Ошибка'
|
||||||
|
if (input.jobsFailed > 0) return 'Внимание'
|
||||||
|
if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание'
|
||||||
|
return 'В норме'
|
||||||
|
}
|
||||||
|
|
||||||
|
function overallHint(input: OverallInput): string {
|
||||||
|
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
|
||||||
|
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
|
||||||
|
return 'Все системы работают в штатном режиме'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadyTable({
|
||||||
|
health,
|
||||||
|
ready,
|
||||||
|
}: {
|
||||||
|
health?: { ok?: boolean; status?: string; error?: string } | null
|
||||||
|
ready: ReadyStatus
|
||||||
|
}) {
|
||||||
|
const checks = ready.checks ?? {}
|
||||||
|
const iconByKey: Record<string, typeof Database> = {
|
||||||
|
postgres: Database,
|
||||||
|
store: HardDrive,
|
||||||
|
jobs: ListTodo,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[55%]">Проверка</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HeartPulse className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Liveness</p>
|
||||||
|
<p className="text-xs text-muted-foreground">/v1/health</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={health?.ok ? 'default' : 'destructive'}>
|
||||||
|
{health?.ok ? 'OK' : 'Ошибка'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Readiness</p>
|
||||||
|
<p className="text-xs text-muted-foreground">/v1/ready</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={ready.status === 'ok' ? 'default' : 'secondary'}>
|
||||||
|
{ready.status ?? '—'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{Object.entries(checks).map(([key, value]) => {
|
||||||
|
const ok = typeof value === 'boolean' ? value : value?.ok !== false
|
||||||
|
const Icon = iconByKey[key] ?? ListTodo
|
||||||
|
return (
|
||||||
|
<TableRow key={key}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{key}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={ok ? 'default' : 'destructive'}>{ok ? 'OK' : 'Ошибка'}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||||
|
if (!bird.birdc_configured) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{bird.message ?? 'birdc не настроен на API-хосте (EVOBGP_BIRDC_SOCKET).'}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const ratio =
|
||||||
|
bird.bgp_sessions_total > 0
|
||||||
|
? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100)
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Established / total</span>
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{bird.bgp_established} / {bird.bgp_sessions_total}
|
||||||
|
{ratio !== null ? <span className="text-muted-foreground"> ({ratio}%)</span> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ratio !== null ? (
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
ratio >= 100 ? 'bg-success' : ratio >= 50 ? 'bg-warning' : 'bg-destructive'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${ratio}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
|
import { Info, RefreshCw } from 'lucide-react'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@evobgp/ui/components/table'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
|
||||||
|
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/network')({
|
||||||
|
component: NetworkComponent,
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
tab: (search.tab === 'peers' || search.tab === 'speakers' || search.tab === 'control-plane'
|
||||||
|
? search.tab
|
||||||
|
: 'overview') as 'overview' | 'peers' | 'speakers' | 'control-plane',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function NetworkComponent() {
|
||||||
|
const search = useSearch({ from: '/_auth/network' })
|
||||||
|
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
|
||||||
|
const refreshing = peersQ.isFetching || speakersQ.isFetching
|
||||||
|
const peers = peersQ.data?.items ?? []
|
||||||
|
const speakers = speakersQ.data?.items ?? []
|
||||||
|
const net = aggregateNetworkMetrics(peers, speakers)
|
||||||
|
|
||||||
|
function refetchAll() {
|
||||||
|
void peersQ.refetch()
|
||||||
|
void speakersQ.refetch()
|
||||||
|
void birdQ.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Сеть"
|
||||||
|
description="BGP-пиры, спикеры и live-метрики нод"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
|
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert className="border-info/30 bg-info/5">
|
||||||
|
<Info className="text-info" />
|
||||||
|
<AlertTitle>О сетевой конфигурации</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Вкладка «Обзор» — live-статус agent и BGP на CP и репликах. Apply и ревизии — на странице «Операции».
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Tabs defaultValue={search.tab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Обзор</TabsTrigger>
|
||||||
|
<TabsTrigger value="peers">Пиры ({peers.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="speakers">Спикеры ({speakers.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="mt-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Сводка сети</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
|
||||||
|
<Field label="Пиры всего" value={String(net.peersTotal)} />
|
||||||
|
<Field label="Established" value={`${net.peersEstablished} / ${net.peersEnabled}`} />
|
||||||
|
<Field label="Спикеры всего" value={String(net.speakersTotal)} />
|
||||||
|
<Field label="Online" value={`${net.speakersOnline} / ${net.speakersTotal}`} />
|
||||||
|
{net.peersMismatch > 0 ? (
|
||||||
|
<Field label="Mismatches" value={String(net.peersMismatch)} />
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
|
||||||
|
<CardDescription>Статус birdc на хосте API</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<QueryState
|
||||||
|
data={birdQ.data}
|
||||||
|
isLoading={birdQ.isLoading}
|
||||||
|
isError={birdQ.isError}
|
||||||
|
error={birdQ.error}
|
||||||
|
skeleton={<TableSkeleton rows={3} cols={2} />}
|
||||||
|
onRetry={() => birdQ.refetch()}
|
||||||
|
>
|
||||||
|
{(bird) => <BirdSummary bird={bird} />}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="peers" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Пиры</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<QueryState
|
||||||
|
data={peers}
|
||||||
|
isLoading={peersQ.isLoading}
|
||||||
|
isError={peersQ.isError}
|
||||||
|
error={peersQ.error}
|
||||||
|
empty={peers.length === 0}
|
||||||
|
emptyTitle="Нет пиров"
|
||||||
|
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||||
|
onRetry={() => peersQ.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => <PeersTable items={items} />}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="speakers" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Спикеры</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<QueryState
|
||||||
|
data={speakers}
|
||||||
|
isLoading={speakersQ.isLoading}
|
||||||
|
isError={speakersQ.isError}
|
||||||
|
error={speakersQ.error}
|
||||||
|
empty={speakers.length === 0}
|
||||||
|
emptyTitle="Нет спикеров"
|
||||||
|
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||||
|
onRetry={() => speakersQ.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => <SpeakersTable items={items} />}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="control-plane" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle>
|
||||||
|
<CardDescription>Конфигурация tenant-level — в разделе «Настройки BIRD»</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 text-sm text-muted-foreground">
|
||||||
|
См. раздел «Настройки BIRD».
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium tabular-nums">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
<Field
|
||||||
|
label="Состояние"
|
||||||
|
value={bird.healthy === true ? 'В норме' : bird.healthy === false ? 'Проблема' : 'Н/Д'}
|
||||||
|
/>
|
||||||
|
<Field label="Сессий BGP" value={`${bird.bgp_established} / ${bird.bgp_sessions_total}`} />
|
||||||
|
{bird.message ? <p className="text-xs text-muted-foreground">{bird.message}</p> : null}
|
||||||
|
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PeersTable({ items }: { items: import('@/types/api').PeerRow[] }) {
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Имя</TableHead>
|
||||||
|
<TableHead>Neighbor</TableHead>
|
||||||
|
<TableHead>ASN</TableHead>
|
||||||
|
<TableHead>Состояние</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="font-medium">{p.name ?? p.neighbor}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{p.neighbor}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{p.remote_asn ?? '—'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={p.session_state} />
|
||||||
|
{p.session_mismatch ? (
|
||||||
|
<Badge variant="warning" className="ml-1">
|
||||||
|
mismatch
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpeakersTable({ items }: { items: import('@/types/api').SpeakerRow[] }) {
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Endpoint</TableHead>
|
||||||
|
<TableHead>Роль</TableHead>
|
||||||
|
<TableHead>Agent</TableHead>
|
||||||
|
<TableHead>BGP</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((s) => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell className="font-mono text-xs">{s.endpoint}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{s.role}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{s.live?.agent_ok === true ? (
|
||||||
|
<StatusBadge status="ok" label="online" />
|
||||||
|
) : s.live?.agent_ok === false ? (
|
||||||
|
<StatusBadge status="error" label="offline" />
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">—</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{s.live ? (
|
||||||
|
<span className="text-xs">
|
||||||
|
{s.live.bgp_established ?? 0} / {s.live.bgp_sessions_total ?? 0}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@evobgp/ui/components/select'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@evobgp/ui/components/table'
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||||
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
|
||||||
|
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
|
||||||
|
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
|
||||||
|
import { apiMutate, waitForJob } from '@/lib/api-client'
|
||||||
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/operations')({
|
||||||
|
component: OperationsComponent,
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
tab: (search.tab === 'diff' || search.tab === 'jobs' ? search.tab : 'revisions') as
|
||||||
|
| 'revisions'
|
||||||
|
| 'diff'
|
||||||
|
| 'jobs',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function OperationsComponent() {
|
||||||
|
const search = useSearch({ from: '/_auth/operations' })
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
|
||||||
|
const jobsQ = useQuery(operationsJobsQueryOptions())
|
||||||
|
const modulesQ = useQuery(overviewModulesQueryOptions())
|
||||||
|
|
||||||
|
const revisions = revisionsQ.data?.items ?? []
|
||||||
|
const jobs = jobsQ.data?.items ?? []
|
||||||
|
const nameById = moduleNameById(modulesQ.data?.items ?? [])
|
||||||
|
|
||||||
|
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
|
||||||
|
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||||
|
const failed = jobs.filter(
|
||||||
|
(j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||||
|
).length
|
||||||
|
|
||||||
|
const items: SectionCardItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Ревизий',
|
||||||
|
value: revisions.length,
|
||||||
|
icon: <Activity className="size-4" />,
|
||||||
|
hint: 'история конфигов',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Активных задач',
|
||||||
|
value: running,
|
||||||
|
icon: <Clock className="size-4" />,
|
||||||
|
hint: 'queued и running',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Задач с ошибкой',
|
||||||
|
value: failed,
|
||||||
|
icon: <AlertTriangle className="size-4" />,
|
||||||
|
hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет',
|
||||||
|
variant: failed > 0 ? 'warning' : 'default',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function refetchAll() {
|
||||||
|
void revisionsQ.refetch()
|
||||||
|
void jobsQ.refetch()
|
||||||
|
void modulesQ.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const revId = revisions[0]?.id
|
||||||
|
if (!revId) throw new Error('Нет ревизий')
|
||||||
|
const res = await apiMutate<{ job_id: string }>('/v1/apply', 'POST', { revision_id: revId })
|
||||||
|
if (!res.job_id) throw new Error('Ответ API без job_id')
|
||||||
|
const job = await waitForJob(res.job_id, { timeoutMs: 180_000 })
|
||||||
|
if (job.status !== 'succeeded') throw new Error(job.error ?? job.status)
|
||||||
|
return job
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Конфигурация успешно применена')
|
||||||
|
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось применить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const birdReloadMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await apiMutate<{ job_id: string }>('/v1/bird/reload', 'POST', {})
|
||||||
|
if (!res.job_id) throw new Error('Ответ API без job_id')
|
||||||
|
const job = await waitForJob(res.job_id, { timeoutMs: 120_000 })
|
||||||
|
if (job.status !== 'succeeded') throw new Error(job.error ?? job.status)
|
||||||
|
return job
|
||||||
|
},
|
||||||
|
onSuccess: () => toast.success('Команда birdc configure выполнена'),
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось перезагрузить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Ревизии и операции"
|
||||||
|
description="Деплой конфигурации, управление ревизиями и задачами"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
|
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert className="border-info/30 bg-info/5">
|
||||||
|
<Info className="text-info" />
|
||||||
|
<AlertTitle>Три раздела на одной странице</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>Ревизии</strong> — история конфигов и откат; <strong>Сравнение</strong> — diff
|
||||||
|
префиксов; <strong>Задачи</strong> — ingest, apply, rollback.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<ConfirmDialog
|
||||||
|
trigger={
|
||||||
|
<Button variant="default" size="sm" disabled={applyMutation.isPending}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="Применить конфигурацию на всех спикерах?"
|
||||||
|
description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator."
|
||||||
|
confirmLabel="Применить"
|
||||||
|
onConfirm={() => applyMutation.mutate()}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline" size="sm" disabled={birdReloadMutation.isPending}>
|
||||||
|
BIRD reload
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="Перезагрузить BIRD?"
|
||||||
|
description="BIRD перезагрузит конфигурацию. Требуется роль operator."
|
||||||
|
confirmLabel="Перезагрузить"
|
||||||
|
onConfirm={() => birdReloadMutation.mutate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||||
|
|
||||||
|
<Tabs defaultValue={search.tab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
||||||
|
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="revisions" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">История ревизий</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<QueryState
|
||||||
|
data={revisions}
|
||||||
|
isLoading={revisionsQ.isLoading}
|
||||||
|
isError={revisionsQ.isError}
|
||||||
|
error={revisionsQ.error}
|
||||||
|
empty={revisions.length === 0}
|
||||||
|
emptyTitle="Нет ревизий"
|
||||||
|
onRetry={() => revisionsQ.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => <RevisionsTable items={items} qc={qc} />}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="diff" className="mt-4">
|
||||||
|
<DiffTab revisions={revisions} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="jobs" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Задачи</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<QueryState
|
||||||
|
data={jobs}
|
||||||
|
isLoading={jobsQ.isLoading}
|
||||||
|
isError={jobsQ.isError}
|
||||||
|
error={jobsQ.error}
|
||||||
|
empty={jobs.length === 0}
|
||||||
|
emptyTitle="Нет задач"
|
||||||
|
onRetry={() => jobsQ.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => <JobsTable items={items} nameById={nameById} qc={qc} />}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevisionsTable({
|
||||||
|
items,
|
||||||
|
qc,
|
||||||
|
}: {
|
||||||
|
items: import('@/types/api').RevisionRow[]
|
||||||
|
qc: import('@tanstack/react-query').QueryClient
|
||||||
|
}) {
|
||||||
|
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 : 'Не удалось откатить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Создана</TableHead>
|
||||||
|
<TableHead>Префиксов</TableHead>
|
||||||
|
<TableHead className="w-24" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="font-mono text-xs">{r.id.slice(0, 12)}…</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{new Date(r.created_at).toLocaleString('ru-RU')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm tabular-nums">
|
||||||
|
{r.materialized_prefix_count}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ConfirmDialog
|
||||||
|
trigger={
|
||||||
|
<Button variant="ghost" size="icon-sm" className="text-destructive">
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title={`Откатиться к ревизии ${r.id.slice(0, 8)}…?`}
|
||||||
|
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
|
||||||
|
confirmLabel="Откатить"
|
||||||
|
destructive
|
||||||
|
onConfirm={() => rollbackMutation.mutate(r.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobsTable({
|
||||||
|
items,
|
||||||
|
nameById,
|
||||||
|
qc,
|
||||||
|
}: {
|
||||||
|
items: JobRow[]
|
||||||
|
nameById: Map<string, string>
|
||||||
|
qc: import('@tanstack/react-query').QueryClient
|
||||||
|
}) {
|
||||||
|
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 : 'Не удалось отменить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Вид</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
<TableHead>Создана</TableHead>
|
||||||
|
<TableHead>Завершена</TableHead>
|
||||||
|
<TableHead className="w-20" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((j) => (
|
||||||
|
<TableRow key={j.job_id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span>{j.kind}</span>
|
||||||
|
{j.meta?.module_id ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{nameById.get(String(j.meta.module_id)) ?? String(j.meta.module_id)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadgeColored status={j.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{j.status === 'running' || j.status === 'queued' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => cancelMutation.mutate(j.job_id)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) {
|
||||||
|
const [a, setA] = useState('')
|
||||||
|
const [b, setB] = useState('')
|
||||||
|
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
|
||||||
|
const revisionItems = useMemo(
|
||||||
|
() =>
|
||||||
|
revisions.map((r) => ({
|
||||||
|
value: r.id,
|
||||||
|
label: `${r.id.slice(0, 12)}…`,
|
||||||
|
})),
|
||||||
|
[revisions],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">Сравнение ревизий</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4 p-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
||||||
|
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Выберите" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{revisions.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>
|
||||||
|
{r.id.slice(0, 12)}…
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
||||||
|
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Выберите" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{revisions.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>
|
||||||
|
{r.id.slice(0, 12)}…
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
||||||
|
Сравнить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<QueryState
|
||||||
|
data={diffQ.data}
|
||||||
|
isLoading={diffQ.isFetching}
|
||||||
|
isError={diffQ.isError}
|
||||||
|
error={diffQ.error}
|
||||||
|
empty={!diffQ.data}
|
||||||
|
emptyTitle="Выберите две ревизии"
|
||||||
|
onRetry={() => diffQ.refetch()}
|
||||||
|
>
|
||||||
|
{(diff) => <DiffView diff={diff} />}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffView({ diff }: { diff: import('@/types/api').RevisionDiff }) {
|
||||||
|
const added = diff.prefixes?.added ?? (diff.added as string[]) ?? []
|
||||||
|
const removed = diff.prefixes?.removed ?? (diff.removed as string[]) ?? []
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-success">Добавлено: {added.length}</p>
|
||||||
|
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||||
|
{added.join('\n')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-destructive">Удалено: {removed.length}</p>
|
||||||
|
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||||
|
{removed.join('\n')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { AlertTriangle, Clock, Info, ListTodo, RefreshCw } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||||
|
import { Badge } from '@evobgp/ui/components/badge'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@evobgp/ui/components/table'
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||||
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
|
||||||
|
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||||
|
import { modulesListQueryOptions } from '@/queries/modules'
|
||||||
|
import { apiMutate } from '@/lib/api-client'
|
||||||
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/schedule')({
|
||||||
|
component: ScheduleComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ScheduleComponent() {
|
||||||
|
const modulesQ = useQuery(modulesListQueryOptions())
|
||||||
|
const jobsQ = useQuery(operationsJobsQueryOptions())
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [refreshing, setRefreshing] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const modules = modulesQ.data?.items ?? []
|
||||||
|
const jobs = jobsQ.data?.items ?? []
|
||||||
|
const loading = modulesQ.isLoading || jobsQ.isLoading
|
||||||
|
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||||
|
const failed = jobs.filter((j) =>
|
||||||
|
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||||
|
).length
|
||||||
|
|
||||||
|
const items: SectionCardItem[] = [
|
||||||
|
{ label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' },
|
||||||
|
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'queued и running' },
|
||||||
|
{
|
||||||
|
label: 'С ошибкой',
|
||||||
|
value: failed,
|
||||||
|
icon: <AlertTriangle className="size-4" />,
|
||||||
|
hint: failed > 0 ? 'требуют внимания' : 'без ошибок',
|
||||||
|
variant: failed > 0 ? 'warning' : 'default',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const refreshMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => apiMutate<{ job_id?: string }>(`/v1/modules/${id}/refresh`, 'POST'),
|
||||||
|
onMutate: (id) => setRefreshing((s) => ({ ...s, [id]: true })),
|
||||||
|
onSuccess: (data, id) => {
|
||||||
|
if (data === undefined) toast.message('Обновление не требуется (тип IP_RANGES)')
|
||||||
|
else toast.success('Задача поставлена в очередь')
|
||||||
|
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['modules'] })
|
||||||
|
setRefreshing((s) => ({ ...s, [id]: false }))
|
||||||
|
},
|
||||||
|
onError: (e, id) => {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось запустить')
|
||||||
|
setRefreshing((s) => ({ ...s, [id]: false }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Расписание и задачи"
|
||||||
|
description="Интервалы обновления модулей и ручной запуск"
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void modulesQ.refetch()
|
||||||
|
void jobsQ.refetch()
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert className="border-info/30 bg-info/5">
|
||||||
|
<Info className="text-info" />
|
||||||
|
<AlertTitle>Как работает расписание</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Планировщик использует <code className="text-xs">refresh_interval_sec</code> и опционально{' '}
|
||||||
|
<code className="text-xs">cron_expr</code>. Ручной запуск —{' '}
|
||||||
|
<code className="text-xs">POST /v1/modules/{id}/refresh</code>.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Модули</CardTitle>
|
||||||
|
<CardDescription>Расписание обновления и ручной запуск ingest</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<QueryState
|
||||||
|
data={modules}
|
||||||
|
isLoading={modulesQ.isLoading}
|
||||||
|
isError={modulesQ.isError}
|
||||||
|
error={modulesQ.error}
|
||||||
|
empty={modules.length === 0}
|
||||||
|
emptyTitle="Нет модулей"
|
||||||
|
onRetry={() => modulesQ.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Модуль</TableHead>
|
||||||
|
<TableHead>Тип</TableHead>
|
||||||
|
<TableHead>Расписание</TableHead>
|
||||||
|
<TableHead>Обновлено</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
<TableHead className="w-32 text-right" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((m) => (
|
||||||
|
<TableRow key={m.id}>
|
||||||
|
<TableCell className="font-medium">{m.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{m.type}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{m.cron_expr ?? (m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{m.enabled ? (
|
||||||
|
<Badge variant="default">Вкл</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Выкл</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<LoadingButton
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
loading={!!refreshing[m.id]}
|
||||||
|
onClick={() => refreshMutation.mutate(m.id)}
|
||||||
|
>
|
||||||
|
<RefreshCw />
|
||||||
|
Обновить
|
||||||
|
</LoadingButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Задачи</CardTitle>
|
||||||
|
<CardDescription>Последние задачи из API</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
|
||||||
|
const refresh = jobs.filter((j) => j.kind === 'module_refresh')
|
||||||
|
const failed = jobs.filter((j) =>
|
||||||
|
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="all">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="all" className="mt-0">
|
||||||
|
<JobsTable items={jobs} loading={loading} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="refresh" className="mt-0">
|
||||||
|
<JobsTable items={refresh} loading={loading} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="failed" className="mt-0">
|
||||||
|
<JobsTable items={failed} loading={loading} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobsTable({ items, loading }: { items: JobRow[]; loading: boolean }) {
|
||||||
|
if (loading) return <div className="p-6 text-center text-sm text-muted-foreground">Загрузка…</div>
|
||||||
|
if (items.length === 0)
|
||||||
|
return <div className="p-6 text-center text-sm text-muted-foreground">Нет задач</div>
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Вид</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
<TableHead>Создана</TableHead>
|
||||||
|
<TableHead>Завершена</TableHead>
|
||||||
|
<TableHead>Ошибка</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((j) => (
|
||||||
|
<TableRow key={j.job_id}>
|
||||||
|
<TableCell className="font-medium">{j.kind}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
j.status === 'succeeded'
|
||||||
|
? 'default'
|
||||||
|
: j.status === 'failed'
|
||||||
|
? 'destructive'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{j.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-xs truncate text-xs text-destructive">
|
||||||
|
{j.error ?? ''}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@evobgp/ui/components/select'
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Save } from 'lucide-react'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/settings')({
|
||||||
|
component: SettingsComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
const THEME_SELECT_ITEMS = [
|
||||||
|
{ value: 'light', label: 'Светлая' },
|
||||||
|
{ value: 'dark', label: 'Тёмная' },
|
||||||
|
{ value: 'system', label: 'Как в системе' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function SettingsComponent() {
|
||||||
|
const { data: session } = useQuery(authSessionQueryOptions())
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const [token, setTokenValue] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''
|
||||||
|
setTokenValue(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function saveTokenHandler() {
|
||||||
|
const t = token.trim()
|
||||||
|
setToken(t || null)
|
||||||
|
toast.success('Токен сохранён')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Настройки"
|
||||||
|
description="Параметры интерфейса и подключения браузера к API."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Подключение к API</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant — в
|
||||||
|
разделе «Права доступа».
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="token">Токен для запросов</Label>
|
||||||
|
<Input
|
||||||
|
id="token"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setTokenValue(e.target.value)}
|
||||||
|
placeholder="Bearer …"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<LoadingButton onClick={saveTokenHandler}>
|
||||||
|
<Save />
|
||||||
|
Сохранить токен
|
||||||
|
</LoadingButton>
|
||||||
|
{session ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
|
||||||
|
<code className="font-mono">{session.role}</code>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Оформление</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Тема интерфейса. Быстрый переключатель также доступен в боковой панели.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="theme-select">Тема</Label>
|
||||||
|
<Select
|
||||||
|
items={[...THEME_SELECT_ITEMS]}
|
||||||
|
value={theme ?? 'system'}
|
||||||
|
onValueChange={(v) => v && setTheme(v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="theme-select" className="w-full max-w-xs">
|
||||||
|
<SelectValue placeholder="Выберите тему" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="light">Светлая</SelectItem>
|
||||||
|
<SelectItem value="dark">Тёмная</SelectItem>
|
||||||
|
<SelectItem value="system">Как в системе</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Info, Save } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@evobgp/ui/components/select'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@evobgp/ui/components/table'
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BIRD_SETTING_KEYS,
|
||||||
|
REVISION_SETTING_KEYS,
|
||||||
|
RUNTIME_LOGS_SETTING_KEYS,
|
||||||
|
buildPayload,
|
||||||
|
partitionSettings,
|
||||||
|
settingsQueryOptions,
|
||||||
|
type BirdSettingKey,
|
||||||
|
} from '@/queries/settings'
|
||||||
|
import { apiMutate } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/tenant-settings')({
|
||||||
|
component: TenantSettingsComponent,
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
|
||||||
|
? search.tab
|
||||||
|
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const RUNTIME_LOGS_ENABLED_ITEMS = [
|
||||||
|
{ value: 'true', label: 'Вкл' },
|
||||||
|
{ value: 'false', label: 'Выкл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const RUNTIME_LOGS_MODE_ITEMS = [
|
||||||
|
{ value: 'truncate', label: 'truncate — обнулить' },
|
||||||
|
{ value: 'delete', label: 'delete — удалить файл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||||
|
bird_router_id: 'Router ID',
|
||||||
|
bird_local_ipv4: 'Локальный IPv4',
|
||||||
|
bird_local_ipv6: 'Локальный IPv6',
|
||||||
|
bird_local_asn: 'Локальный ASN',
|
||||||
|
bird_bgp_source_ipv4: 'BGP source IPv4',
|
||||||
|
bird_bgp_source_ipv6: 'BGP source IPv6',
|
||||||
|
}
|
||||||
|
|
||||||
|
function TenantSettingsComponent() {
|
||||||
|
const search = useSearch({ from: '/_auth/tenant-settings' })
|
||||||
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
||||||
|
|
||||||
|
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
||||||
|
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
||||||
|
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (partitioned) {
|
||||||
|
setBirdForm({ ...partitioned.bird })
|
||||||
|
setRevisionForm({ ...partitioned.revision })
|
||||||
|
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settingsQ.data])
|
||||||
|
|
||||||
|
const patchMutation = useMutation({
|
||||||
|
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
||||||
|
apiMutate('/v1/settings', 'PATCH', payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Параметры сохранены')
|
||||||
|
void qc.invalidateQueries({ queryKey: ['settings'] })
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function saveBird() {
|
||||||
|
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
||||||
|
}
|
||||||
|
function saveRevision() {
|
||||||
|
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
||||||
|
}
|
||||||
|
function saveRuntimeLogs() {
|
||||||
|
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Параметры tenant"
|
||||||
|
description="Параметры control plane для текущего tenant (API /v1/settings)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert className="border-info/30 bg-info/5">
|
||||||
|
<Info className="text-info" />
|
||||||
|
<AlertTitle>Operator-only</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Изменение значений через <code className="text-xs">PATCH /v1/settings</code> требует роли
|
||||||
|
operator. При отсутствии прав API вернёт 403.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Tabs defaultValue={search.tab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="bird">BIRD</TabsTrigger>
|
||||||
|
<TabsTrigger value="revision">Ревизии</TabsTrigger>
|
||||||
|
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
||||||
|
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="bird" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>BIRD control plane</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
|
||||||
|
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Alert className="border-info/30 bg-info/5">
|
||||||
|
<Info className="text-info" />
|
||||||
|
<AlertTitle>Подстановка в конфиг</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса).
|
||||||
|
Пиры и спикеры настраиваются в разделе «Сеть».
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-64" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{BIRD_SETTING_KEYS.map((key) => (
|
||||||
|
<div key={key} className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor={key}>{BIRD_LABELS[key]}</Label>
|
||||||
|
<Input
|
||||||
|
id={key}
|
||||||
|
value={birdForm[key] ?? ''}
|
||||||
|
onChange={(e) => setBirdForm((s) => ({ ...s, [key]: e.target.value }))}
|
||||||
|
placeholder={BIRD_LABELS[key]}
|
||||||
|
/>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">{key}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="revision" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Ревизии</CardTitle>
|
||||||
|
<CardDescription>Время хранения ревизий в БД</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-32" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div className="flex max-w-sm flex-col gap-1.5">
|
||||||
|
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label>
|
||||||
|
<Input
|
||||||
|
id="revision_retention_minutes"
|
||||||
|
type="number"
|
||||||
|
value={revisionForm.revision_retention_minutes ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRevisionForm((s) => ({
|
||||||
|
...s,
|
||||||
|
revision_retention_minutes: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
|
revision_retention_minutes
|
||||||
|
</p>
|
||||||
|
<LoadingButton
|
||||||
|
className="mt-2 w-fit"
|
||||||
|
onClick={saveRevision}
|
||||||
|
loading={patchMutation.isPending}
|
||||||
|
>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="runtime-logs" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Файловые логи</CardTitle>
|
||||||
|
<CardDescription>Автоматическая очистка логов</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-48" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Авто-очистка включена</Label>
|
||||||
|
<Select
|
||||||
|
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
v &&
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_enabled: v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Выберите" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">Вкл</SelectItem>
|
||||||
|
<SelectItem value="false">Выкл</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
|
runtime_logs_auto_enabled
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
|
||||||
|
<Input
|
||||||
|
id="runtime_logs_max_file_mb"
|
||||||
|
type="number"
|
||||||
|
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_max_file_mb: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
|
runtime_logs_max_file_mb
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label>
|
||||||
|
<Input
|
||||||
|
id="runtime_logs_auto_schedule"
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_schedule: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
|
runtime_logs_auto_schedule
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Режим очистки</Label>
|
||||||
|
<Select
|
||||||
|
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
v &&
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_mode: v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Выберите" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="truncate">truncate — обнулить</SelectItem>
|
||||||
|
<SelectItem value="delete">delete — удалить файл</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
|
runtime_logs_auto_mode
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="additional" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Дополнительные параметры</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Параметры вне стандартных групп (readonly — изменяются только через API)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<QueryState
|
||||||
|
data={partitioned?.additional ?? []}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
empty={(partitioned?.additional ?? []).length === 0}
|
||||||
|
emptyTitle="Нет дополнительных параметров"
|
||||||
|
skeleton={<div className="h-32" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Ключ</TableHead>
|
||||||
|
<TableHead>Значение</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell className="font-mono text-xs">{row.key}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{row.value}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/')({
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: '/dashboard' })
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
// ---- Pagination ----
|
||||||
|
export type Page<T> = {
|
||||||
|
items: T[]
|
||||||
|
next_cursor: string | null
|
||||||
|
has_more: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Modules ----
|
||||||
|
export type ModuleType = 'AS_PREFIXES' | 'CDN_CIDRS' | 'DOMAINS' | 'IP_RANGES'
|
||||||
|
|
||||||
|
export type DohResolverPolicy = 'primary_only' | 'failover' | 'union'
|
||||||
|
|
||||||
|
export type ModuleRow = {
|
||||||
|
id: string
|
||||||
|
type: ModuleType
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
priority: number
|
||||||
|
refresh_interval_sec: number | null
|
||||||
|
cron_expr: string | null
|
||||||
|
default_community_id: string | null
|
||||||
|
/** @deprecated use doh_profile_ids */
|
||||||
|
doh_profile_id: string | null
|
||||||
|
doh_profile_ids: string[]
|
||||||
|
doh_resolver_policy: DohResolverPolicy
|
||||||
|
last_refreshed_at: string | null
|
||||||
|
}
|
||||||
|
export type ModulesResponse = Page<ModuleRow>
|
||||||
|
|
||||||
|
export type RouterListsCatalogResponse = {
|
||||||
|
modules: { items: ModuleRow[] }
|
||||||
|
domains: { items: { module_id: string; entry: DomainEntry }[] }
|
||||||
|
asns: { items: { module_id: string; entry: AsEntry }[] }
|
||||||
|
ip_ranges: { items: { module_id: string; entry: IpRangeEntry }[] }
|
||||||
|
communities: { items: BgpCommunity[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModuleCreate = {
|
||||||
|
type: ModuleType
|
||||||
|
name: string
|
||||||
|
enabled?: boolean
|
||||||
|
priority?: number
|
||||||
|
doh_profile_id?: string | null
|
||||||
|
doh_profile_ids?: string[]
|
||||||
|
doh_resolver_policy?: DohResolverPolicy
|
||||||
|
refresh_interval_sec?: number | null
|
||||||
|
cron_expr?: string | null
|
||||||
|
default_community_id?: string | null
|
||||||
|
}
|
||||||
|
export type ModulePatch = Partial<Omit<ModuleCreate, 'type'>>
|
||||||
|
|
||||||
|
// ---- AS Entries ----
|
||||||
|
export type AsEntry = {
|
||||||
|
id: string
|
||||||
|
asn: number
|
||||||
|
community_id: string | null
|
||||||
|
/** Имя/держатель AS (RIPEstat), после успешного обновления модуля */
|
||||||
|
asn_name?: string | null
|
||||||
|
/** Число объявленных префиксов на момент последнего резолва */
|
||||||
|
prefix_count?: number | null
|
||||||
|
/** ISO-время последнего успешного резолва ASN */
|
||||||
|
asn_resolved_at?: string | null
|
||||||
|
}
|
||||||
|
export type AsEntryCreate = {
|
||||||
|
asn: number
|
||||||
|
community_id?: string | null
|
||||||
|
}
|
||||||
|
export type AsEntryPatch = {
|
||||||
|
asn?: number
|
||||||
|
community_id?: string | null
|
||||||
|
}
|
||||||
|
export type AsEntriesResponse = Page<AsEntry>
|
||||||
|
|
||||||
|
// ---- CDN Sources ----
|
||||||
|
export type CdnSource = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
source_kind: string
|
||||||
|
prefix_path: string
|
||||||
|
community_id: string | null
|
||||||
|
refresh_interval_sec: number | null
|
||||||
|
last_refreshed_at: string | null
|
||||||
|
}
|
||||||
|
export type CdnSourceCreate = {
|
||||||
|
url: string
|
||||||
|
source_kind: string
|
||||||
|
prefix_path?: string
|
||||||
|
community_id?: string | null
|
||||||
|
}
|
||||||
|
export type CdnSourcePatch = Partial<CdnSourceCreate> & { refresh_interval_sec?: number | null }
|
||||||
|
export type CdnSourcesResponse = Page<CdnSource>
|
||||||
|
|
||||||
|
export type CdnPreviewResponse = {
|
||||||
|
items: string[]
|
||||||
|
total: number
|
||||||
|
truncated: boolean
|
||||||
|
source_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Domain Entries ----
|
||||||
|
export type DomainEntry = {
|
||||||
|
id: string
|
||||||
|
fqdn: string
|
||||||
|
community_id: string | null
|
||||||
|
}
|
||||||
|
export type DomainEntryCreate = {
|
||||||
|
fqdn: string
|
||||||
|
community_id?: string | null
|
||||||
|
}
|
||||||
|
export type DomainEntriesResponse = Page<DomainEntry>
|
||||||
|
|
||||||
|
// ---- IP Range Entries ----
|
||||||
|
export type IpRangeEntry = {
|
||||||
|
id: string
|
||||||
|
prefix: string
|
||||||
|
community_id: string
|
||||||
|
}
|
||||||
|
export type IpRangeEntryCreate = {
|
||||||
|
prefix: string
|
||||||
|
community_id: string
|
||||||
|
}
|
||||||
|
export type IpRangeEntriesResponse = Page<IpRangeEntry>
|
||||||
|
|
||||||
|
// ---- DoH Profiles ----
|
||||||
|
export type DohProfile = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
url: string
|
||||||
|
timeout_ms: number | null
|
||||||
|
vault_secret_ref: string | null
|
||||||
|
}
|
||||||
|
export type DohProfileCreate = {
|
||||||
|
name?: string
|
||||||
|
url: string
|
||||||
|
timeout_ms?: number | null
|
||||||
|
vault_secret_ref?: string | null
|
||||||
|
}
|
||||||
|
export type DohProfilePatch = Partial<DohProfileCreate>
|
||||||
|
export type DohProfilesResponse = Page<DohProfile>
|
||||||
|
|
||||||
|
// ---- Communities ----
|
||||||
|
export type BgpCommunity = {
|
||||||
|
id: string
|
||||||
|
community: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
export type BgpCommunityCreate = {
|
||||||
|
community: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
export type BgpCommunityPatch = Partial<BgpCommunityCreate>
|
||||||
|
export type CommunitiesResponse = Page<BgpCommunity>
|
||||||
|
|
||||||
|
// ---- Peers ----
|
||||||
|
export type PeerSessionOnSpeaker = {
|
||||||
|
speaker_id: string
|
||||||
|
label: string
|
||||||
|
state: string
|
||||||
|
poll_error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PeerRow = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
neighbor: string
|
||||||
|
remote_asn?: number
|
||||||
|
enabled?: boolean
|
||||||
|
session_state: string
|
||||||
|
bgp_speaker_id: string | null
|
||||||
|
connected_speaker_id?: string | null
|
||||||
|
connected_speaker_label?: string
|
||||||
|
session_on_speakers?: PeerSessionOnSpeaker[]
|
||||||
|
established_on_speakers?: PeerSessionOnSpeaker[]
|
||||||
|
session_mismatch?: boolean
|
||||||
|
}
|
||||||
|
export type LiveSpeakerPoll = {
|
||||||
|
speaker_id: string
|
||||||
|
label: string
|
||||||
|
ok: boolean
|
||||||
|
session_count: number
|
||||||
|
poll_error?: string
|
||||||
|
}
|
||||||
|
export type PeersResponse = Page<PeerRow> & { live_speaker_poll?: LiveSpeakerPoll[] }
|
||||||
|
export type BgpPeerCreate = {
|
||||||
|
name?: string
|
||||||
|
neighbor: string
|
||||||
|
remote_asn: number
|
||||||
|
bgp_speaker_id?: string | null
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
export type BgpPeerPatch = Partial<BgpPeerCreate>
|
||||||
|
|
||||||
|
// ---- Speakers ----
|
||||||
|
export type BgpSessionLive = {
|
||||||
|
name: string
|
||||||
|
neighbor?: string
|
||||||
|
state: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeakerLiveStatus = {
|
||||||
|
label?: string
|
||||||
|
agent_ok?: boolean
|
||||||
|
agent_error?: string
|
||||||
|
agent_last_sync_at?: string
|
||||||
|
agent_last_applied_revision_id?: string
|
||||||
|
bgp_poll_ok?: boolean
|
||||||
|
bgp_poll_error?: string
|
||||||
|
bgp_sessions_total?: number
|
||||||
|
bgp_established?: number
|
||||||
|
sessions?: BgpSessionLive[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeakerRow = {
|
||||||
|
id: string
|
||||||
|
role: string
|
||||||
|
endpoint: string
|
||||||
|
last_applied_revision_id: string | null
|
||||||
|
published_revision_id?: string | null
|
||||||
|
published_at?: string | null
|
||||||
|
agent_domain?: string
|
||||||
|
node_ipv4?: string
|
||||||
|
bird_bgp_source_ipv4?: string
|
||||||
|
dispatch_status?: string
|
||||||
|
sync_status?: string
|
||||||
|
last_dispatch_at?: string | null
|
||||||
|
last_dispatch_error?: string | null
|
||||||
|
meta_json?: Record<string, unknown>
|
||||||
|
agent_secret?: string
|
||||||
|
live?: SpeakerLiveStatus
|
||||||
|
}
|
||||||
|
export type SpeakersResponse = Page<SpeakerRow>
|
||||||
|
export type BgpSpeakerCreate = {
|
||||||
|
endpoint: string
|
||||||
|
role?: string
|
||||||
|
meta_json?: string
|
||||||
|
}
|
||||||
|
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>
|
||||||
|
|
||||||
|
export type BundleSigningPublicKey = {
|
||||||
|
public_key_base64: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Revisions ----
|
||||||
|
export type RevisionRow = {
|
||||||
|
id: string
|
||||||
|
content_hash: string
|
||||||
|
created_at: string
|
||||||
|
parent_revision_id: string | null
|
||||||
|
materialized_prefix_count: number
|
||||||
|
module_id: string | null
|
||||||
|
}
|
||||||
|
export type RevisionsResponse = Page<RevisionRow>
|
||||||
|
|
||||||
|
export type RevisionPrefix = {
|
||||||
|
/** Обычно CIDR; для AS-модуля в снимке ревизии — строка вида `as:<номер_asn>`. */
|
||||||
|
prefix: string
|
||||||
|
/** Источник материализации (например, domain:<fqdn>, as:<asn>, cdn:<source_id>, ip_range). */
|
||||||
|
source?: string
|
||||||
|
community_id?: string | null
|
||||||
|
}
|
||||||
|
export type RevisionPrefixesResponse = Page<RevisionPrefix>
|
||||||
|
|
||||||
|
export type RevisionPreview = {
|
||||||
|
id: string
|
||||||
|
prefixes?: RevisionPrefix[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ответ GET /v1/revisions/{a}/diff/{b}: префиксы в `prefixes` (источник правды в бэкенде). */
|
||||||
|
export type RevisionDiff = {
|
||||||
|
revision_a?: string
|
||||||
|
revision_b?: string
|
||||||
|
prefixes?: {
|
||||||
|
added: string[]
|
||||||
|
removed: string[]
|
||||||
|
unchanged_count?: number
|
||||||
|
}
|
||||||
|
/** Устаревший/нормализованный вид — см. нормализацию в UI */
|
||||||
|
added?: (string | RevisionPrefix)[]
|
||||||
|
removed?: (string | RevisionPrefix)[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BIRD (локальный birdc на хосте с API, если задан EVOBGP_BIRDC_SOCKET) ----
|
||||||
|
export type BirdStatus = {
|
||||||
|
birdc_configured: boolean
|
||||||
|
message?: string
|
||||||
|
error?: string
|
||||||
|
protocols_excerpt?: string
|
||||||
|
bgp_sessions_total: number
|
||||||
|
bgp_established: number
|
||||||
|
healthy: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Jobs ----
|
||||||
|
export type JobRow = {
|
||||||
|
job_id: string
|
||||||
|
kind: string
|
||||||
|
status: string
|
||||||
|
idempotency_key?: string | null
|
||||||
|
created_at?: string
|
||||||
|
started_at?: string | null
|
||||||
|
finished_at?: string | null
|
||||||
|
error?: string | null
|
||||||
|
meta?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
export type JobsResponse = Page<JobRow>
|
||||||
|
|
||||||
|
// ---- Settings ----
|
||||||
|
export type AppSettings = Record<string, unknown>
|
||||||
|
|
||||||
|
// ---- Auth / API keys ----
|
||||||
|
export type AuthSession = {
|
||||||
|
tenant_id: string
|
||||||
|
role: 'viewer' | 'editor' | 'operator' | 'node'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiKeyRole = AuthSession['role']
|
||||||
|
|
||||||
|
export type ApiKey = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: ApiKeyRole
|
||||||
|
prefix: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
expires_at: string | null
|
||||||
|
revoked_at: string | null
|
||||||
|
last_used_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiKeysResponse = Page<ApiKey>
|
||||||
|
|
||||||
|
export type ApiKeyCreate = {
|
||||||
|
name: string
|
||||||
|
role: ApiKeyRole
|
||||||
|
expires_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiKeyCreated = ApiKey & { token: string }
|
||||||
|
|
||||||
|
// ---- AsyncJobAccepted (RFC 9457 companion: 202 with job_id) ----
|
||||||
|
export type AsyncJobAccepted = {
|
||||||
|
job_id: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@/hooks/use-mobile": ["../../packages/ui/src/hooks/use-mobile.ts"],
|
||||||
|
"@evobgp/ui/components/*": ["../../packages/ui/src/components/*"],
|
||||||
|
"@evobgp/ui/hooks/*": ["../../packages/ui/src/hooks/*"],
|
||||||
|
"@evobgp/ui/lib/utils": ["../../packages/ui/src/lib/utils.ts"],
|
||||||
|
"@evobgp/ui/globals.css": ["../../packages/ui/src/styles/globals.css"]
|
||||||
|
},
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/layout/app-shell.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { TanStackRouterVite as TanStackRouterPlugin } from '@tanstack/router-plugin/vite'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
TanStackRouterPlugin({ target: 'react', autoCodeSplitting: true }),
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: [
|
||||||
|
// shadcn components inside packages/ui import '@/hooks/use-mobile' (their own alias).
|
||||||
|
// Vite doesn't read tsconfig paths from packages/ui; expose a fallback so those imports
|
||||||
|
// resolve to packages/ui/src/hooks instead of apps/web/src/hooks.
|
||||||
|
{
|
||||||
|
find: '@/hooks/use-mobile',
|
||||||
|
replacement: path.resolve(__dirname, '../../packages/ui/src/hooks/use-mobile.ts'),
|
||||||
|
},
|
||||||
|
{ find: '@', replacement: path.resolve(__dirname, './src') },
|
||||||
|
{ find: '@evobgp/ui/components', replacement: path.resolve(__dirname, '../../packages/ui/src/components') },
|
||||||
|
{ find: '@evobgp/ui/hooks', replacement: path.resolve(__dirname, '../../packages/ui/src/hooks') },
|
||||||
|
{ find: '@evobgp/ui/lib/utils', replacement: path.resolve(__dirname, '../../packages/ui/src/lib/utils.ts') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/v1': { target: 'http://127.0.0.1:8080', changeOrigin: true },
|
||||||
|
'/metrics': { target: 'http://127.0.0.1:8080', changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -18,7 +18,7 @@ apt-get install -y --no-install-recommends \
|
|||||||
|
|
||||||
cd /tmp
|
cd /tmp
|
||||||
rm -rf "bird-${BIRD_VERSION}"
|
rm -rf "bird-${BIRD_VERSION}"
|
||||||
curl -fsSL "https://bird.network.cz/download/bird-${BIRD_VERSION}.tar.gz" | tar xz
|
curl -fsSL "https://bird.nic.cz/download/bird-${BIRD_VERSION}.tar.gz" | tar xz
|
||||||
cd "bird-${BIRD_VERSION}"
|
cd "bird-${BIRD_VERSION}"
|
||||||
./configure --prefix=/usr/local --enable-client
|
./configure --prefix=/usr/local --enable-client
|
||||||
make -j"$(nproc)"
|
make -j"$(nproc)"
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# Статическая панель EvoBGP (SvelteKit) + nginx.
|
# React + Vite статическая панель EvoBGP + nginx.
|
||||||
# Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build).
|
# Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build).
|
||||||
|
# Сборка ведётся из корня репозитория (context = "../.." в docker-bake.hcl).
|
||||||
|
|
||||||
FROM public.ecr.aws/docker/library/node:22-alpine AS deps
|
FROM public.ecr.aws/docker/library/node:22-alpine AS deps
|
||||||
WORKDIR /web
|
WORKDIR /repo
|
||||||
COPY web/package.json web/package-lock.json ./
|
RUN corepack enable && corepack prepare [email protected] --activate
|
||||||
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
npm ci
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
COPY web/ ./
|
COPY tsconfig.base.json ./
|
||||||
RUN npm run build
|
COPY apps/web/ ./apps/web/
|
||||||
|
COPY packages/ui/ ./packages/ui/
|
||||||
|
RUN pnpm --filter @evobgp/web run build
|
||||||
|
|
||||||
FROM public.ecr.aws/docker/library/nginx:1.27-alpine AS web
|
FROM public.ecr.aws/docker/library/nginx:1.27-alpine AS web
|
||||||
ARG EVOBGP_UPSTREAM=evobgp-api
|
ARG EVOBGP_UPSTREAM=evobgp-api
|
||||||
COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf
|
COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf
|
||||||
RUN sed -e "s/evobgp-api/${EVOBGP_UPSTREAM}/g" /tmp/nginx-default.conf > /etc/nginx/conf.d/default.conf \
|
RUN sed -e "s/evobgp-api/${EVOBGP_UPSTREAM}/g" /tmp/nginx-default.conf > /etc/nginx/conf.d/default.conf \
|
||||||
&& rm -f /tmp/nginx-default.conf
|
&& rm -f /tmp/nginx-default.conf
|
||||||
COPY --from=web-artifacts /web/build /usr/share/nginx/html
|
COPY --from=web-artifacts /repo/apps/web/dist /usr/share/nginx/html
|
||||||
|
|||||||
+40
-4
@@ -184,6 +184,10 @@ type Registry struct {
|
|||||||
onTerminal func(j *Job)
|
onTerminal func(j *Job)
|
||||||
onEnqueued func(j *Job)
|
onEnqueued func(j *Job)
|
||||||
onRunning func(j *Job)
|
onRunning func(j *Job)
|
||||||
|
// inflightRefresh counts refresh-kind jobs (module_refresh, tenant_refresh) per tenant that
|
||||||
|
// have been enqueued but not yet finalized in finishModuleRefreshSuccess. Used for deterministic
|
||||||
|
// deploy coalescing under tenantRefreshMu (instead of polling job statuses).
|
||||||
|
inflightRefresh map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
type idempoKey struct {
|
type idempoKey struct {
|
||||||
@@ -194,10 +198,11 @@ type idempoKey struct {
|
|||||||
func NewRegistry(workerStart func(j *Job)) *Registry {
|
func NewRegistry(workerStart func(j *Job)) *Registry {
|
||||||
maxWorkers := registryMaxConcurrentJobs()
|
maxWorkers := registryMaxConcurrentJobs()
|
||||||
return &Registry{
|
return &Registry{
|
||||||
byID: make(map[string]*Job),
|
byID: make(map[string]*Job),
|
||||||
byIdempo: make(map[idempoKey]*Job),
|
byIdempo: make(map[idempoKey]*Job),
|
||||||
workerStart: workerStart,
|
workerStart: workerStart,
|
||||||
workerSem: make(chan struct{}, maxWorkers),
|
workerSem: make(chan struct{}, maxWorkers),
|
||||||
|
inflightRefresh: make(map[string]int),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +345,9 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
|||||||
r.byIdempo[idempoKey{tenant: tenantID, key: *idempotencyKey}] = j
|
r.byIdempo[idempoKey{tenant: tenantID, key: *idempotencyKey}] = j
|
||||||
}
|
}
|
||||||
r.byID[j.ID] = j
|
r.byID[j.ID] = j
|
||||||
|
if isRefreshKind(kind) {
|
||||||
|
r.inflightRefresh[tenantID]++
|
||||||
|
}
|
||||||
r.pruneTerminalIfOver(maxJobs)
|
r.pruneTerminalIfOver(maxJobs)
|
||||||
enqueuedHook := r.onEnqueued
|
enqueuedHook := r.onEnqueued
|
||||||
workerStart := r.workerStart
|
workerStart := r.workerStart
|
||||||
@@ -474,6 +482,34 @@ func (r *Registry) CountOtherActiveRefresh(tenantID, excludeJobID string) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isRefreshKind reports whether a job kind participates in deploy coalescing.
|
||||||
|
func isRefreshKind(kind string) bool {
|
||||||
|
return kind == KindModuleRefresh || kind == KindTenantRefresh
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalizeRefreshCoalesce is called from finishModuleRefreshSuccess under tenantRefreshMu.
|
||||||
|
// It atomically decrements the per-tenant inflight refresh counter and reports whether the
|
||||||
|
// caller is the last outstanding refresh for the tenant (and therefore should render+deploy).
|
||||||
|
//
|
||||||
|
// Unlike CountOtherActiveRefresh (which polls job statuses and races under -race), this counter
|
||||||
|
// is incremented in Enqueue under r.mu and decremented here, so the "last one" decision is
|
||||||
|
// deterministic regardless of how fast each refresh's ingest completes.
|
||||||
|
func (r *Registry) finalizeRefreshCoalesce(tenantID string) bool {
|
||||||
|
if r == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
n := r.inflightRefresh[tenantID]
|
||||||
|
if n <= 1 {
|
||||||
|
// Last (or already-balanced to zero) — clear the slot and let the caller deploy.
|
||||||
|
delete(r.inflightRefresh, tenantID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
r.inflightRefresh[tenantID] = n - 1
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func parseCursor(s string, off *int) error {
|
func parseCursor(s string, off *int) error {
|
||||||
_, err := fmt.Sscanf(s, "%d", off)
|
_, err := fmt.Sscanf(s, "%d", off)
|
||||||
return err
|
return err
|
||||||
|
|||||||
+60
-23
@@ -119,26 +119,7 @@ func (w *Worker) Process(j *Job) {
|
|||||||
|
|
||||||
switch j.Kind {
|
switch j.Kind {
|
||||||
case KindModuleRefresh:
|
case KindModuleRefresh:
|
||||||
mid, _ := j.Meta["module_id"].(string)
|
w.runModuleRefresh(j)
|
||||||
if strings.TrimSpace(mid) == "" {
|
|
||||||
j.Fail("missing module_id in job meta")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := j.workContext()
|
|
||||||
defer cancel()
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
j.MarkCancelled()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
j.MarkCancelled()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
j.Fail(err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.finishModuleRefreshSuccess(j, mid)
|
|
||||||
case KindTenantRefresh:
|
case KindTenantRefresh:
|
||||||
w.runTenantRefresh(j)
|
w.runTenantRefresh(j)
|
||||||
case KindPeerReconcile:
|
case KindPeerReconcile:
|
||||||
@@ -295,7 +276,55 @@ func (w *Worker) tenantRefreshMu(tenantID string) *sync.Mutex {
|
|||||||
|
|
||||||
// finishModuleRefreshSuccess marks the refresh job and, for the last active refresh in tenant,
|
// finishModuleRefreshSuccess marks the refresh job and, for the last active refresh in tenant,
|
||||||
// creates one aggregate revision and enqueues a single deploy_apply.
|
// creates one aggregate revision and enqueues a single deploy_apply.
|
||||||
|
// runModuleRefresh handles a single module_refresh job and guarantees the per-tenant inflight
|
||||||
|
// slot is released exactly once — even on failure/cancellation before finishModuleRefreshSuccess.
|
||||||
|
func (w *Worker) runModuleRefresh(j *Job) {
|
||||||
|
coalesceFinalized := false
|
||||||
|
defer func() {
|
||||||
|
if !coalesceFinalized && w != nil && w.Registry != nil {
|
||||||
|
// Refresh failed/was cancelled before reaching finishModuleRefreshSuccess.
|
||||||
|
// Decrement the counter under the tenant mutex so the "last one" logic stays sound.
|
||||||
|
mu := w.tenantRefreshMu(j.TenantID)
|
||||||
|
mu.Lock()
|
||||||
|
w.Registry.finalizeRefreshCoalesce(j.TenantID)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
mid, _ := j.Meta["module_id"].(string)
|
||||||
|
if strings.TrimSpace(mid) == "" {
|
||||||
|
j.Fail("missing module_id in job meta")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := j.workContext()
|
||||||
|
defer cancel()
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
j.MarkCancelled()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
j.MarkCancelled()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.Fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.finishModuleRefreshSuccess(j, mid)
|
||||||
|
coalesceFinalized = true
|
||||||
|
}
|
||||||
|
|
||||||
func (w *Worker) runTenantRefresh(j *Job) {
|
func (w *Worker) runTenantRefresh(j *Job) {
|
||||||
|
coalesceFinalized := false
|
||||||
|
defer func() {
|
||||||
|
if !coalesceFinalized && w != nil && w.Registry != nil {
|
||||||
|
mu := w.tenantRefreshMu(j.TenantID)
|
||||||
|
mu.Lock()
|
||||||
|
w.Registry.finalizeRefreshCoalesce(j.TenantID)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
moduleIDs := moduleIDsFromJobMeta(j.Meta)
|
moduleIDs := moduleIDsFromJobMeta(j.Meta)
|
||||||
if len(moduleIDs) == 0 {
|
if len(moduleIDs) == 0 {
|
||||||
j.Fail("missing module_ids in job meta")
|
j.Fail("missing module_ids in job meta")
|
||||||
@@ -318,6 +347,7 @@ func (w *Worker) runTenantRefresh(j *Job) {
|
|||||||
}
|
}
|
||||||
j.mergeMeta(map[string]any{"module_ids": moduleIDs, "modules_refreshed": len(moduleIDs)})
|
j.mergeMeta(map[string]any{"module_ids": moduleIDs, "modules_refreshed": len(moduleIDs)})
|
||||||
w.finishModuleRefreshSuccess(j, trigger)
|
w.finishModuleRefreshSuccess(j, trigger)
|
||||||
|
coalesceFinalized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func moduleIDsFromJobMeta(meta map[string]any) []string {
|
func moduleIDsFromJobMeta(meta map[string]any) []string {
|
||||||
@@ -346,6 +376,9 @@ func moduleIDsFromJobMeta(meta map[string]any) []string {
|
|||||||
|
|
||||||
func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
||||||
if w == nil || w.Store == nil {
|
if w == nil || w.Store == nil {
|
||||||
|
if w != nil && w.Registry != nil {
|
||||||
|
w.Registry.finalizeRefreshCoalesce(j.TenantID)
|
||||||
|
}
|
||||||
j.Succeed()
|
j.Succeed()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -354,11 +387,15 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
|||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
ctx, cancel := j.workContext()
|
ctx, cancel := j.workContext()
|
||||||
defer cancel()
|
defer cancel()
|
||||||
deferDeploy := false
|
// Determine whether this is the last outstanding refresh for the tenant. The counter is
|
||||||
|
// incremented in Enqueue (under r.mu) and decremented here, so the "last one" decision is
|
||||||
|
// deterministic regardless of ingest timing — unlike the previous status-polling approach
|
||||||
|
// (CountOtherActiveRefresh) which could race under -race.
|
||||||
|
isLastRefresh := true
|
||||||
if w.Registry != nil {
|
if w.Registry != nil {
|
||||||
deferDeploy = w.Registry.CountOtherActiveRefresh(j.TenantID, j.ID) > 0
|
isLastRefresh = w.Registry.finalizeRefreshCoalesce(j.TenantID)
|
||||||
}
|
}
|
||||||
if deferDeploy {
|
if !isLastRefresh {
|
||||||
j.mergeMeta(map[string]any{
|
j.mergeMeta(map[string]any{
|
||||||
"deploy_apply_deferred": true,
|
"deploy_apply_deferred": true,
|
||||||
"deploy_apply_defer_reason": "parallel_module_refresh",
|
"deploy_apply_defer_reason": "parallel_module_refresh",
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "evobgp-release",
|
"name": "evobgp-release",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "[email protected]",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --filter @evobgp/web dev",
|
||||||
|
"build": "pnpm --filter @evobgp/web build",
|
||||||
|
"typecheck": "pnpm --filter @evobgp/web typecheck",
|
||||||
|
"lint": "pnpm --filter @evobgp/web lint",
|
||||||
|
"test": "pnpm --filter @evobgp/web test"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^19.8.1",
|
"@commitlint/cli": "^19.8.1",
|
||||||
"@commitlint/config-conventional": "^19.8.1",
|
"@commitlint/config-conventional": "^19.8.1",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@evobgp/ui",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./components/*": "./src/components/*",
|
||||||
|
"./hooks/*": "./src/hooks/*",
|
||||||
|
"./lib/utils": "./src/lib/utils.ts",
|
||||||
|
"./globals.css": "./src/styles/globals.css"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.0.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
|
"recharts": "3.8.0",
|
||||||
|
"sonner": "^1.7.4",
|
||||||
|
"tailwind-merge": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.2.17",
|
||||||
|
"@types/react-dom": "19.2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-2 right-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
data-slot="breadcrumb"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"a">) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "a",
|
||||||
|
props: mergeProps<"a">(
|
||||||
|
{
|
||||||
|
className: cn("transition-colors hover:text-foreground", className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "breadcrumb-link",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-normal text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<ChevronRightIcon />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"flex size-5 items-center justify-center [&>svg]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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"
|
||||||
|
import { Separator } from "@evobgp/ui/components/separator"
|
||||||
|
|
||||||
|
const buttonGroupVariants = cva(
|
||||||
|
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
|
||||||
|
vertical:
|
||||||
|
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ButtonGroup({
|
||||||
|
className,
|
||||||
|
orientation,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupText({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"div">) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
props: mergeProps<"div">(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "button-group-text",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupSeparator({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
ButtonGroupText,
|
||||||
|
buttonGroupVariants,
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
DayPicker,
|
||||||
|
getDefaultClassNames,
|
||||||
|
type DayButton,
|
||||||
|
type Locale,
|
||||||
|
} from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@evobgp/ui/components/button"
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
locale,
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
locale={locale}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString(locale?.code, { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"relative flex flex-col gap-4 md:flex-row",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative rounded-(--cell-radius)",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"absolute inset-0 bg-popover opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"font-medium select-none",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"w-(--cell-size) select-none",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] text-muted-foreground select-none",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
|
||||||
|
props.showWeekNumber
|
||||||
|
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
|
||||||
|
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn(
|
||||||
|
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
|
||||||
|
defaultClassNames.range_end
|
||||||
|
),
|
||||||
|
today: cn(
|
||||||
|
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: ({ ...props }) => (
|
||||||
|
<CalendarDayButton locale={locale} {...props} />
|
||||||
|
),
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
locale,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RechartsPrimitive from "recharts"
|
||||||
|
import type { TooltipValueType } from "recharts"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
|
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||||
|
type TooltipNameType = number | string
|
||||||
|
|
||||||
|
export type ChartConfig = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label?: React.ReactNode
|
||||||
|
icon?: React.ComponentType
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
>
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
initialDimension = INITIAL_DIMENSION,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"]
|
||||||
|
initialDimension?: {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer
|
||||||
|
initialDimension={initialDimension}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme ?? config.color
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||||
|
itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: "line" | "dot" | "dashed"
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
} & Omit<
|
||||||
|
RechartsPrimitive.DefaultTooltipContentProps<
|
||||||
|
TooltipValueType,
|
||||||
|
TooltipNameType
|
||||||
|
>,
|
||||||
|
"accessibilityLayer"
|
||||||
|
>) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload
|
||||||
|
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? (config[label]?.label ?? label)
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label ?? item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value != null && (
|
||||||
|
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||||
|
{typeof item.value === "number"
|
||||||
|
? item.value.toLocaleString()
|
||||||
|
: String(item.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey: string = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@evobgp/ui/components/dialog"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
} from "@evobgp/ui/components/input-group"
|
||||||
|
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||||
|
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className={cn("py-6 text-center text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
MenuPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user