diff --git a/.agents/skills/shadcn-react/SKILL.md b/.agents/skills/shadcn-react/SKILL.md new file mode 100644 index 0000000..4c7688d --- /dev/null +++ b/.agents/skills/shadcn-react/SKILL.md @@ -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 ` — [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components) + - `@reui/*` → [ReUI docs](https://reui.io/docs/components/base/) + [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/` | + +```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 ` (или `@reui/`) из `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 diff --git a/.cursor/plans/evobgp_→_react+shadcn_ui+reui_99d7c4ae.plan.md b/.cursor/plans/evobgp_→_react+shadcn_ui+reui_99d7c4ae.plan.md new file mode 100644 index 0000000..a06b8c2 --- /dev/null +++ b/.cursor/plans/evobgp_→_react+shadcn_ui+reui_99d7c4ae.plan.md @@ -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 ` сверка с [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 + `, единственный `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 }>` + `` + +### Этап 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={}` — **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` + +### Этап 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`, `apiMutate` с auto-Idempotency-Key + - `parseResponse`: 204/205→undefined, ошибки → `ApiError` с RFC 9457 Problem + - `waitForJob(jobId, opts?)`: poll `GET /v1/jobs/{id}` каждые 400ms + - `apiPageAll`: 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 ` сверка с [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 разбит по сложности; можно остановиться после базовых и продолжить инкрементально | \ No newline at end of file diff --git a/.cursor/rules/context7-stack.mdc b/.cursor/rules/context7-stack.mdc index 1f86181..0728b66 100644 --- a/.cursor/rules/context7-stack.mdc +++ b/.cursor/rules/context7-stack.mdc @@ -32,23 +32,28 @@ alwaysApply: true --- -## Web UI (`web/`) +## Web UI (`apps/web/` + `packages/ui/`) | Библиотека | Context7 ID | Версия в проекте | Когда | |------------|-------------|------------------|-------| -| Svelte | `/websites/svelte_dev` | ^5.54 | runes, компоненты, реактивность | -| SvelteKit | `/sveltejs/kit` | ^2.50 | routing, `load`, adapters, SSR | +| React | `/facebook/react` | ^19.2 | hooks, components, JSX | +| TanStack Router | `/tanstack/router` | ^1.130 | file-based routes, `createFileRoute`, `useSearch`, `Link` | +| TanStack Query | `/tanstack/query` | ^5.90 | `useQuery`, `useMutation`, `queryOptions`, invalidation | +| TanStack Table | `/websites/tanstack_table` | ^8.21 | data-grid колонки, сортировка (ReUI) | +| TanStack Virtual | `/tanstack/virtual` | ^3.14 | виртуализация списков (ReUI data-grid) | | Vite | `/vitejs/vite/v7.3.1` | ^7.3.1 | dev server, build, plugins | | TypeScript | `/microsoft/typescript/v5.9.3` | ^5.9.3 | типы, strict, tsconfig | | Tailwind CSS | `/tailwindlabs/tailwindcss.com` | ^4.1 | v4, `@tailwindcss/vite`, утилиты | -| shadcn-svelte | `/websites/shadcn-svelte` | CLI | примитивы `ui/core`, theming | -| Bits UI | `/llmstxt/bits-ui_llms_txt` | ^2.17 | headless-примитивы под shadcn | -| sveltekit-superforms | `/ciscoheat/sveltekit-superforms` | ^2.30 | формы, server actions | -| Formsnap | `/svecosystem/formsnap` | ^2.0 | доступные поля форм | -| Zod | `/websites/zod_dev_v4` | ^4.4 | схемы валидации | -| TanStack Table | `/websites/tanstack_table` | table-core ^8.21 | `AppDataTable`, колонки, сортировка | +| shadcn/ui (React) | MCP `plugin-shadcn-shadcn` + https://ui.shadcn.com/docs | base-nova | примитивы `@evobgp/ui/components/*` | +| ReUI | https://reui.io/llms.txt + MCP с `registries: ["@reui"]` | registry | enterprise: data-grid, filters, autocomplete | +| react-hook-form | `/react-hook-form` | ^7.60 | формы, controller | +| Zod | `/websites/zod_dev_v4` | ^3.25 / ^4 (apps/web) | схемы валидации | +| recharts | `/recharts/recharts` | 3.8.0 | графики через shadcn `Chart` | +| next-themes | `/pacocoursey/next-themes` | ^0.4 | dark/light theme provider | +| sonner | `/emilkowalski/sonner` | ^1.7 | toast notifications | +| lucide-react | `/lucide-icons/lucide` | ^0.468 | иконки | -UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (shadcn-svelte docs — первичный источник для компонентов). +UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (MCP + shadcn/ui React docs — первичный источник для компонентов). --- @@ -82,7 +87,9 @@ UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (shadcn ## Примеры запросов ``` -/docs /websites/svelte_dev runes $state $derived +/docs /facebook/react hooks useState useEffect +/docs /tanstack/router createFileRoute useSearch Link +/docs /tanstack/query useQuery useMutation queryOptions /docs /golang/go/go1_24_6 net/http ServeMux pattern matching /docs /websites/pkg_go_dev_github_com_jackc_pgx_v5 pool acquire rows /docs /llmstxt/bird_xmsl_dev_llms_txt filter bgp import diff --git a/.cursor/rules/engineering.mdc b/.cursor/rules/engineering.mdc index 2290ae8..4112c1b 100644 --- a/.cursor/rules/engineering.mdc +++ b/.cursor/rules/engineering.mdc @@ -94,8 +94,8 @@ alwaysApply: true **DEP-03** | MUST | Миграции схемы — пары `.up.sql`/`.down.sql` для **postgres** и **sqlite**, синхронная нумерация. *Проверка:* `migrations/postgres/`, `migrations/sqlite/`. -**DEP-04** | MUST | Web UI-библиотеки — только экосистема shadcn-svelte/bits-ui (см. `web-shadcn.mdc`). -*Проверка:* `web/package.json` review. +**DEP-04** | MUST | Web UI-библиотеки — только экосистема shadcn/ui (React) + ReUI (см. `web-shadcn.mdc`). +*Проверка:* `apps/web/package.json`, `packages/ui/package.json` review. --- @@ -123,7 +123,7 @@ alwaysApply: true **TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`. *Проверка:* CI job `bird2`. -**TEST-04** | MUST | Изменения `web/` — локально **`npm run check` и `npm run lint`** (обе команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`. Агент: при fail lint — `npx prettier --write` затем повтор. Только `check` не заменяет `lint`. +**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`. *Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19. **TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`. @@ -213,7 +213,7 @@ alwaysApply: true **DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией. **DOC-SYNC-02** | NEVER | Устаревшие примеры (Svelte 4 `export let`, deprecated pgx). **DOC-SYNC-03** | MUST | Конфликт docs: **OpenAPI (HTTP)** → **код** → обзорные `docs/`; `.cursor/plans/` не контракт. -**DOC-SYNC-04** | MUST | Сомнения по Svelte — Svelte MCP / `npm run check`. +**DOC-SYNC-04** | MUST | Сомнения по React/shadcn/ReUI — MCP `plugin-shadcn-shadcn` + `pnpm --filter @evobgp/web run typecheck`. **DOC-SYNC-05** | MUST | BIRD — официальная документация BIRD2 + `networking-bird.mdc` + `go test ./internal/birdfmt/...`. Приоритет при сомнениях — **официальные источники**, не блоги и не «память модели». @@ -230,7 +230,7 @@ alwaysApply: true go vet ./... go test ./... -race -count=1 npx @redocly/cli lint docs/openapi.yaml -# web: cd web; npm run check; npm run lint (или scripts/lint-web.ps1) +# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build # go fmt/lint: gofmt -w ; scripts/lint-go.ps1 (gofmt + vet + golangci-lint) # birdfmt: go test ./internal/birdfmt/... -count=1 ``` diff --git a/.cursor/rules/web-shadcn.mdc b/.cursor/rules/web-shadcn.mdc index f8c7ddb..cae3ce1 100644 --- a/.cursor/rules/web-shadcn.mdc +++ b/.cursor/rules/web-shadcn.mdc @@ -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: - - web/** + - apps/web/** + - packages/ui/** 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/ +- 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 | Слой | Путь | Назначение | |------|------|------------| -| Примитивы | `src/lib/ui/core/` | shadcn-svelte (только CLI `add`) | -| Паттерны | `src/lib/ui/patterns/` | FormField, AppDataTable, ConfirmDialog, EmptyState | -| App chrome | `src/lib/ui/app/` | Layout, PageHeader, `notify` | -| Legacy | `src/lib/components/ui/` | Re-export; **не добавлять новые файлы** | +| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) | +| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` | +| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton | +| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) | -Тема: `src/routes/layout.css`, `src/lib/ui/app/tokens.md`. CLI из `web/`: `npx shadcn-svelte@latest add -y -o`. - ---- +Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add `. ## Правила -**WEB-01** | MUST | Перед новым UI — проверить https://shadcn-svelte.com/docs/components; использовать компонент, не HTML+CSS с нуля. -*Rationale:* Open Code + единый дизайн. -*Проверка:* review; нет голых ` + ) +} diff --git a/apps/web/src/components/mode-toggle.tsx b/apps/web/src/components/mode-toggle.tsx new file mode 100644 index 0000000..14be8e7 --- /dev/null +++ b/apps/web/src/components/mode-toggle.tsx @@ -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 ( + + }> + + + Сменить тему + + + setTheme('light')}>Светлая + setTheme('dark')}>Тёмная + setTheme('system')}>Системная + + + ) +} diff --git a/apps/web/src/components/page-header.tsx b/apps/web/src/components/page-header.tsx new file mode 100644 index 0000000..77ea88d --- /dev/null +++ b/apps/web/src/components/page-header.tsx @@ -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 ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ) +} diff --git a/apps/web/src/components/page-shell.tsx b/apps/web/src/components/page-shell.tsx new file mode 100644 index 0000000..b995be1 --- /dev/null +++ b/apps/web/src/components/page-shell.tsx @@ -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
{children}
+} diff --git a/apps/web/src/components/query-state.tsx b/apps/web/src/components/query-state.tsx new file mode 100644 index 0000000..b7db781 --- /dev/null +++ b/apps/web/src/components/query-state.tsx @@ -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 { + 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({ + data, + isLoading, + isError, + error, + empty, + emptyTitle = 'Нет данных', + emptyDescription, + emptyAction, + onRetry, + skeleton, + children, +}: QueryStateProps) { + if (isLoading) { + return <>{skeleton ?? } + } + if (isError) { + return ( + } + title="Ошибка загрузки" + description={error instanceof Error ? error.message : 'Не удалось загрузить данные'} + action={ + onRetry ? ( + + ) : null + } + /> + ) + } + if (empty || data == null) { + return + } + return <>{children(data)} +} + +function DefaultSkeleton() { + return ( +
+ + +
+ ) +} diff --git a/apps/web/src/components/reui/autocomplete.tsx b/apps/web/src/components/reui/autocomplete.tsx new file mode 100644 index 0000000..8ecedb4 --- /dev/null +++ b/apps/web/src/components/reui/autocomplete.tsx @@ -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 ( + + ) +} + +function AutocompleteInput({ + className, + size = "default", + showClear = false, + showTrigger = false, + ...props +}: Omit & + VariantProps & { + showClear?: boolean + showTrigger?: boolean + }) { + return ( +
+ + {showTrigger && } + {showClear && } +
+ ) +} + +function AutocompleteStatus({ + className, + ...props +}: AutocompletePrimitive.Status.Props) { + return ( + + ) +} + +function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) { + return ( + + ) +} + +function AutocompleteBackdrop({ + ...props +}: AutocompletePrimitive.Backdrop.Props) { + return ( + + ) +} + +function AutocompletePositioner({ + className, + ...props +}: AutocompletePrimitive.Positioner.Props) { + return ( + + ) +} + +function AutocompleteList({ + className, + scrollAreaClassName, + ...props +}: AutocompletePrimitive.List.Props & { + scrollAreaClassName?: string + scrollFade?: boolean + scrollbarGutter?: boolean +}) { + return ( + + + + ) +} + +function AutocompleteCollection({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteRow({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +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 ( + + {showBackdrop && } + +
+ + {children} + +
+
+
+ ) +} + +function AutocompleteGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteGroupLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteEmpty({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteClear({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function AutocompleteTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function AutocompleteArrow({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Autocomplete, + AutocompleteValue, + AutocompleteTrigger, + AutocompleteInput, + AutocompleteStatus, + AutocompletePortal, + AutocompleteBackdrop, + AutocompletePositioner, + AutocompleteContent, + AutocompleteList, + AutocompleteCollection, + AutocompleteRow, + AutocompleteItem, + AutocompleteGroup, + AutocompleteGroupLabel, + AutocompleteEmpty, + AutocompleteClear, + AutocompleteArrow, + AutocompleteSeparator, +} \ No newline at end of file diff --git a/apps/web/src/components/reui/badge.tsx b/apps/web/src/components/reui/badge.tsx new file mode 100644 index 0000000..e94aaa0 --- /dev/null +++ b/apps/web/src/components/reui/badge.tsx @@ -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["variant"] + size?: VariantProps["size"] + radius?: VariantProps["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 } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx new file mode 100644 index 0000000..3ac4284 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx @@ -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 { + column?: Column + title?: string + options: { + label: string + value: string + icon?: React.ComponentType<{ className?: string }> + }[] +} + +function DataGridColumnFilter({ + column, + title, + options, +}: DataGridColumnFilterProps) { + 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 ( + + + + {title} + {selectedValues?.size > 0 && ( + <> + + + {selectedValues.size} + +
+ {selectedValues.size > 2 ? ( + + {selectedValues.size} selected + + ) : ( + options + .filter((option) => selectedValues.has(option.value)) + .map((option) => ( + + {option.label} + + )) + )} +
+ + )} + + } + /> + +
+ setSearchQuery(e.target.value)} + className="h-8" + /> +
+
+ {filteredOptions.length === 0 ? ( +
+ No results found. +
+ ) : ( +
+ {filteredOptions.map((option) => { + const isSelected = selectedValues.has(option.value) + return ( +
{ + 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" + )} + > +
+ +
+ {option.icon && ( + + )} + {option.label} + {facets?.get(option.value) && ( + + {facets.get(option.value)} + + )} +
+ ) + })} +
+ )} + {selectedValues.size > 0 && ( + <> +
+
+
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 +
+
+ + )} +
+ + + ) +} + +export { DataGridColumnFilter, type DataGridColumnFilterProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx new file mode 100644 index 0000000..a943366 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx @@ -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 { + column: Column + /** 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({ + column, + title, + icon, + className, + filter, + visibility = false, +}: DataGridColumnHeaderProps) { + 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" ? ( + + ) : isSorted === "asc" ? ( + + ) : ( + + )) + + 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( + + {filter} + + ) + hasPreviousSection = true + } + + // Sort section + if (canSort) { + if (hasPreviousSection) { + items.push() + } + items.push( + { + if (isSorted === "asc") { + column.clearSorting() + } else { + column.toggleSorting(false) + } + }} + disabled={!canSort} + > + + Asc + {isSorted === "asc" && ( + + )} + , + { + if (isSorted === "desc") { + column.clearSorting() + } else { + column.toggleSorting(true) + } + }} + disabled={!canSort} + > + + Desc + {isSorted === "desc" && ( + + )} + + ) + hasPreviousSection = true + } + + // Pin section + if (props.tableLayout?.columnsPinnable && canPin) { + if (hasPreviousSection) { + items.push() + } + items.push( + column.pin(isPinned === "left" ? false : "left")} + > + , + column.pin(isPinned === "right" ? false : "right")} + > + + ) + hasPreviousSection = true + } + + // Move section + if (props.tableLayout?.columnsMovable) { + if (hasPreviousSection) { + items.push() + } + items.push( + { + 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} + > + , + { + 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} + > + + ) + hasPreviousSection = true + } + + // Visibility section + if (props.tableLayout?.columnsVisibility && visibility) { + if (hasPreviousSection) { + items.push() + } + items.push( + + + + Columns + + + {table + .getAllColumns() + .filter((col) => col.getCanHide()) + .map((col) => ( + event.preventDefault()} + onCheckedChange={(value) => col.toggleVisibility(!!value)} + className="capitalize" + > + {getColumnHeaderLabel(col)} + + ))} + + + ) + } + + 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 ( +
+ + + {icon && icon} + {resolvedTitle} + {sortIcon} + + } + /> + + {menuItems} + + + {props.tableLayout?.columnsPinnable && canPin && isPinned && ( + + )} +
+ ) + } + + if (canSort || (props.tableLayout?.columnsResizable && canResize)) { + return ( +
+ +
+ ) + } + + return ( +
+ {icon && icon} + {resolvedTitle} +
+ ) +} + +const DataGridColumnHeader = memo( + DataGridColumnHeaderInner +) as typeof DataGridColumnHeaderInner + +export { DataGridColumnHeader, type DataGridColumnHeaderProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx new file mode 100644 index 0000000..5b14a2f --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx @@ -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({ + table, + trigger, +}: { + table: Table + trigger: ReactElement> +}) { + return ( + + + + + + Toggle Columns + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + event.preventDefault()} + onCheckedChange={(value) => column.toggleVisibility(!!value)} + > + {getColumnHeaderLabel(column)} + + ) + })} + + + + ) +} + +export { DataGridColumnVisibility } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx b/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx new file mode 100644 index 0000000..1c52421 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx @@ -0,0 +1,224 @@ +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 = { + sizes: [5, 10, 25, 50, 100], + sizesLabel: "Show", + sizesDescription: "per page", + sizesSkeleton: , + moreLimit: 5, + more: false, + info: "{from} - {to} of {count}", + infoSkeleton: , + 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( + + ) + } + return buttons + } + + // Render a "previous" ellipsis button if there are previous pages to show + const renderEllipsisPrevButton = () => { + if (currentGroupStart > 0) { + return ( + + ) + } + return null + } + + // Render a "next" ellipsis button if there are more pages to show after the current group + const renderEllipsisNextButton = () => { + if (currentGroupEnd < pageCount) { + return ( + + ) + } + return null + } + + return ( +
+
+ {isLoading ? ( + mergedProps?.sizesSkeleton + ) : ( + <> +
+ {mergedProps.rowsPerPageLabel} +
+ + + )} +
+
+ {isLoading ? ( + mergedProps?.infoSkeleton + ) : ( + <> +
+ {paginationInfo} +
+ {pageCount > 1 && ( +
+ + + {renderEllipsisPrevButton()} + + {renderPageButtons()} + + {renderEllipsisNextButton()} + + +
+ )} + + )} +
+
+ ) +} + +export { DataGridPagination, type DataGridPaginationProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx b/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx new file mode 100644 index 0000000..b6cc647 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx @@ -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(null) + const viewportRef = useRef(null) + const dragRef = useRef<{ + pointerId: number + startScrollTop: number + startY: number + } | null>(null) + const metricsRef = useRef(INITIAL_METRICS) + const observedElementsRef = useRef({ + 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) => { + 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) => { + 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) => { + if (dragRef.current?.pointerId !== event.pointerId) return + clearDragState() + } + + const handleTrackPointerDown = (event: PointerEvent) => { + 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 ( +
+ + + + {children} + + + + {showHorizontal && ( + + + + )} + + {showVertical && !usesCustomVerticalScrollbar && ( + + + + )} + + + {usesCustomVerticalScrollbar && hasCustomVerticalOverflow && ( + + ) +} + +export { DataGridScrollArea } +export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx new file mode 100644 index 0000000..3b592c0 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx @@ -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 +const SortableRowContext = createContext | 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 ( + + ) + } + + return ( + + ) +} + +function DataGridTableDndRow({ row }: { row: Row }) { + 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 ( + + + {row.getVisibleCells().map((cell: Cell, colIndex) => { + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + + ) +} + +function DataGridTableDndRows({ + handleDragEnd, + dataIds, + footerContent, +}: { + handleDragEnd: (event: DragEndEvent) => void + dataIds: UniqueIdentifier[] + footerContent?: ReactNode +}) { + const { table, isLoading, props } = useDataGrid() + const pagination = table.getState().pagination + const tableContainerRef = useRef(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 ( + setIsDraggingRow(false)} + onDragEnd={(event) => { + setIsDraggingRow(false) + handleDragEnd(event) + }} + onDragStart={() => setIsDraggingRow(true)} + sensors={sensors} + > + + + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => { + return ( + + {headerGroup.headers.map((header, index) => { + const { column } = header + + return ( + + {header.isPlaceholder ? null : props.tableLayout + ?.columnsResizable && column.getCanResize() ? ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {props.tableLayout?.columnsResizable && + column.getCanResize() && ( + + )} +
+ ) + })} +
+ ) + })} +
+ + {(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( + + )} + + + {props.loadingMode === "skeleton" && + isLoading && + pagination?.pageSize ? ( + Array.from({ length: pagination.pageSize }).map((_, rowIndex) => ( + + {table.getVisibleFlatColumns().map((column, colIndex) => { + return ( + + {column.columnDef.meta?.skeleton} + + ) + })} + + )) + ) : table.getRowModel().rows.length ? ( + + {table.getRowModel().rows.map((row: Row) => { + return + })} + + ) : ( + + )} + + + {footerContent && ( + {footerContent} + )} +
+
+
+ ) +} + +export { DataGridTableDndRowHandle, DataGridTableDndRows } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx new file mode 100644 index 0000000..14776e9 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx @@ -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({ + header, +}: { + header: Header +}) { + 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 ( + +
+ {canOrder && ( + + )} + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + {props.tableLayout?.columnsResizable && column.getCanResize() && ( + + )} +
+
+ ) +} + +function DataGridTableDndCell({ cell }: { cell: Cell }) { + 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 ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) +} + +function DataGridTableDnd({ + handleDragEnd, + footerContent, +}: { + handleDragEnd: (event: DragEndEvent) => void + footerContent?: ReactNode +}) { + const { table, isLoading, props } = useDataGrid() + const pagination = table.getState().pagination + const containerRef = useRef(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 ( + setIsDraggingColumn(false)} + onDragEnd={(event) => { + setIsDraggingColumn(false) + handleDragEnd(event) + }} + onDragStart={() => setIsDraggingColumn(true)} + sensors={sensors} + > + + + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => { + return ( + + + {headerGroup.headers.map((header) => ( + + ))} + + + ) + })} + + + {(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( + + )} + + + {props.loadingMode === "skeleton" && + isLoading && + pagination?.pageSize ? ( + Array.from({ length: pagination.pageSize }).map((_, rowIndex) => ( + + {table.getVisibleFlatColumns().map((column, colIndex) => { + return ( + + {column.columnDef.meta?.skeleton} + + ) + })} + + )) + ) : table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row: Row) => { + return ( + + + {row + .getVisibleCells() + .map((cell: Cell) => { + return ( + + + + ) + })} + + {row.getIsExpanded() && ( + + )} + + ) + }) + ) : ( + + )} + + + {footerContent && ( + {footerContent} + )} + + + + ) +} + +export { DataGridTableDnd } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx new file mode 100644 index 0000000..b89fcad --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx @@ -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 = Omit< + VirtualizerOptions, + "count" | "estimateSize" | "getItemKey" | "getScrollElement" +> & { + estimateSize?: (index: number, row: Row) => number + getItemKey?: (index: number, row: Row) => string | number + getScrollElement?: ( + elements: DataGridTableVirtualScrollElements + ) => HTMLElement | null +} + +interface DataGridTableVirtualProps { + height?: number | string + estimateSize?: number + overscan?: number + footerContent?: ReactNode + renderHeader?: boolean + onFetchMore?: () => void + isFetchingMore?: boolean + hasMore?: boolean + fetchMoreOffset?: number + virtualizerOptions?: DataGridTableVirtualizerOptions +} + +interface VirtualBodyProps { + table: Table + columnCount: number + topRows: Row[] + centerRows: Row[] + bottomRows: Row[] + 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 ( + + + + ) +} + +function DataGridTableVirtualStatusRow({ + children, + className, + columnCount, +}: { + children: ReactNode + className?: string + columnCount: number +}) { + return ( + + + {children} + + + ) +} + +function DataGridTableVirtualBody({ + table, + columnCount, + topRows, + centerRows, + bottomRows, + virtualItems, + totalSize, + isVirtualizationEnabled, + isInfiniteMode, + isFetchingMore, + hasMore, + loadingMoreMessage, + allRowsLoadedMessage, + measureRowRef, +}: VirtualBodyProps) { + const totalRows = topRows.length + centerRows.length + bottomRows.length + + if (!totalRows) return + + 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( + + ) + }) + + if (isVirtualizationEnabled) { + if (leadingSpacerHeight > 0) { + renderedRows.push( + + ) + } + + virtualItems.forEach((virtualRow) => { + const row = centerRows[virtualRow.index] + + if (!row) return + + renderedRows.push( + + ) + }) + + if (trailingSpacerHeight > 0) { + renderedRows.push( + + ) + } + } else { + centerRows.forEach((row) => { + renderedRows.push() + }) + } + + if (showFetchingRow) { + renderedRows.push( + +
+ + {loadingMoreMessage} +
+
+ ) + } + + if (showCompleteRow) { + renderedRows.push( + + {allRowsLoadedMessage} + + ) + } + + bottomRows.forEach((row, index) => { + renderedRows.push( + 0 || hasMiddleSection) + ? "bottom" + : undefined + } + /> + ) + }) + + return <>{renderedRows} +} + +/** + * Memoized virtual body: skip re-renders during active column resize. + * Column widths update via CSS variables on the 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({ + height, + estimateSize = 48, + overscan = 10, + footerContent, + renderHeader = true, + onFetchMore, + isFetchingMore = false, + hasMore, + fetchMoreOffset = 0, + virtualizerOptions, +}: DataGridTableVirtualProps) { + 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({ + 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 ( + + + {renderHeader && ( + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => ( + + {headerGroup.headers.map((header, hIndex) => { + const { column } = header + + return ( + + {header.isPlaceholder ? null : props.tableLayout + ?.columnsResizable && column.getCanResize() ? ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {props.tableLayout?.columnsResizable && + column.getCanResize() && ( + + )} +
+ ) + })} +
+ ))} +
+ )} + + {renderHeader && + (props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( + + )} + + + + + + {footerContent && ( + {footerContent} + )} +
+
+ ) +} + +export { DataGridTableVirtual } +export type { + DataGridTableVirtualProps, + DataGridTableVirtualScrollElements, + DataGridTableVirtualizerOptions, +} \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table.tsx b/apps/web/src/components/reui/data-grid/data-grid-table.tsx new file mode 100644 index 0000000..3277028 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-table.tsx @@ -0,0 +1,1433 @@ +"use client" + +import { + CSSProperties, + Fragment, + memo, + MouseEvent as ReactMouseEvent, + ReactNode, + TouchEvent as ReactTouchEvent, + Ref, + useCallback, + useEffect, + useMemo, + useState, +} from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" +import { + Cell, + Column, + flexRender, + Header, + HeaderGroup, + Row, + Table, +} from "@tanstack/react-table" +import { cva } from "class-variance-authority" + +import { cn } from "@evobgp/ui/lib/utils" +import { Checkbox } from "@evobgp/ui/components/checkbox" +import { Spinner } from "@evobgp/ui/components/spinner" + +const headerCellSpacingVariants = cva("", { + variants: { + size: { + dense: + "px-2 h-8", + default: + "px-3", + }, + }, + defaultVariants: { + size: "default", + }, +}) + +const bodyCellSpacingVariants = cva("", { + variants: { + size: { + dense: + "px-2 py-1.5", + default: + "px-3 py-2", + }, + }, + defaultVariants: { + size: "default", + }, +}) + +const footerCellSpacingVariants = cva("", { + variants: { + size: { + dense: + "px-2 py-1.5", + default: + "px-3 py-2", + }, + }, + defaultVariants: { + size: "default", + }, +}) + +function getPinningStyles(column: Column): CSSProperties { + const isPinned = column.getIsPinned() + + return { + left: isPinned === "left" ? `${column.getStart("left")}px` : undefined, + right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, + position: isPinned ? "sticky" : "relative", + width: column.getSize(), + zIndex: isPinned ? 1 : 0, + } +} + +function assignRef(ref: Ref | undefined, value: T | null) { + if (!ref) return + + if (typeof ref === "function") { + ref(value) + return + } + + ;(ref as { current: T | null }).current = value +} + +type DataGridResizeStartEvent = + | ReactMouseEvent + | ReactTouchEvent + +type DataGridResizeDocumentEvent = globalThis.MouseEvent | globalThis.TouchEvent + +function isDataGridTouchEvent( + event: DataGridResizeStartEvent | DataGridResizeDocumentEvent +): event is ReactTouchEvent | globalThis.TouchEvent { + return "touches" in event +} + +function getDataGridResizeEventClientX( + event: DataGridResizeStartEvent | DataGridResizeDocumentEvent +) { + if (isDataGridTouchEvent(event)) { + return event.touches[0]?.clientX ?? event.changedTouches[0]?.clientX + } + + return event.clientX +} + +function startDataGridColumnResizeOnEnd( + event: DataGridResizeStartEvent, + header: Header, + table: Table +) { + const column = table.getColumn(header.column.id) + + if (!column || !column.getCanResize()) return + if (isDataGridTouchEvent(event) && event.touches.length > 1) return + + event.persist?.() + + const ownerDocument = event.currentTarget.ownerDocument + const previousBodyCursor = ownerDocument.body.style.cursor + const previousDocumentCursor = ownerDocument.documentElement.style.cursor + const startSize = header.getSize() + const dragStartClientX = getDataGridResizeEventClientX(event) + const headerCell = event.currentTarget.closest("th") + const headerRect = headerCell?.getBoundingClientRect() + const startOffset = + headerRect && + Number.isFinite( + table.options.columnResizeDirection === "rtl" + ? headerRect.left + : headerRect.right + ) + ? table.options.columnResizeDirection === "rtl" + ? headerRect.left + : headerRect.right + : dragStartClientX + + if (typeof dragStartClientX !== "number" || typeof startOffset !== "number") { + return + } + + ownerDocument.body.style.cursor = "col-resize" + ownerDocument.documentElement.style.cursor = "col-resize" + + const columnSizingStart = header + .getLeafHeaders() + .map( + (leafHeader) => + [leafHeader.column.id, leafHeader.column.getSize()] as [string, number] + ) + const directionMultiplier = + table.options.columnResizeDirection === "rtl" ? -1 : 1 + + const updateOffset = (clientXPos?: number, commit = false) => { + if (typeof clientXPos !== "number") return + + const nextColumnSizing: Record = {} + const deltaOffset = (clientXPos - dragStartClientX) * directionMultiplier + const deltaPercentage = Math.max(deltaOffset / startSize, -0.999999) + + columnSizingStart.forEach(([columnId, headerSize]) => { + nextColumnSizing[columnId] = + Math.round( + Math.max(headerSize + headerSize * deltaPercentage, 0) * 100 + ) / 100 + }) + + table.setColumnSizingInfo((old) => ({ + ...old, + startOffset, + startSize, + deltaOffset, + deltaPercentage, + columnSizingStart, + isResizingColumn: column.id, + })) + + if (commit) { + table.setColumnSizing((old) => ({ + ...old, + ...nextColumnSizing, + })) + } + } + + const endResize = (clientXPos?: number) => { + updateOffset(clientXPos, true) + table.setColumnSizingInfo((old) => ({ + ...old, + isResizingColumn: false, + startOffset: null, + startSize: null, + deltaOffset: null, + deltaPercentage: null, + columnSizingStart: [], + })) + ownerDocument.body.style.cursor = previousBodyCursor + ownerDocument.documentElement.style.cursor = previousDocumentCursor + } + + const mouseMoveHandler = (moveEvent: globalThis.MouseEvent) => { + updateOffset(moveEvent.clientX) + } + const mouseUpHandler = (upEvent: globalThis.MouseEvent) => { + ownerDocument.removeEventListener("mousemove", mouseMoveHandler) + ownerDocument.removeEventListener("mouseup", mouseUpHandler) + endResize(upEvent.clientX) + } + const touchMoveHandler = (moveEvent: globalThis.TouchEvent) => { + if (moveEvent.cancelable) { + moveEvent.preventDefault() + moveEvent.stopPropagation() + } + + updateOffset(getDataGridResizeEventClientX(moveEvent)) + } + const touchEndHandler = (endEvent: globalThis.TouchEvent) => { + ownerDocument.removeEventListener("touchmove", touchMoveHandler) + ownerDocument.removeEventListener("touchend", touchEndHandler) + + if (endEvent.cancelable) { + endEvent.preventDefault() + endEvent.stopPropagation() + } + + endResize(getDataGridResizeEventClientX(endEvent)) + } + + const passiveIfSupported = { passive: false } as const + + if (isDataGridTouchEvent(event)) { + ownerDocument.addEventListener( + "touchmove", + touchMoveHandler, + passiveIfSupported + ) + ownerDocument.addEventListener( + "touchend", + touchEndHandler, + passiveIfSupported + ) + } else { + ownerDocument.addEventListener( + "mousemove", + mouseMoveHandler, + passiveIfSupported + ) + ownerDocument.addEventListener( + "mouseup", + mouseUpHandler, + passiveIfSupported + ) + } + + table.setColumnSizingInfo((old) => ({ + ...old, + startOffset, + startSize, + deltaOffset: 0, + deltaPercentage: 0, + columnSizingStart, + isResizingColumn: column.id, + })) +} + +type DataGridTablePinnedBoundary = "top" | "bottom" + +function getDataGridTableRowSections( + table: Table, + rowsPinnable?: boolean +) { + if (!rowsPinnable) { + return { + topRows: [] as Row[], + centerRows: table.getRowModel().rows as Row[], + bottomRows: [] as Row[], + } + } + + return { + topRows: table.getTopRows() as Row[], + centerRows: table.getCenterRows() as Row[], + bottomRows: table.getBottomRows() as Row[], + } +} + +function getDataGridTableResolvedRows( + table: Table, + rowsPinnable?: boolean +) { + const { topRows, centerRows, bottomRows } = getDataGridTableRowSections( + table, + rowsPinnable + ) + const resolvedRows: Array<{ + row: Row + pinnedBoundary?: DataGridTablePinnedBoundary + }> = [] + + topRows.forEach((row, index) => { + resolvedRows.push({ + row, + pinnedBoundary: + index === topRows.length - 1 && + (centerRows.length > 0 || bottomRows.length > 0) + ? "top" + : undefined, + }) + }) + + centerRows.forEach((row) => { + resolvedRows.push({ row }) + }) + + bottomRows.forEach((row, index) => { + resolvedRows.push({ + row, + pinnedBoundary: + index === 0 && (centerRows.length > 0 || topRows.length > 0) + ? "bottom" + : undefined, + }) + }) + + return resolvedRows +} + +function DataGridTableFillCol() { + const { props } = useDataGrid() + + if (!props.tableLayout?.columnsResizable) return null + + return ( +
+ ) +} + +function DataGridTableFillHeadCell() { + const { props } = useDataGrid() + + if (!props.tableLayout?.columnsResizable) return null + + return ( + + {children} + + ) +} + +function DataGridTableHeadRow({ + children, + headerGroup, +}: { + children: ReactNode + headerGroup: HeaderGroup +}) { + const { props } = useDataGrid() + + return ( + th]:border-b", + props.tableLayout?.cellBorder && "*:last:border-e-0", + props.tableLayout?.stripped && "bg-transparent", + props.tableLayout?.headerBackground === false && "bg-transparent", + props.tableClassNames?.headerRow + )} + > + {children} + + + ) +} + +function DataGridTableHeadRowCell({ + children, + header, + dndRef, + dndStyle, +}: { + children: ReactNode + header: Header + dndRef?: React.Ref + dndStyle?: CSSProperties +}) { + const { props } = useDataGrid() + + const { column } = header + const isPinned = column.getIsPinned() + const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left") + const isFirstRightPinned = + isPinned === "right" && column.getIsFirstColumn("right") + const isLastVisibleColumn = + column.getIndex() === + header.getContext().table.getVisibleLeafColumns().length - 1 + const headerCellSpacing = headerCellSpacingVariants({ + size: props.tableLayout?.dense ? "dense" : "default", + }) + + return ( + + ) +} + +function DataGridTableHeadRowCellResize({ + header, +}: { + header: Header +}) { + const { props, table } = useDataGrid() + const { column } = header + const isLastVisibleColumn = + column.getIndex() === + header.getContext().table.getVisibleLeafColumns().length - 1 + const isResizeModeOnEnd = + (props.tableLayout?.columnsResizeMode ?? table.options.columnResizeMode) === + "onEnd" + + const handleMouseDown = (event: ReactMouseEvent) => { + event.preventDefault() + event.stopPropagation() + + if (isResizeModeOnEnd) { + startDataGridColumnResizeOnEnd(event, header, table) + return + } + + header.getResizeHandler()(event) + } + + const handleTouchStart = (event: ReactTouchEvent) => { + event.preventDefault() + event.stopPropagation() + + if (isResizeModeOnEnd) { + startDataGridColumnResizeOnEnd(event, header, table) + return + } + + header.getResizeHandler()(event) + } + + return ( +
column.resetSize(), + onMouseDown: handleMouseDown, + onTouchStart: handleTouchStart, + className: cn( + "absolute top-0 h-full cursor-col-resize user-select-none touch-none z-10 flex", + isLastVisibleColumn + ? "end-0 w-5 justify-end before:hidden" + : "-end-2 w-5 justify-center before:absolute before:inset-y-0 before:w-px before:-translate-x-px before:bg-border", + column.getIsResizing() && + (isResizeModeOnEnd + ? "opacity-100" + : isLastVisibleColumn + ? "before:absolute before:end-0 before:block before:inset-y-0 before:w-0.5 before:bg-primary opacity-100" + : "before:block before:bg-primary before:w-0.5 opacity-100") + ), + }} + /> + ) +} + +function DataGridTableResizeIndicator({ + viewportElement, +}: { + viewportElement: HTMLDivElement | null +}) { + const { props, table } = useDataGrid() + const columnSizingInfo = table.getState().columnSizingInfo + const resizingColumnId = columnSizingInfo.isResizingColumn + const resizeMode = + props.tableLayout?.columnsResizeMode ?? table.options.columnResizeMode + + if ( + !props.tableLayout?.columnsResizable || + resizeMode !== "onEnd" || + !resizingColumnId + ) { + return null + } + + const resizingHeader = table + .getFlatHeaders() + .find( + (header) => + header.column.id === resizingColumnId || header.id === resizingColumnId + ) + + if (!resizingHeader) return null + + const deltaOffset = columnSizingInfo.deltaOffset ?? 0 + const headerHeight = + viewportElement + ?.querySelector('[data-slot="data-grid-table"] thead') + ?.getBoundingClientRect().height ?? 0 + const indicatorLeft = + typeof columnSizingInfo.startOffset === "number" && viewportElement + ? columnSizingInfo.startOffset - + viewportElement.getBoundingClientRect().left + : resizingHeader.getStart() + resizingHeader.getSize() + + return ( +
+} + +function DataGridTableBody({ children }: { children: ReactNode }) { + const { props } = useDataGrid() + + return ( + + {children} + + ) +} + +function DataGridTableFoot({ children }: { children: ReactNode }) { + const { props } = useDataGrid() + return ( + + {children} + + ) +} + +function DataGridTableFootRow({ children }: { children: ReactNode }) { + const { props } = useDataGrid() + return ( + + {children} + + + ) +} + +function DataGridTableFootRowCell({ + children, + colSpan, + className, +}: { + children?: ReactNode + colSpan?: number + className?: string +}) { + const { props } = useDataGrid() + const spacing = footerCellSpacingVariants({ + size: props.tableLayout?.dense ? "dense" : "default", + }) + return ( + + ) +} + +function DataGridTableBodyRowSkeleton({ children }: { children: ReactNode }) { + const { table, props } = useDataGrid() + + return ( + td]:border-b", + props.tableLayout?.cellBorder && "*:last:border-e-0", + props.tableLayout?.stripped && + "odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent", + table.options.enableRowSelection && "*:first:relative", + props.tableClassNames?.bodyRow + )} + > + {children} + + + ) +} + +function DataGridTableBodyRowSkeletonCell({ + children, + column, +}: { + children: ReactNode + column: Column +}) { + const { props, table } = useDataGrid() + const bodyCellSpacing = bodyCellSpacingVariants({ + size: props.tableLayout?.dense ? "dense" : "default", + }) + + return ( + + ) +} + +function DataGridTableBodyRow({ + children, + row, + pinnedBoundary, + rowRef, + dndRef, + dndStyle, +}: { + children: ReactNode + row: Row + pinnedBoundary?: DataGridTablePinnedBoundary + rowRef?: React.Ref + dndRef?: React.Ref + dndStyle?: CSSProperties +}) { + const { props, table } = useDataGrid() + const isRowPinned = row.getIsPinned() + + return ( + { + assignRef(rowRef, node) + assignRef(dndRef, node) + }} + style={{ ...(dndStyle ? dndStyle : null) }} + data-state={ + table.options.enableRowSelection && row.getIsSelected() + ? "selected" + : undefined + } + data-row-pinned={isRowPinned || undefined} + data-row-pinned-boundary={pinnedBoundary} + onClick={() => props.onRowClick && props.onRowClick(row.original)} + className={cn( + "hover:bg-muted/40 data-[state=selected]:bg-muted/50", + props.onRowClick && "cursor-pointer", + !props.tableLayout?.stripped && + props.tableLayout?.rowBorder && + "border-border border-b [&:not(:last-child)>td]:border-b", + props.tableLayout?.cellBorder && "*:last:border-e-0", + props.tableLayout?.stripped && + "odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent", + table.options.enableRowSelection && "*:first:relative", + props.tableLayout?.rowsPinnable && + isRowPinned && + "bg-muted/30 hover:bg-muted/50", + pinnedBoundary === "top" && "[&>td]:shadow-[0_2px_0_rgba(0,0,0,0.03)]", + pinnedBoundary === "bottom" && + "[&>td]:shadow-[0_2px_0_rgba(0,0,0,0.03)]", + props.tableClassNames?.bodyRow + )} + > + {children} + + + ) +} + +function DataGridTableBodyRowExpandded({ row }: { row: Row }) { + const { props, table } = useDataGrid() + + return ( + td]:border-b" + )} + > + + + ) +} + +function DataGridTableBodyRowCell({ + children, + cell, + dndRef, + dndStyle, +}: { + children: ReactNode + cell: Cell + dndRef?: React.Ref + dndStyle?: CSSProperties +}) { + const { props } = useDataGrid() + + const { column, row } = cell + const isPinned = column.getIsPinned() + const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left") + const isFirstRightPinned = + isPinned === "right" && column.getIsFirstColumn("right") + const bodyCellSpacing = bodyCellSpacingVariants({ + size: props.tableLayout?.dense ? "dense" : "default", + }) + + return ( + + ) +} + +function DataGridTableRenderedRow({ + row, + pinnedBoundary, + rowRef, +}: { + row: Row + pinnedBoundary?: DataGridTablePinnedBoundary + rowRef?: React.Ref +}) { + return ( + + + {row.getVisibleCells().map((cell: Cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + {row.getIsExpanded() && } + + ) +} + +function DataGridTableEmpty() { + const { table, props } = useDataGrid() + const visibleColumnCount = + table.getVisibleLeafColumns().length + + (props.tableLayout?.columnsResizable ? 1 : 0) + + return ( + + + + ) +} + +function DataGridTableLoader() { + const { props } = useDataGrid() + + return ( +
+
+ + {props.loadingMessage || "Loading..."} +
+
+ ) +} + +function DataGridTableRowPin({ row }: { row: Row }) { + const isPinned = row.getIsPinned() + + return ( + + ) +} + +function DataGridTableRowSelect({ row }: { row: Row }) { + return ( + <> + + row.toggleSelected(!!value)} + aria-label="Select row" + className="align-[inherit]" + /> + + ) +} + +function DataGridTableRowSelectAll() { + const { table, recordCount, isLoading } = useDataGrid() + + const isAllSelected = table.getIsAllPageRowsSelected() + const isSomeSelected = table.getIsSomePageRowsSelected() + + return ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="align-[inherit]" + /> + ) +} + +function DataGridTableBodyRows({ table }: { table: Table }) { + const { isLoading, props } = useDataGrid() + const pagination = table.getState().pagination + + if (isLoading && props.loadingMode === "skeleton" && pagination?.pageSize) { + return ( + <> + {Array.from({ length: pagination.pageSize }).map((_, rowIndex) => ( + + {table.getVisibleFlatColumns().map((column, colIndex) => ( + + {column.columnDef.meta?.skeleton} + + ))} + + ))} + + ) + } + + if (isLoading && props.loadingMode === "spinner") { + return ( + + + + ) + } + + const resolvedRows = getDataGridTableResolvedRows( + table, + props.tableLayout?.rowsPinnable + ) + + if (!resolvedRows.length) return + + return ( + <> + {resolvedRows.map(({ row, pinnedBoundary }) => ( + + ))} + + ) +} + +/** + * Memoized body rows: skip re-renders during active column resize. + * Column widths update via CSS variables on the
+ {children} +
+ {children} +
+ {children} +
+ {table + .getAllColumns() + .find((column) => column.columnDef.meta?.expandedContent) + ?.columnDef.meta?.expandedContent?.(row.original)} +
+ {children} +
+ {props.emptyMessage || "No data available"} +
+
+ + + + + {props.loadingMessage || "Loading..."} +
+
element, + * so the browser handles width changes without React re-renders. + */ +const MemoizedDataGridTableBodyRows = memo( + DataGridTableBodyRows, + (_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn +) as typeof DataGridTableBodyRows + +function DataGridTableHeader() { + const { table, props } = useDataGrid() + + return ( + + + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => { + return ( + + {headerGroup.headers.map((header, index) => { + const { column } = header + + return ( + + {header.isPlaceholder ? null : props.tableLayout + ?.columnsResizable && column.getCanResize() ? ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {props.tableLayout?.columnsResizable && + column.getCanResize() && ( + + )} +
+ ) + })} +
+ ) + })} +
+
+
+ ) +} + +function DataGridTable({ + footerContent, + renderHeader = true, +}: { + footerContent?: ReactNode + renderHeader?: boolean +}) { + const { table, props } = useDataGrid() + + return ( + + + {renderHeader && ( + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => { + return ( + + {headerGroup.headers.map((header, index) => { + const { column } = header + + return ( + + {header.isPlaceholder ? null : props.tableLayout + ?.columnsResizable && column.getCanResize() ? ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {props.tableLayout?.columnsResizable && + column.getCanResize() && ( + + )} +
+ ) + })} +
+ ) + })} +
+ )} + + {renderHeader && + (props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( + + )} + + + + + + {footerContent && ( + {footerContent} + )} +
+
+ ) +} + +export { + DataGridTable, + DataGridTableBase, + DataGridTableBody, + DataGridTableBodyRow, + DataGridTableBodyRowCell, + DataGridTableBodyRowExpandded, + DataGridTableRenderedRow, + DataGridTableBodyRowSkeleton, + DataGridTableBodyRowSkeletonCell, + DataGridTableEmpty, + DataGridTableFoot, + DataGridTableFootRow, + DataGridTableFootRowCell, + DataGridTableHeader, + DataGridTableHead, + DataGridTableHeadRow, + DataGridTableHeadRowCell, + DataGridTableHeadRowCellResize, + DataGridTableLoader, + DataGridTableRowPin, + DataGridTableRowSelect, + DataGridTableRowSelectAll, + DataGridTableRowSpacer, + DataGridTableViewport, + getDataGridTableResolvedRows, + getDataGridTableRowSections, +} + +export type { DataGridTablePinnedBoundary } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid.tsx b/apps/web/src/components/reui/data-grid/data-grid.tsx new file mode 100644 index 0000000..e7361eb --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid.tsx @@ -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 { + 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( + column: Column +): 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 = { + data: T[] + empty: boolean + pagination: { + total: number + page: number + } +} + +export interface DataGridContextProps { + props: DataGridProps + table: Table + recordCount: number + isLoading: boolean +} + +export type DataGridRequestParams = { + pageIndex: number + pageSize: number + sorting?: SortingState + columnFilters?: ColumnFiltersState +} + +export interface DataGridProps { + className?: string + table?: Table + 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 | undefined +>(undefined) + +function useDataGrid() { + const context = useContext(DataGridContext) + if (!context) { + throw new Error("useDataGrid must be used within a DataGridProvider") + } + return context +} + +function DataGridProvider({ + children, + table, + ...props +}: DataGridProps & { table: Table }) { + 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
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 ( + + {children} + + ) +} + +function DataGrid({ + children, + table, + ...props +}: DataGridProps) { + const defaultProps: Partial> = { + 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 = { + ...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 ( + + {children} + + ) +} + +function DataGridContainer({ + children, + className, + border = true, +}: { + children: ReactNode + className?: string + border?: boolean +}) { + return ( +
+ {children} +
+ ) +} + +export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer } \ No newline at end of file diff --git a/apps/web/src/components/reui/date-selector.tsx b/apps/web/src/components/reui/date-selector.tsx new file mode 100644 index 0000000..ee78445 --- /dev/null +++ b/apps/web/src/components/reui/date-selector.tsx @@ -0,0 +1,1332 @@ +"use client" + +import { + ChangeEvent, + ComponentProps, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react" +import { + addMonths, + format, + isBefore, + isSameMonth, + parse, + subMonths, +} from "date-fns" +import { DayButton } from "react-day-picker" +import type { DateRange } from "react-day-picker" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@evobgp/ui/lib/utils" +import { Button } from "@evobgp/ui/components/button" +import { Calendar, CalendarDayButton } from "@evobgp/ui/components/calendar" +import { Input } from "@evobgp/ui/components/input" +import { ScrollArea } from "@evobgp/ui/components/scroll-area" +import { Tabs, TabsList, TabsTrigger } from "@evobgp/ui/components/tabs" +import { CornerUpLeftIcon, CornerUpRightIcon, ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react" + +export interface DateSelectorI18nConfig { + // Labels + selectDate: string + apply: string + cancel: string + clear: string + today: string + // Filter types + filterTypes: { + is: string + before: string + after: string + between: string + } + // Period types + periodTypes: { + day: string + month: string + quarter: string + halfYear: string + year: string + } + // Months + months: string[] + monthsShort: string[] + // Quarters + quarters: string[] + // Half years + halfYears: string[] + // Weekdays + weekdays: string[] + weekdaysShort: string[] + // Placeholders + placeholder: string + rangePlaceholder: string +} + +export const DEFAULT_DATE_SELECTOR_I18N: DateSelectorI18nConfig = { + selectDate: "Select date", + apply: "Apply", + cancel: "Cancel", + clear: "Clear", + today: "Today", + filterTypes: { + is: "is", + before: "before", + after: "after", + between: "between", + }, + periodTypes: { + day: "Day", + month: "Month", + quarter: "Quarter", + halfYear: "Half-year", + year: "Year", + }, + months: [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ], + monthsShort: [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ], + quarters: ["Q1", "Q2", "Q3", "Q4"], + halfYears: ["H1", "H2"], + weekdays: [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ], + weekdaysShort: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], + placeholder: "Select date...", + rangePlaceholder: "Select date range...", +} + +export type DateSelectorPeriodType = + | "day" + | "month" + | "quarter" + | "half-year" + | "year" +export type DateSelectorFilterType = "is" | "before" | "after" | "between" + +export interface DateSelectorValue { + period: DateSelectorPeriodType + operator: DateSelectorFilterType + startDate?: Date + endDate?: Date + year?: number + month?: number + quarter?: number + halfYear?: number + rangeStart?: { year: number; value: number } + rangeEnd?: { year: number; value: number } +} + +export interface DateSelectorContextValue { + i18n: DateSelectorI18nConfig + variant: "outline" | "default" + size: "sm" | "default" | "lg" +} + +const DateSelectorContext = createContext({ + i18n: DEFAULT_DATE_SELECTOR_I18N, + variant: "outline", + size: "default", +}) + +export const useDateSelectorContext = () => useContext(DateSelectorContext) + +export function formatDateValue( + value: DateSelectorValue, + i18n: DateSelectorI18nConfig = DEFAULT_DATE_SELECTOR_I18N, + dayDateFormat: string = "MM/dd/yyyy" +): string { + const { + period, + startDate, + endDate, + year, + month, + quarter, + halfYear, + rangeStart, + rangeEnd, + } = value + + if (period === "day") { + if (startDate && endDate) { + return `${format(startDate, dayDateFormat)} - ${format(endDate, dayDateFormat)}` + } + if (startDate) { + return format(startDate, dayDateFormat) + } + return "" + } + + if (period === "month") { + if (rangeStart && rangeEnd) { + return `${i18n.monthsShort[rangeStart.value]} ${rangeStart.year} - ${i18n.monthsShort[rangeEnd.value]} ${rangeEnd.year}` + } + if (year !== undefined && month !== undefined) { + return `${i18n.monthsShort[month]} ${year}` + } + return "" + } + + if (period === "quarter") { + if (rangeStart && rangeEnd) { + return `${i18n.quarters[rangeStart.value]} ${rangeStart.year} - ${i18n.quarters[rangeEnd.value]} ${rangeEnd.year}` + } + if (year !== undefined && quarter !== undefined) { + return `${i18n.quarters[quarter]} ${year}` + } + return "" + } + + if (period === "half-year") { + if (rangeStart && rangeEnd) { + return `${i18n.halfYears[rangeStart.value]} ${rangeStart.year} - ${i18n.halfYears[rangeEnd.value]} ${rangeEnd.year}` + } + if (year !== undefined && halfYear !== undefined) { + return `${i18n.halfYears[halfYear]} ${year}` + } + return "" + } + + if (period === "year") { + if (rangeStart && rangeEnd) { + return `${rangeStart.year} - ${rangeEnd.year}` + } + if (year !== undefined) { + return `${year}` + } + return "" + } + + return "" +} + +interface UseDateSelectorOptions { + value?: DateSelectorValue + onChange?: (value: DateSelectorValue) => void + defaultPeriodType?: DateSelectorPeriodType + defaultFilterType?: DateSelectorFilterType + presetMode?: DateSelectorFilterType + allowRange?: boolean + yearRange?: number + baseYear?: number + minYear?: number + maxYear?: number + periodTypes?: DateSelectorPeriodType[] +} + +export function useDateSelector({ + value, + onChange, + defaultPeriodType = "day", + defaultFilterType = "is", + presetMode, + allowRange = true, + yearRange = 11, + baseYear, + minYear, + maxYear, + periodTypes, +}: UseDateSelectorOptions) { + const currentYear = baseYear ?? new Date().getFullYear() + + const validDefaultPeriodType = useMemo(() => { + if (!periodTypes || periodTypes.length === 0) return defaultPeriodType + if (periodTypes.includes(defaultPeriodType)) return defaultPeriodType + return periodTypes[0] + }, [periodTypes, defaultPeriodType]) + + // Use presetMode if provided, otherwise use value or default + const effectiveFilterType = presetMode ?? value?.operator ?? defaultFilterType + + const [periodType, setPeriodType] = useState( + value?.period || validDefaultPeriodType + ) + const [filterType, setFilterType] = + useState(effectiveFilterType) + const [selectedDate, setSelectedDate] = useState( + value?.startDate + ) + const [selectedEndDate, setSelectedEndDate] = useState( + value?.endDate + ) + const [calendarMonth, setCalendarMonth] = useState( + value?.startDate || new Date() + ) + const [selectedYear, setSelectedYear] = useState( + value?.year + ) + const [selectedMonth, setSelectedMonth] = useState( + value?.month + ) + const [selectedQuarter, setSelectedQuarter] = useState( + value?.quarter + ) + const [selectedHalfYear, setSelectedHalfYear] = useState( + value?.halfYear + ) + const [rangeStart, setRangeStart] = useState< + { year: number; value: number } | undefined + >(value?.rangeStart) + const [rangeEnd, setRangeEnd] = useState< + { year: number; value: number } | undefined + >(value?.rangeEnd) + const [hoverDate, setHoverDate] = useState() + + const years = useMemo(() => { + if (minYear !== undefined && maxYear !== undefined) { + return Array.from( + { length: maxYear - minYear + 1 }, + (_, i) => minYear + i + ) + } + return Array.from( + { length: yearRange }, + (_, i) => currentYear - Math.floor(yearRange / 2) + i + ) + }, [currentYear, yearRange, minYear, maxYear]) + + const currentValue = useMemo( + () => ({ + period: periodType, + operator: presetMode ?? filterType, + startDate: selectedDate, + endDate: selectedEndDate, + year: selectedYear, + month: selectedMonth, + quarter: selectedQuarter, + halfYear: selectedHalfYear, + rangeStart, + rangeEnd, + }), + [ + periodType, + presetMode, + filterType, + selectedDate, + selectedEndDate, + selectedYear, + selectedMonth, + selectedQuarter, + selectedHalfYear, + rangeStart, + rangeEnd, + ] + ) + + const clearSelection = useCallback(() => { + setSelectedDate(undefined) + setSelectedEndDate(undefined) + setSelectedYear(undefined) + setSelectedMonth(undefined) + setSelectedQuarter(undefined) + setSelectedHalfYear(undefined) + setRangeStart(undefined) + setRangeEnd(undefined) + }, []) + + const handleDayClick = useCallback( + (day: Date) => { + if (filterType === "between" && allowRange) { + if (!selectedDate || (selectedDate && selectedEndDate)) { + setSelectedDate(day) + setSelectedEndDate(undefined) + } else { + if (isBefore(day, selectedDate)) { + setSelectedEndDate(selectedDate) + setSelectedDate(day) + } else { + setSelectedEndDate(day) + } + } + } else { + setSelectedDate(day) + setSelectedEndDate(undefined) + } + }, + [filterType, allowRange, selectedDate, selectedEndDate] + ) + + const handlePeriodSelect = useCallback( + (year: number, value: number) => { + if (filterType === "between" && allowRange) { + if (!rangeStart || (rangeStart && rangeEnd)) { + setRangeStart({ year, value }) + setRangeEnd(undefined) + setSelectedYear(year) + if (periodType === "month") setSelectedMonth(value) + if (periodType === "quarter") setSelectedQuarter(value) + if (periodType === "half-year") setSelectedHalfYear(value) + } else { + const startKey = rangeStart.year * 100 + rangeStart.value + const endKey = year * 100 + value + if (endKey < startKey) { + setRangeEnd(rangeStart) + setRangeStart({ year, value }) + } else { + setRangeEnd({ year, value }) + } + } + } else { + setSelectedYear(year) + if (periodType === "month") setSelectedMonth(value) + if (periodType === "quarter") setSelectedQuarter(value) + if (periodType === "half-year") setSelectedHalfYear(value) + setRangeStart(undefined) + setRangeEnd(undefined) + } + }, + [filterType, allowRange, rangeStart, rangeEnd, periodType] + ) + + const handleYearSelect = useCallback( + (year: number) => { + if (filterType === "between" && allowRange) { + if (!rangeStart || (rangeStart && rangeEnd)) { + setRangeStart({ year, value: 0 }) + setRangeEnd(undefined) + setSelectedYear(year) + } else { + if (year < rangeStart.year) { + setRangeEnd(rangeStart) + setRangeStart({ year, value: 0 }) + } else { + setRangeEnd({ year, value: 0 }) + } + } + } else { + setSelectedYear(year) + setRangeStart(undefined) + setRangeEnd(undefined) + } + }, + [filterType, allowRange, rangeStart, rangeEnd] + ) + + const handlePeriodTypeChange = useCallback( + (type: DateSelectorPeriodType) => { + setPeriodType(type) + clearSelection() + }, + [clearSelection] + ) + + const handleFilterTypeChange = useCallback( + (type: DateSelectorFilterType) => { + // Don't allow changes if presetMode is set + if (presetMode !== undefined) return + setFilterType(type) + clearSelection() + }, + [clearSelection, presetMode] + ) + + const isInRange = useCallback( + (year: number, value: number) => { + if (!rangeStart || !rangeEnd) return false + const key = year * 100 + value + const startKey = rangeStart.year * 100 + rangeStart.value + const endKey = rangeEnd.year * 100 + rangeEnd.value + return key >= startKey && key <= endKey + }, + [rangeStart, rangeEnd] + ) + + const isYearInRange = useCallback( + (year: number) => { + if (!rangeStart || !rangeEnd) return false + return year >= rangeStart.year && year <= rangeEnd.year + }, + [rangeStart, rangeEnd] + ) + + useEffect(() => { + if (value) { + setPeriodType(value.period || validDefaultPeriodType) + // Use presetMode if provided, otherwise use value's operator or default + const newFilterType = presetMode ?? value.operator ?? defaultFilterType + setFilterType(newFilterType) + setSelectedDate(value.startDate) + setSelectedEndDate(value.endDate) + setSelectedYear(value.year) + setSelectedMonth(value.month) + setSelectedQuarter(value.quarter) + setSelectedHalfYear(value.halfYear) + setRangeStart(value.rangeStart) + setRangeEnd(value.rangeEnd) + } + }, [value, validDefaultPeriodType, defaultFilterType, presetMode]) + + // Sync filterType when presetMode changes + useEffect(() => { + if (presetMode !== undefined) { + setFilterType(presetMode) + } + }, [presetMode]) + + useEffect(() => { + onChange?.(currentValue) + }, [currentValue, onChange]) + + return { + // State + periodType, + filterType, + selectedDate, + selectedEndDate, + calendarMonth, + selectedYear, + selectedMonth, + selectedQuarter, + selectedHalfYear, + rangeStart, + rangeEnd, + hoverDate, + years, + currentValue, + allowRange, + + // Setters + setPeriodType: handlePeriodTypeChange, + setFilterType: handleFilterTypeChange, + setSelectedDate, + setSelectedEndDate, + setCalendarMonth, + setHoverDate, + + // Actions + clearSelection, + handleDayClick, + handlePeriodSelect, + handleYearSelect, + isInRange, + isYearInRange, + } +} + +interface DateSelectorFilterToggleProps { + value: DateSelectorFilterType + onChange: (value: DateSelectorFilterType) => void + showBetween?: boolean + showIs?: boolean + presetMode?: DateSelectorFilterType + className?: string +} + +function DateSelectorFilterToggle({ + value, + onChange, + showBetween = true, + showIs = true, + presetMode, + className, +}: DateSelectorFilterToggleProps) { + const { i18n } = useDateSelectorContext() + const isDisabled = presetMode !== undefined + + return ( + { + if (!isDisabled && newValue) { + onChange(newValue as DateSelectorFilterType) + } + }} + className={className} + > + + {showIs && ( + + {i18n.filterTypes.is} + + )} + + {i18n.filterTypes.before} + + + {i18n.filterTypes.after} + + {showBetween && ( + + {i18n.filterTypes.between} + + )} + + + ) +} + +interface DateSelectorDateSelectorPeriodTabsProps { + value: DateSelectorPeriodType + onChange: (value: DateSelectorPeriodType) => void + periodTypes?: DateSelectorPeriodType[] + className?: string + calendarMonth?: Date + onMonthChange?: (date: Date) => void + showNavigationButtons?: boolean +} + +function DateSelectorPeriodTabs({ + value, + onChange, + periodTypes, + className, + calendarMonth, + onMonthChange, + showNavigationButtons = false, +}: DateSelectorDateSelectorPeriodTabsProps) { + const { i18n } = useDateSelectorContext() + + const tabs: { value: DateSelectorPeriodType; label: string }[] = [ + { value: "day", label: i18n.periodTypes.day }, + { value: "month", label: i18n.periodTypes.month }, + { value: "quarter", label: i18n.periodTypes.quarter }, + { value: "half-year", label: i18n.periodTypes.halfYear }, + { value: "year", label: i18n.periodTypes.year }, + ] + + const filteredTabs = periodTypes + ? tabs.filter((tab) => periodTypes.includes(tab.value)) + : tabs + + return ( +
+ { + if (newValue) { + onChange(newValue as DateSelectorPeriodType) + } + }} + > + + {filteredTabs.map((tab) => ( + + {tab.label} + + ))} + + + {showNavigationButtons && + value === "day" && + calendarMonth && + onMonthChange && ( +
+ {(() => { + const today = new Date() + const isCurrentMonth = isSameMonth(calendarMonth, today) + + // Only show today button if not on current month + if (isCurrentMonth) { + return null + } + + // Determine direction based on whether calendarMonth is in future or past + const isFuture = calendarMonth > today + + return ( + + ) + })()} + + +
+ )} +
+ ) +} + +interface DateSelectorDayPickerProps { + currentMonth: Date + selectedDate?: Date + selectedEndDate?: Date + onDayClick: (day: Date) => void + isRange: boolean + onDayHover?: (day: Date | undefined) => void + hoverDate?: Date + showTwoMonths?: boolean + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 + className?: string +} + +function DateSelectorDayPicker({ + currentMonth, + selectedDate, + selectedEndDate, + onDayClick, + isRange, + onDayHover, + hoverDate, + showTwoMonths = true, + weekStartsOn, + className, +}: DateSelectorDayPickerProps) { + const { i18n } = useDateSelectorContext() + const isMobile = useIsMobile() + + // Convert to react-day-picker format + const selected: Date | DateRange | undefined = isRange + ? selectedDate && selectedEndDate + ? { from: selectedDate, to: selectedEndDate } + : selectedDate + ? { from: selectedDate, to: hoverDate || selectedDate } + : undefined + : selectedDate + + const handleSelect = (date: Date | DateRange | undefined) => { + if (!date) { + return + } + + if (isRange && "from" in date) { + // For range mode + if (date.from && !date.to) { + // First click - set start date + onDayClick(date.from) + } else if (date.from && date.to) { + // Range selected - set end date + onDayClick(date.to) + } + } else if (!isRange && date instanceof Date) { + onDayClick(date) + } + } + + // Create custom DayButton component with hover support + const CustomDayButton = useCallback( + (props: ComponentProps) => { + return ( + { + if (isRange && onDayHover && props.day) { + onDayHover(props.day.date) + } + }} + onMouseLeave={() => { + if (isRange && onDayHover) { + onDayHover(undefined) + } + }} + /> + ) + }, + [isRange, onDayHover] + ) + + // Create custom formatters for i18n + const formatters = { + formatWeekdayName: (date: Date) => { + const dayIndex = date.getDay() + return i18n.weekdaysShort[dayIndex] || i18n.weekdays[dayIndex] + }, + formatMonthCaption: (date: Date) => { + const monthIndex = date.getMonth() + const year = date.getFullYear() + return `${i18n.months[monthIndex]} ${year}` + }, + } + + return ( +
+ {isRange ? ( + void} + numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1} + showOutsideDays={true} + weekStartsOn={weekStartsOn} + formatters={formatters} + className="w-full shrink-0 p-0" + classNames={{ + months: "flex flex-wrap items-start justify-between gap-5 w-full", + month: "flex flex-col items-center min-w-0 flex-1", + nav: "hidden", + }} + components={{ + DayButton: CustomDayButton, + }} + /> + ) : ( + void} + numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1} + showOutsideDays={true} + weekStartsOn={weekStartsOn} + formatters={formatters} + className="w-full shrink-0 p-0" + classNames={{ + months: "flex flex-wrap items-start justify-between gap-5 w-full", + month: "flex flex-col items-center min-w-0 flex-1", + nav: "hidden", + }} + components={{ + DayButton: CustomDayButton, + }} + /> + )} +
+ ) +} + +interface DateSelectorDateSelectorPeriodGridProps { + years: number[] + items: string[] + selectedYear?: number + selectedValue?: number + rangeStart?: { year: number; value: number } + rangeEnd?: { year: number; value: number } + isInRange: (year: number, value: number) => boolean + onSelect: (year: number, value: number) => void + columns: number + className?: string +} + +function DateSelectorPeriodGrid({ + years, + items, + selectedYear, + selectedValue, + rangeStart, + rangeEnd, + isInRange, + onSelect, + columns, + className, +}: DateSelectorDateSelectorPeriodGridProps) { + return ( +
+ {years.map((year) => ( +
+
+ {year} +
+
+ {items.map((item, index) => { + const isSelected = + selectedYear === year && selectedValue === index + const isRangeStart = + rangeStart?.year === year && rangeStart?.value === index + const isRangeEnd = + rangeEnd?.year === year && rangeEnd?.value === index + const inRange = isInRange(year, index) + + return ( + + ) + })} +
+
+ ))} +
+ ) +} + +interface DateSelectorYearListProps { + years: number[] + selectedYear?: number + rangeStart?: { year: number; value: number } + rangeEnd?: { year: number; value: number } + isYearInRange: (year: number) => boolean + onSelect: (year: number) => void + className?: string +} + +function DateSelectorYearList({ + years, + selectedYear, + rangeStart, + rangeEnd, + isYearInRange, + onSelect, + className, +}: DateSelectorYearListProps) { + return ( +
+ {years.map((year) => { + const isSelected = selectedYear === year && !rangeStart && !rangeEnd + const isRangeStart = rangeStart?.year === year + const isRangeEnd = rangeEnd?.year === year + const inRange = isYearInRange(year) + + return ( + + ) + })} +
+ ) +} + +export interface DateSelectorProps { + value?: DateSelectorValue + onChange?: (value: DateSelectorValue) => void + allowRange?: boolean + periodTypes?: DateSelectorPeriodType[] + defaultPeriodType?: DateSelectorPeriodType + defaultFilterType?: DateSelectorFilterType + presetMode?: DateSelectorFilterType + showInput?: boolean + showTwoMonths?: boolean + label?: string + className?: string + yearRange?: number + baseYear?: number + minYear?: number + maxYear?: number + i18n?: Partial + inputHint?: string + dayDateFormat?: string + dayDateFormats?: string[] + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 +} + +export function DateSelector({ + value, + onChange, + allowRange = true, + periodTypes, + defaultPeriodType = "day", + defaultFilterType = "is", + presetMode, + showInput = true, + showTwoMonths = true, + label, + className, + yearRange = 10, + baseYear, + minYear = 2015, + maxYear = 2026, + i18n: i18nOverride, + inputHint, + dayDateFormat = "MM/dd/yyyy", + dayDateFormats, + weekStartsOn, +}: DateSelectorProps) { + const mergedI18n = useMemo( + () => ({ ...DEFAULT_DATE_SELECTOR_I18N, ...i18nOverride }), + [i18nOverride] + ) + + const selector = useDateSelector({ + value, + onChange, + defaultPeriodType, + defaultFilterType, + presetMode, + allowRange, + yearRange, + baseYear, + minYear, + maxYear, + periodTypes, + }) + + const { + periodType, + filterType, + selectedDate, + selectedEndDate, + calendarMonth, + selectedYear, + selectedMonth, + selectedQuarter, + selectedHalfYear, + rangeStart, + rangeEnd, + hoverDate, + years, + currentValue, + setPeriodType, + setFilterType, + setCalendarMonth, + setHoverDate, + clearSelection, + handleDayClick, + handlePeriodSelect, + handleYearSelect, + isInRange, + isYearInRange, + } = selector + + const displayValue = formatDateValue(currentValue, mergedI18n, dayDateFormat) + const [inputValue, setInputValue] = useState(displayValue) + const [isInputFocused, setIsInputFocused] = useState(false) + + // Sync input value when displayValue changes (but not when user is typing) + useEffect(() => { + if (!isInputFocused) { + setInputValue(displayValue) + } + }, [displayValue, isInputFocused]) + + // Compute date formats for parsing + const dateFormats = useMemo(() => { + if (dayDateFormats && dayDateFormats.length > 0) { + // Use provided formats, with dayDateFormat first if not already included + const formats = [...dayDateFormats] + if (!formats.includes(dayDateFormat)) { + formats.unshift(dayDateFormat) + } + return formats + } + // Default formats: use dayDateFormat first, then common alternatives + const defaultFormats = [ + dayDateFormat, + "dd/MM/yyyy", + "yyyy-MM-dd", + "MM-dd-yyyy", + "dd-MM-yyyy", + ] + // Remove duplicates while preserving order + return Array.from(new Set(defaultFormats)) + }, [dayDateFormat, dayDateFormats]) + + // Parse input text to DateSelectorValue + const parseInputValue = useCallback( + (text: string): DateSelectorValue | null => { + if (!text.trim()) return null + + const trimmed = text.trim() + + // Try parsing as year (e.g., "2025") + const yearMatch = trimmed.match(/^\d{4}$/) + if (yearMatch) { + const year = parseInt(yearMatch[0]) + if (year >= 1900 && year <= 2100) { + return { + period: "year", + operator: presetMode ?? filterType, + year, + } + } + } + + // Try parsing as quarter (e.g., "Q4", "Q1 2025") + const quarterMatch = trimmed.match(/^Q([1-4])(?:\s+(\d{4}))?$/i) + if (quarterMatch) { + const quarter = parseInt(quarterMatch[1]) - 1 + const year = quarterMatch[2] + ? parseInt(quarterMatch[2]) + : new Date().getFullYear() + if (year >= 1900 && year <= 2100) { + return { + period: "quarter", + operator: presetMode ?? filterType, + year, + quarter, + } + } + } + + // Try parsing as date using computed formats + for (const dateFormat of dateFormats) { + try { + const parsed = parse(trimmed, dateFormat, new Date()) + if (!isNaN(parsed.getTime())) { + return { + period: "day", + operator: presetMode ?? filterType, + startDate: parsed, + } + } + } catch { + // Continue to next format + } + } + + return null + }, + [filterType, presetMode, dateFormats] + ) + + const handleInputChange = useCallback( + (e: ChangeEvent) => { + const newValue = e.target.value + setInputValue(newValue) + + // Try to parse the input + const parsed = parseInputValue(newValue) + if (parsed) { + onChange?.(parsed) + } + }, + [onChange, parseInputValue] + ) + + const handleInputBlur = useCallback(() => { + setIsInputFocused(false) + // Reset to display value if parsing failed + if (!parseInputValue(inputValue)) { + setInputValue(displayValue) + } + }, [inputValue, displayValue, parseInputValue]) + + return ( + +
+
+ {label && ( +

+ {label} +

+ )} + +
+ {showInput && ( +
+ setIsInputFocused(true)} + onBlur={handleInputBlur} + onChange={handleInputChange} + /> + {(inputHint ? inputValue : displayValue) && ( + + )} +
+ )} + + + {periodType === "day" ? ( +
+ +
+ ) : ( +
+ + {periodType === "month" && ( + + )} + + {periodType === "quarter" && ( + + )} + + {periodType === "half-year" && ( + + )} + + {periodType === "year" && ( + + )} + +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/reui/filters.tsx b/apps/web/src/components/reui/filters.tsx new file mode 100644 index 0000000..231377b --- /dev/null +++ b/apps/web/src/components/reui/filters.tsx @@ -0,0 +1,1932 @@ +import type React from "react" +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react" +import { useRender } from "@base-ui/react/use-render" +import { cva } from "class-variance-authority" + +import { cn } from "@evobgp/ui/lib/utils" +import { Button } from "@evobgp/ui/components/button" +import { + ButtonGroup, + ButtonGroupText, +} from "@evobgp/ui/components/button-group" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@evobgp/ui/components/dropdown-menu" +import { Input } from "@evobgp/ui/components/input" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + InputGroupText, +} from "@evobgp/ui/components/input-group" +import { Kbd } from "@evobgp/ui/components/kbd" +import { ScrollArea } from "@evobgp/ui/components/scroll-area" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@evobgp/ui/components/tooltip" +import { AlertCircleIcon, XIcon, CheckIcon } from "lucide-react" + +// i18n Configuration Interface +export interface FilterI18nConfig { + // UI Labels + addFilter: string + searchFields: string + noFieldsFound: string + noResultsFound: string + select: string + true: string + false: string + min: string + max: string + to: string + typeAndPressEnter: string + selected: string + selectedCount: string + percent: string + defaultCurrency: string + defaultColor: string + addFilterTitle: string + + // Operators + operators: { + is: string + isNot: string + isAnyOf: string + isNotAnyOf: string + includesAll: string + excludesAll: string + before: string + after: string + between: string + notBetween: string + contains: string + notContains: string + startsWith: string + endsWith: string + isExactly: string + equals: string + notEquals: string + greaterThan: string + lessThan: string + overlaps: string + includes: string + excludes: string + includesAllOf: string + includesAnyOf: string + empty: string + notEmpty: string + } + + // Placeholders + placeholders: { + enterField: (fieldType: string) => string + selectField: string + searchField: (fieldName: string) => string + enterKey: string + enterValue: string + } + + // Helper functions + helpers: { + formatOperator: (operator: string) => string + } + + // Validation + validation: { + invalidEmail: string + invalidUrl: string + invalidTel: string + invalid: string + } +} + +// Default English i18n configuration +export const DEFAULT_I18N: FilterI18nConfig = { + // UI Labels + addFilter: "Filter", + searchFields: "Filter...", + noFieldsFound: "No filters found.", + noResultsFound: "No results found.", + select: "Select...", + true: "True", + false: "False", + min: "Min", + max: "Max", + to: "to", + typeAndPressEnter: "Type and press Enter to add tag", + selected: "selected", + selectedCount: "selected", + percent: "%", + defaultCurrency: "$", + defaultColor: "#000000", + addFilterTitle: "Add filter", + + // Operators + operators: { + is: "is", + isNot: "is not", + isAnyOf: "is any of", + isNotAnyOf: "is not any of", + includesAll: "includes all", + excludesAll: "excludes all", + before: "before", + after: "after", + between: "between", + notBetween: "not between", + contains: "contains", + notContains: "does not contain", + startsWith: "starts with", + endsWith: "ends with", + isExactly: "is exactly", + equals: "equals", + notEquals: "not equals", + greaterThan: "greater than", + lessThan: "less than", + overlaps: "overlaps", + includes: "includes", + excludes: "excludes", + includesAllOf: "includes all of", + includesAnyOf: "includes any of", + empty: "is empty", + notEmpty: "is not empty", + }, + + // Placeholders + placeholders: { + enterField: (fieldType: string) => `Enter ${fieldType}...`, + selectField: "Select...", + searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`, + enterKey: "Enter key...", + enterValue: "Enter value...", + }, + + // Helper functions + helpers: { + formatOperator: (operator: string) => operator.replace(/_/g, " "), + }, + + // Validation + validation: { + invalidEmail: "Invalid email format", + invalidUrl: "Invalid URL format", + invalidTel: "Invalid phone format", + invalid: "Invalid input format", + }, +} + +// Context for all Filter component props +interface FilterContextValue { + variant: "solid" | "default" + size: "sm" | "default" | "lg" + radius: "default" | "full" + i18n: FilterI18nConfig + className?: string + showSearchInput?: boolean + trigger?: React.ReactNode + allowMultiple?: boolean +} + +const FilterContext = createContext({ + variant: "default", + size: "default", + radius: "default", + i18n: DEFAULT_I18N, + className: undefined, + showSearchInput: true, + trigger: undefined, + allowMultiple: true, +}) + +const useFilterContext = () => useContext(FilterContext) + +// Container variant for filters wrapper +const filtersContainerVariants = cva("flex flex-wrap items-center", { + variants: { + variant: { + solid: "gap-2", + default: "", + }, + size: { + sm: "gap-1.5", + default: "gap-2.5", + lg: "gap-3.5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, +}) + +function FilterInput({ + field, + onBlur, + onKeyDown, + className, + ...props +}: React.InputHTMLAttributes & { + className?: string + field?: FilterFieldConfig +}) { + const context = useFilterContext() + const [isValid, setIsValid] = useState(true) + const [validationMessage, setValidationMessage] = useState("") + const inputRef = useRef(null) + + useEffect(() => { + if (props.autoFocus) { + const timer = setTimeout(() => { + inputRef.current?.focus() + }, 300) + return () => clearTimeout(timer) + } + }, [props.autoFocus]) + + // Validation function to check if input matches pattern + const validateInput = (value: string, pattern?: string): boolean => { + if (!pattern || !value) return true + const regex = new RegExp(pattern) + return regex.test(value) + } + + // Get validation message for field type + const getValidationMessage = (): string => { + return context.i18n.validation.invalid + } + + // Handle blur event - validate when user leaves input + const handleBlur = (e: React.FocusEvent) => { + const value = e.target.value + const pattern = field?.pattern || props.pattern + + // Only validate if there's a value and (pattern or validation function) + if (value && (pattern || field?.validation)) { + let valid = true + let customMessage = "" + + // If there's a custom validation function, use it + if (field?.validation) { + const result = field.validation(value) + // Handle both boolean and object return types + if (typeof result === "boolean") { + valid = result + } else { + valid = result.valid + customMessage = result.message || "" + } + } else if (pattern) { + // Use pattern validation + valid = validateInput(value, pattern) + } + + setIsValid(valid) + setValidationMessage(valid ? "" : customMessage || getValidationMessage()) + } else { + // Reset validation state for empty values or no validation + setIsValid(true) + setValidationMessage("") + } + + // Call the original onBlur if provided + onBlur?.(e) + } + + // Handle keydown event - hide validation error when user starts typing + const handleKeyDown = (e: React.KeyboardEvent) => { + // Hide validation error when user starts typing (any key except special keys) + if ( + !isValid && + ![ + "Tab", + "Escape", + "Enter", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + ].includes(e.key) + ) { + setIsValid(true) + setValidationMessage("") + } + + // Call the original onKeyDown if provided + onKeyDown?.(e) + } + + return ( + + {field?.prefix && ( + + {field.prefix} + + )} + + {!isValid && validationMessage && ( + + + }> + + + +

{validationMessage}

+
+
+
+ )} + + {field?.suffix && ( + + {field.suffix} + + )} +
+ ) +} + +interface FilterRemoveButtonProps extends React.ButtonHTMLAttributes { + icon?: React.ReactNode +} + +function FilterRemoveButton({ + className, + icon = ( + + ), + ...props +}: FilterRemoveButtonProps) { + const context = useFilterContext() + + const sizeMap = { + sm: "sm" as const, + default: "sm" as const, + lg: "default" as const, + } + + return ( + + ) +} + +// Generic types for flexible filter system +export interface FilterOption { + value: T + label: string + icon?: React.ReactNode + metadata?: Record + className?: string +} + +export interface FilterOperator { + value: string + label: string + supportsMultiple?: boolean +} + +// Custom renderer props interface +export interface CustomRendererProps { + field: FilterFieldConfig + values: T[] + onChange: (values: T[]) => void + operator: string +} + +// Grouped field configuration interface +export interface FilterFieldGroup { + group?: string + fields: FilterFieldConfig[] +} + +// Union type for both flat and grouped field configurations +export type FilterFieldsConfig = + | FilterFieldConfig[] + | FilterFieldGroup[] + +export interface FilterFieldConfig { + key?: string + label?: string + icon?: React.ReactNode + type?: "select" | "multiselect" | "text" | "custom" | "separator" + // Group-level configuration + group?: string + fields?: FilterFieldConfig[] + // Field-specific options + options?: FilterOption[] + operators?: FilterOperator[] + customRenderer?: (props: CustomRendererProps) => React.ReactNode + customValueRenderer?: ( + values: T[], + options: FilterOption[] + ) => React.ReactNode + placeholder?: string + searchable?: boolean + maxSelections?: number + min?: number + max?: number + step?: number + prefix?: string | React.ReactNode + suffix?: string | React.ReactNode + pattern?: string + validation?: ( + value: unknown + ) => boolean | { valid: boolean; message?: string } + allowCustomValues?: boolean + className?: string + menuPopupClassName?: string + // Grouping options (legacy support) + groupLabel?: string + // Boolean field options + onLabel?: string + offLabel?: string + // Input event handlers + onInputChange?: (e: React.ChangeEvent) => void + // Default operator to use when creating a filter for this field + defaultOperator?: string + // Controlled values support for this field + value?: T[] + onValueChange?: (values: T[]) => void +} + +// Helper functions to handle both flat and grouped field configurations +const isFieldGroup = ( + item: FilterFieldConfig | FilterFieldGroup +): item is FilterFieldGroup => { + return "fields" in item && Array.isArray(item.fields) +} + +// Helper function to check if a FilterFieldConfig is a group-level configuration +const isGroupLevelField = ( + field: FilterFieldConfig +): boolean => { + return Boolean(field.group && field.fields) +} + +const flattenFields = ( + fields: FilterFieldsConfig +): FilterFieldConfig[] => { + return fields.reduce[]>((acc, item) => { + if (isFieldGroup(item)) { + return [...acc, ...item.fields] + } + // Handle group-level fields (new structure) + if (isGroupLevelField(item)) { + return [...acc, ...item.fields!] + } + return [...acc, item] + }, []) +} + +const getFieldsMap = ( + fields: FilterFieldsConfig +): Record> => { + const flatFields = flattenFields(fields) + return flatFields.reduce( + (acc, field) => { + // Only add fields that have a key (skip group-level configurations) + if (field.key) { + acc[field.key] = field + } + return acc + }, + {} as Record> + ) +} + +// Helper function to create operators from i18n config +const createOperatorsFromI18n = ( + i18n: FilterI18nConfig +): Record => ({ + select: [ + { value: "is", label: i18n.operators.is }, + { value: "is_not", label: i18n.operators.isNot }, + { value: "empty", label: i18n.operators.empty }, + { value: "not_empty", label: i18n.operators.notEmpty }, + ], + multiselect: [ + { value: "is_any_of", label: i18n.operators.isAnyOf }, + { value: "is_not_any_of", label: i18n.operators.isNotAnyOf }, + { value: "includes_all", label: i18n.operators.includesAll }, + { value: "excludes_all", label: i18n.operators.excludesAll }, + { value: "empty", label: i18n.operators.empty }, + { value: "not_empty", label: i18n.operators.notEmpty }, + ], + text: [ + { value: "contains", label: i18n.operators.contains }, + { value: "not_contains", label: i18n.operators.notContains }, + { value: "starts_with", label: i18n.operators.startsWith }, + { value: "ends_with", label: i18n.operators.endsWith }, + { value: "is", label: i18n.operators.isExactly }, + { value: "empty", label: i18n.operators.empty }, + { value: "not_empty", label: i18n.operators.notEmpty }, + ], + custom: [ + { value: "is", label: i18n.operators.is }, + { value: "after", label: i18n.operators.after }, + { value: "is", label: i18n.operators.is }, + { value: "between", label: i18n.operators.between }, + { value: "empty", label: i18n.operators.empty }, + { value: "not_empty", label: i18n.operators.notEmpty }, + ], +}) + +// Default operators for different field types (using default i18n) +export const DEFAULT_OPERATORS: Record = + createOperatorsFromI18n(DEFAULT_I18N) + +// Helper function to get operators for a field +const getOperatorsForField = ( + field: FilterFieldConfig, + values: T[], + i18n: FilterI18nConfig +): FilterOperator[] => { + if (field.operators) return field.operators + + const operators = createOperatorsFromI18n(i18n) + + // Determine field type for operator selection + let fieldType = field.type || "select" + + // If it's a select field but has multiple values, treat as multiselect + if (fieldType === "select" && values.length > 1) { + fieldType = "multiselect" + } + + // If it's a multiselect field or has multiselect operators, use multiselect operators + if (fieldType === "multiselect" || field.type === "multiselect") { + return operators.multiselect + } + + return operators[fieldType] || operators.select +} + +interface FilterOperatorDropdownProps { + field: FilterFieldConfig + operator: string + values: T[] + onChange: (operator: string) => void +} + +function FilterOperatorDropdown({ + field, + operator, + values, + onChange, +}: FilterOperatorDropdownProps) { + const context = useFilterContext() + const operators = getOperatorsForField(field, values, context.i18n) + + // Find the operator label, with fallback to formatted operator name + const operatorLabel = + operators.find((op) => op.value === operator)?.label || + context.i18n.helpers.formatOperator(operator) + + return ( + + + {operatorLabel} + + } + /> + + {operators.map((op) => ( + onChange(op.value)} + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground flex items-center justify-between" + )} + > + {op.label} + + + ))} + + + ) +} + +interface FilterValueSelectorProps { + field: FilterFieldConfig + values: T[] + onChange: (values: T[]) => void + operator: string + autoFocus?: boolean +} + +interface SelectOptionsPopoverProps { + field: FilterFieldConfig + values: T[] + onChange: (values: T[]) => void + onClose?: () => void + inline?: boolean +} + +function SelectOptionsPopover({ + field, + values, + onChange, + onClose, + inline = false, +}: SelectOptionsPopoverProps) { + const [open, setOpen] = useState(false) + const [searchInput, setSearchInput] = useState("") + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const inputRef = useRef(null) + const context = useFilterContext() + const baseId = useId() + + useEffect(() => { + if (open) { + inputRef.current?.focus() + } + }, [open]) + + useEffect(() => { + setHighlightedIndex(-1) + }, [searchInput, open]) + + useEffect(() => { + if (highlightedIndex >= 0 && open) { + const element = document.getElementById( + `${baseId}-item-${highlightedIndex}` + ) + element?.scrollIntoView({ block: "nearest" }) + } + }, [highlightedIndex, open, baseId]) + + const isMultiSelect = field.type === "multiselect" || values.length > 1 + const effectiveValues = + (field.value !== undefined ? (field.value as T[]) : values) || [] + + const selectedOptions = + field.options?.filter((opt) => effectiveValues.includes(opt.value)) || [] + const unselectedOptions = + field.options?.filter((opt) => !effectiveValues.includes(opt.value)) || [] + + // Filter options based on search input + const filteredSelectedOptions = selectedOptions // Keep all selected visible + const filteredUnselectedOptions = unselectedOptions.filter((opt) => + opt.label.toLowerCase().includes(searchInput.toLowerCase()) + ) + + const allFilteredOptions = useMemo( + () => [...filteredSelectedOptions, ...filteredUnselectedOptions], + [filteredSelectedOptions, filteredUnselectedOptions] + ) + + const handleClose = () => { + setOpen(false) + onClose?.() + } + + const renderMenuContent = () => ( + <> + {field.searchable !== false && ( + <> + = 0 + ? `${baseId}-item-${highlightedIndex}` + : undefined + } + placeholder={context.i18n.placeholders.searchField( + field.label || "" + )} + className={cn( + "border-input h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none", + "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0", + open && "placeholder:text-foreground" + )} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} + onBlur={() => open && inputRef.current?.focus()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault() + if (allFilteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev < allFilteredOptions.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (allFilteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : allFilteredOptions.length - 1 + ) + } + } else if (e.key === "ArrowLeft") { + e.preventDefault() + setOpen(false) + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault() + const option = allFilteredOptions[highlightedIndex] + if (option) { + const isSelected = effectiveValues.includes(option.value as T) + const next = isSelected + ? (effectiveValues.filter((v) => v !== option.value) as T[]) + : isMultiSelect + ? ([...effectiveValues, option.value] as T[]) + : ([option.value] as T[]) + + if ( + !isSelected && + isMultiSelect && + field.maxSelections && + next.length > field.maxSelections + ) { + return + } + + if (field.onValueChange) { + field.onValueChange(next) + } else { + onChange(next) + } + if (!isMultiSelect) handleClose() + } + } + e.stopPropagation() + }} + /> + + + )} +
+
+ + {allFilteredOptions.length === 0 && ( +
+ {context.i18n.noResultsFound} +
+ )} + + {/* Selected items */} + {filteredSelectedOptions.length > 0 && ( + + {filteredSelectedOptions.map((option, index) => { + const isHighlighted = highlightedIndex === index + const itemId = `${baseId}-item-${index}` + + return ( + setHighlightedIndex(index)} + checked={true} + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground", + option.className + )} + onSelect={(e) => { + if (isMultiSelect) e.preventDefault() + }} + onCheckedChange={() => { + const next = effectiveValues.filter( + (v) => v !== option.value + ) as T[] + if (field.onValueChange) { + field.onValueChange(next) + } else { + onChange(next) + } + if (!isMultiSelect) handleClose() + }} + > + {option.icon && option.icon} + {option.label} + + ) + })} + + )} + + {/* Separator */} + {filteredSelectedOptions.length > 0 && + filteredUnselectedOptions.length > 0 && ( + + )} + + {/* Available items */} + {filteredUnselectedOptions.length > 0 && ( + + {filteredUnselectedOptions.map((option, index) => { + const overallIndex = index + filteredSelectedOptions.length + const isHighlighted = highlightedIndex === overallIndex + const itemId = `${baseId}-item-${overallIndex}` + + return ( + setHighlightedIndex(overallIndex)} + checked={false} + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground", + option.className + )} + onSelect={(e) => { + if (isMultiSelect) e.preventDefault() + }} + onCheckedChange={() => { + const next = isMultiSelect + ? ([...effectiveValues, option.value] as T[]) + : ([option.value] as T[]) + + if ( + isMultiSelect && + field.maxSelections && + next.length > field.maxSelections + ) { + return + } + + if (field.onValueChange) { + field.onValueChange(next) + } else { + onChange(next) + } + if (!isMultiSelect) handleClose() + }} + > + {option.icon && option.icon} + {option.label} + + ) + })} + + )} +
+
+
+ + ) + + if (inline) { + return
{renderMenuContent()}
+ } + + return ( + { + setOpen(open) + if (!open) { + setTimeout(() => setSearchInput(""), 200) + } + }} + > + +
+ {field.customValueRenderer ? ( + field.customValueRenderer(values, field.options || []) + ) : ( + <> + {selectedOptions.length > 0 && ( +
+ {selectedOptions.slice(0, 3).map((option) => ( +
{option.icon}
+ ))} +
+ )} + {selectedOptions.length === 1 + ? selectedOptions[0].label + : selectedOptions.length > 1 + ? `${selectedOptions.length} ${context.i18n.selectedCount}` + : context.i18n.select} + + )} +
+ + } + /> + + {renderMenuContent()} + +
+ ) +} + +function FilterValueSelector({ + field, + values, + onChange, + operator, + autoFocus, +}: FilterValueSelectorProps) { + const context = useFilterContext() + + if (operator === "empty" || operator === "not_empty") { + return null + } + + if (field.customRenderer) { + return ( + + {field.customRenderer({ field, values, onChange, operator })} + + ) + } + + if (field.type === "text") { + return ( + onChange([e.target.value] as T[])} + placeholder={field.placeholder} + pattern={field.pattern} + field={field} + className={cn("w-36", field.className)} + autoFocus={autoFocus} + /> + ) + } + + if (field.type === "select" || field.type === "multiselect") { + return ( + + ) + } + + return ( + + ) +} +export interface Filter { + id: string + field: string + operator: string + values: T[] +} + +export interface FilterGroup { + id: string + label?: string + filters: Filter[] + fields: FilterFieldConfig[] +} + +interface FiltersContentProps { + filters: Filter[] + fields: FilterFieldsConfig + onChange: (filters: Filter[]) => void +} + +export const FiltersContent = ({ + filters, + fields, + onChange, +}: FiltersContentProps) => { + const context = useFilterContext() + const fieldsMap = useMemo(() => getFieldsMap(fields), [fields]) + + const updateFilter = useCallback( + (filterId: string, updates: Partial>) => { + onChange( + filters.map((filter) => { + if (filter.id === filterId) { + const updatedFilter = { ...filter, ...updates } + if ( + updates.operator === "empty" || + updates.operator === "not_empty" + ) { + updatedFilter.values = [] as T[] + } + return updatedFilter + } + return filter + }) + ) + }, + [filters, onChange] + ) + + const removeFilter = useCallback( + (filterId: string) => { + onChange(filters.filter((filter) => filter.id !== filterId)) + }, + [filters, onChange] + ) + + return ( +
+ {filters.map((filter) => { + const field = fieldsMap[filter.field] + if (!field) return null + + return ( + + + {field.icon && field.icon} + {field.label} + + + + field={field} + operator={filter.operator} + values={filter.values} + onChange={(operator) => updateFilter(filter.id, { operator })} + /> + + + field={field} + values={filter.values} + onChange={(values) => updateFilter(filter.id, { values })} + operator={filter.operator} + autoFocus={false} + /> + + removeFilter(filter.id)} /> + + ) + })} +
+ ) +} + +interface FiltersProps { + filters: Filter[] + fields: FilterFieldsConfig + onChange: (filters: Filter[]) => void + className?: string + variant?: "solid" | "default" + size?: "sm" | "default" | "lg" + radius?: "default" | "full" + i18n?: Partial + showSearchInput?: boolean + trigger?: React.ReactNode + allowMultiple?: boolean + menuPopupClassName?: string + collapseAddButton?: boolean + enableShortcut?: boolean + shortcutKey?: string + shortcutLabel?: string +} + +interface FilterSubmenuContentProps { + field: FilterFieldConfig + currentValues: T[] + isMultiSelect: boolean + onToggle: (value: T, isSelected: boolean) => void + i18n: FilterI18nConfig + isActive?: boolean + onActive?: () => void + onBack?: () => void + onClose?: () => void +} + +function FilterSubmenuContent({ + field, + currentValues, + isMultiSelect, + onToggle, + i18n, + isActive, + onActive, + onBack, + onClose, +}: FilterSubmenuContentProps) { + const [searchInput, setSearchInput] = useState("") + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const inputRef = useRef(null) + const baseId = useId() + + useEffect(() => { + if (isActive) { + if (field.searchable !== false) { + inputRef.current?.focus() + } else { + const listbox = document.getElementById(`${baseId}-listbox`) + listbox?.focus() + } + } + }, [isActive, field.searchable, baseId]) + + useEffect(() => { + setHighlightedIndex(-1) + }, [searchInput]) + + useEffect(() => { + if (highlightedIndex >= 0 && isActive) { + const element = document.getElementById( + `${baseId}-item-${highlightedIndex}` + ) + element?.scrollIntoView({ block: "nearest" }) + } + }, [highlightedIndex, isActive, baseId]) + + const filteredOptions = useMemo(() => { + return ( + field.options?.filter((option) => { + const isSelected = currentValues.includes(option.value) + if (isSelected) return true + if (!searchInput) return true + return option.label.toLowerCase().includes(searchInput.toLowerCase()) + }) || [] + ) + }, [field.options, searchInput, currentValues]) + + useEffect(() => { + if (isActive && filteredOptions.length > 0) { + setHighlightedIndex(0) + } + }, [isActive, filteredOptions.length]) + + return ( +
+ {field.searchable !== false && ( + <> + = 0 + ? `${baseId}-item-${highlightedIndex}` + : undefined + } + placeholder={i18n.placeholders.searchField(field.label || "")} + className={cn( + "h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none", + "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0", + isActive && "placeholder:text-foreground" + )} + value={searchInput} + onBlur={() => isActive && inputRef.current?.focus()} + onChange={(e) => setSearchInput(e.target.value)} + onFocus={() => onActive?.()} + onMouseEnter={(e) => { + onActive?.() + e.stopPropagation() + }} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault() + if (filteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev < filteredOptions.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (filteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredOptions.length - 1 + ) + } + } else if (e.key === "ArrowLeft") { + e.preventDefault() + onBack?.() + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault() + const option = filteredOptions[highlightedIndex] + if (option) { + onToggle( + option.value as T, + currentValues.includes(option.value) + ) + if (!isMultiSelect) { + onBack?.() + } + } + } else if (e.key === "Escape") { + e.preventDefault() + onClose?.() + } + e.stopPropagation() + }} + /> + + + )} +
+
{ + if (field.searchable === false) { + if (e.key === "ArrowDown") { + e.preventDefault() + if (filteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev < filteredOptions.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (filteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredOptions.length - 1 + ) + } + } else if (e.key === "ArrowLeft") { + e.preventDefault() + onBack?.() + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault() + const option = filteredOptions[highlightedIndex] + if (option) { + onToggle( + option.value as T, + currentValues.includes(option.value) + ) + if (!isMultiSelect) { + onBack?.() + } + } + } else if (e.key === "Escape") { + e.preventDefault() + onClose?.() + } + e.stopPropagation() + } + }} + > + + {filteredOptions.length === 0 ? ( +
+ {i18n.noResultsFound} +
+ ) : ( + + {filteredOptions.map((option, index) => { + const isSelected = currentValues.includes(option.value) + const isHighlighted = highlightedIndex === index + const itemId = `${baseId}-item-${index}` + + return ( + setHighlightedIndex(index)} + checked={isSelected} + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground", + option.className + )} + onSelect={(e) => { + if (isMultiSelect) e.preventDefault() + }} + onCheckedChange={() => + onToggle(option.value as T, isSelected) + } + > + {option.icon && option.icon} + {option.label} + + ) + })} + + )} +
+
+
+
+ ) +} + +export function Filters({ + filters, + fields, + onChange, + className, + variant = "default", + size = "default", + radius = "default", + i18n, + showSearchInput = true, + trigger, + allowMultiple = true, + menuPopupClassName, + enableShortcut = false, + shortcutKey = "f", + shortcutLabel = "F", +}: FiltersProps) { + const [addFilterOpen, setAddFilterOpen] = useState(false) + const [menuSearchInput, setMenuSearchInput] = useState("") + const [activeMenu, setActiveMenu] = useState("root") + const [openSubMenu, setOpenSubMenu] = useState(null) + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const [lastAddedFilterId, setLastAddedFilterId] = useState( + null + ) + const rootInputRef = useRef(null) + const rootId = useId() + + useEffect(() => { + if (!enableShortcut) return + + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.key.toLowerCase() === shortcutKey.toLowerCase() && + !addFilterOpen && + !( + document.activeElement instanceof HTMLInputElement || + document.activeElement instanceof HTMLTextAreaElement + ) + ) { + e.preventDefault() + setAddFilterOpen(true) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [enableShortcut, shortcutKey, addFilterOpen]) + + useEffect(() => { + if (addFilterOpen && activeMenu === "root") { + rootInputRef.current?.focus() + } + }, [addFilterOpen, activeMenu]) + + useEffect(() => { + setHighlightedIndex(-1) + }, [menuSearchInput]) + + useEffect(() => { + if (highlightedIndex >= 0 && addFilterOpen) { + const element = document.getElementById( + `${rootId}-item-${highlightedIndex}` + ) + element?.scrollIntoView({ block: "nearest" }) + } + }, [highlightedIndex, addFilterOpen, rootId]) + + useEffect(() => { + if (!addFilterOpen) { + setOpenSubMenu(null) + } + }, [addFilterOpen]) + + // Track which filter instance is being built in the current Add Filter menu session + // Maps fieldKey -> unique filterId created during this open session + const [sessionFilterIds, setSessionFilterIds] = useState< + Record + >({}) + + useEffect(() => { + if (lastAddedFilterId) { + const timer = setTimeout(() => { + setLastAddedFilterId(null) + }, 1000) + return () => clearTimeout(timer) + } + }, [lastAddedFilterId]) + + const mergedI18n: FilterI18nConfig = { + ...DEFAULT_I18N, + ...i18n, + operators: { ...DEFAULT_I18N.operators, ...i18n?.operators }, + placeholders: { ...DEFAULT_I18N.placeholders, ...i18n?.placeholders }, + validation: { ...DEFAULT_I18N.validation, ...i18n?.validation }, + } + + const fieldsMap = useMemo(() => getFieldsMap(fields), [fields]) + + const updateFilter = useCallback( + (filterId: string, updates: Partial>) => { + onChange( + filters.map((filter) => { + if (filter.id === filterId) { + const updatedFilter = { ...filter, ...updates } + if ( + updates.operator === "empty" || + updates.operator === "not_empty" + ) { + updatedFilter.values = [] as T[] + } + return updatedFilter + } + return filter + }) + ) + }, + [filters, onChange] + ) + + const removeFilter = useCallback( + (filterId: string) => { + onChange(filters.filter((filter) => filter.id !== filterId)) + }, + [filters, onChange] + ) + + const addFilter = useCallback( + (fieldKey: string) => { + const field = fieldsMap[fieldKey] + if (field && field.key) { + const defaultOperator = + field.defaultOperator || + (field.type === "multiselect" ? "is_any_of" : "is") + const defaultValues: unknown[] = field.type === "text" ? [""] : [] + const newFilter = createFilter( + fieldKey, + defaultOperator, + defaultValues as T[] + ) + setLastAddedFilterId(newFilter.id) + onChange([...filters, newFilter]) + setAddFilterOpen(false) + setMenuSearchInput("") + } + }, + [fieldsMap, filters, onChange] + ) + + useEffect(() => { + if (addFilterOpen && activeMenu === "root") { + rootInputRef.current?.focus() + } + }, [addFilterOpen, activeMenu]) + + const selectableFields = useMemo(() => { + const flatFields = flattenFields(fields) + return flatFields.filter((field) => { + if (!field.key || field.type === "separator") return false + if (allowMultiple) return true + return !filters.some((filter) => filter.field === field.key) + }) + }, [fields, filters, allowMultiple]) + + const filteredFields = useMemo(() => { + return selectableFields.filter( + (f) => + !menuSearchInput || + f.label?.toLowerCase().includes(menuSearchInput.toLowerCase()) + ) + }, [selectableFields, menuSearchInput]) + + useEffect(() => { + if (addFilterOpen && filteredFields.length > 0) { + setHighlightedIndex(0) + } + }, [addFilterOpen, filteredFields.length]) + + const triggerButton = useRender({ + render: trigger as React.ReactElement, + defaultTagName: "button", + }) + + return ( + +
+ {selectableFields.length > 0 && ( + { + setAddFilterOpen(open) + if (!open) { + setMenuSearchInput("") + setSessionFilterIds({}) + } else { + setActiveMenu("root") + } + }} + > + + + {showSearchInput && ( + <> +
+ = 0 + ? `${rootId}-item-${highlightedIndex}` + : undefined + } + placeholder={mergedI18n.searchFields} + className={cn( + "h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none", + "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0", + activeMenu === "root" && "placeholder:text-foreground" + )} + value={menuSearchInput} + onFocus={() => setActiveMenu("root")} + onMouseEnter={() => setActiveMenu("root")} + onBlur={() => + activeMenu === "root" && rootInputRef.current?.focus() + } + onChange={(e) => setMenuSearchInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault() + if (filteredFields.length > 0) { + setHighlightedIndex((prev) => + prev < filteredFields.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (filteredFields.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredFields.length - 1 + ) + } + } else if ( + (e.key === "ArrowRight" || e.key === "ArrowLeft") && + highlightedIndex >= 0 + ) { + const field = filteredFields[highlightedIndex] + const hasSubMenu = + field && + (field.type === "select" || + field.type === "multiselect") && + field.options?.length + + if (e.key === "ArrowRight" && hasSubMenu) { + e.preventDefault() + setOpenSubMenu(field.key || null) + setActiveMenu(field.key || "root") + } else if (e.key === "ArrowLeft") { + e.preventDefault() + if (openSubMenu) { + setOpenSubMenu(null) + setActiveMenu("root") + } + } + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault() + const field = filteredFields[highlightedIndex] + if (field.key) { + const hasSubMenu = + (field.type === "select" || + field.type === "multiselect") && + field.options?.length + if (!hasSubMenu) { + addFilter(field.key) + } else { + if (openSubMenu === field.key) { + setOpenSubMenu(null) + setActiveMenu("root") + } else { + setOpenSubMenu(field.key) + setActiveMenu(field.key) + } + } + } + } else if (e.key === "Escape") { + setAddFilterOpen(false) + } + e.stopPropagation() + }} + /> + {enableShortcut && shortcutLabel && ( + + {shortcutLabel} + + )} +
+ + + )} + +
+
setActiveMenu("root")} + > + + {(() => { + if (filteredFields.length === 0) { + return ( +
+ {mergedI18n.noFieldsFound} +
+ ) + } + + return filteredFields.map((field, index) => { + const isHighlighted = highlightedIndex === index + const itemId = `${rootId}-item-${index}` + const hasSubMenu = + (field.type === "select" || + field.type === "multiselect") && + field.options?.length + + if (hasSubMenu) { + const isMultiSelect = field.type === "multiselect" + const fieldKey = field.key as string + const sessionFilterId = sessionFilterIds[fieldKey] + const sessionFilter = sessionFilterId + ? filters.find((f) => f.id === sessionFilterId) + : null + const currentValues = sessionFilter?.values || [] + + return ( + { + if (open) { + setOpenSubMenu(fieldKey) + } else { + if (openSubMenu === fieldKey) { + setOpenSubMenu(null) + setActiveMenu("root") + } + } + }} + > + { + setHighlightedIndex(index) + setActiveMenu("root") + }} + className="data-popup-open:bg-accent data-popup-open:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground" + > + {field.icon} + {field.label} + + + { + if (field.searchable !== false) { + setActiveMenu(fieldKey) + } + }} + onBack={() => { + setOpenSubMenu(null) + setActiveMenu("root") + }} + onClose={() => setAddFilterOpen(false)} + onToggle={(value, isSelected) => { + if (isMultiSelect) { + const nextValues = isSelected + ? (currentValues.filter( + (v) => v !== value + ) as T[]) + : ([...currentValues, value] as T[]) + + if (sessionFilter) { + if (nextValues.length === 0) { + onChange( + filters.filter( + (f) => f.id !== sessionFilter.id + ) + ) + setSessionFilterIds((prev) => ({ + ...prev, + [fieldKey]: "", + })) + } else { + onChange( + filters.map((f) => + f.id === sessionFilter.id + ? { ...f, values: nextValues } + : f + ) + ) + } + } else { + const newFilter = createFilter( + fieldKey, + field.defaultOperator || "is_any_of", + nextValues + ) + onChange([...filters, newFilter]) + setSessionFilterIds((prev) => ({ + ...prev, + [fieldKey]: newFilter.id, + })) + } + } else { + const newFilter = createFilter( + fieldKey, + field.defaultOperator || "is", + [value] as T[] + ) + setLastAddedFilterId(newFilter.id) + onChange([...filters, newFilter]) + setAddFilterOpen(false) + } + }} + /> + + + ) + } + + return ( + setHighlightedIndex(index)} + onClick={() => field.key && addFilter(field.key)} + className="data-highlighted:bg-accent data-highlighted:text-accent-foreground" + > + {field.icon} + {field.label} + + ) + }) + })()} +
+
+
+
+
+ )} + + {filters.map((filter) => { + const field = fieldsMap[filter.field] + if (!field) return null + return ( + + + {field.icon && field.icon} + {field.label} + + + field={field} + operator={filter.operator} + values={filter.values} + onChange={(operator) => updateFilter(filter.id, { operator })} + /> + + field={field} + values={filter.values} + operator={filter.operator} + onChange={(values) => updateFilter(filter.id, { values })} + autoFocus={filter.id === lastAddedFilterId} + /> + removeFilter(filter.id)} /> + + ) + })} +
+
+ ) +} + +export const createFilter = ( + field: string, + operator?: string, + values: T[] = [] +): Filter => ({ + id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, + field, + operator: operator || "is", + values, +}) + +export const createFilterGroup = ( + id: string, + label: string, + fields: FilterFieldConfig[], + initialFilters: Filter[] = [] +): FilterGroup => ({ + id, + label, + filters: initialFilters, + fields, +}) \ No newline at end of file diff --git a/apps/web/src/components/reui/number-field.tsx b/apps/web/src/components/reui/number-field.tsx new file mode 100644 index 0000000..b499f9b --- /dev/null +++ b/apps/web/src/components/reui/number-field.tsx @@ -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) { + const generatedId = useId() + const fieldId = id ?? generatedId + const sizeValue = size ?? "default" + + return ( + + + + ) +} + +function NumberFieldGroup({ + className, + size: sizeProp, + ...props +}: NumberFieldPrimitive.Group.Props & + Partial>) { + const context = useContext(NumberFieldContext) + if (!context) { + throw new Error( + "NumberFieldGroup must be used within a NumberField component." + ) + } + const size = sizeProp ?? context.size + + return ( + + ) +} + +function NumberFieldDecrement({ + className, + size: sizeProp, + children, + ...props +}: NumberFieldPrimitive.Decrement.Props & + Partial> & { + 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 ( + + {children ?? ( + + )} + + ) +} + +function NumberFieldIncrement({ + className, + size: sizeProp, + children, + ...props +}: NumberFieldPrimitive.Increment.Props & + Partial> & { + 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 ( + + {children ?? ( + + )} + + ) +} + +function NumberFieldInput({ + className, + size: sizeProp, + ...props +}: NumberFieldPrimitive.Input.Props & + Partial>) { + const context = useContext(NumberFieldContext) + if (!context) { + throw new Error( + "NumberFieldInput must be used within a NumberField component." + ) + } + const size = sizeProp ?? context.size + + return ( + + ) +} + +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 ( + + + + + + + ) +} + +function CursorGrowIcon(props: React.ComponentProps<"svg">) { + return ( + + + + ) +} + +export { + NumberField, + NumberFieldScrubArea, + NumberFieldDecrement, + NumberFieldIncrement, + NumberFieldGroup, + NumberFieldInput, +} \ No newline at end of file diff --git a/apps/web/src/components/section-cards.tsx b/apps/web/src/components/section-cards.tsx new file mode 100644 index 0000000..04c55f6 --- /dev/null +++ b/apps/web/src/components/section-cards.tsx @@ -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, 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, string> = { + default: '', + warning: 'text-warning-foreground', + destructive: 'text-destructive', +} + +export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) { + return ( +
+ {items.map((item, idx) => { + const clickable = Boolean(item.onClick) + const content = ( + + {item.icon ? ( + + {item.icon} + + ) : null} +
+
+ {typeof item.label === 'string' ? ( + {item.label} + ) : ( + {item.label} + )} + {item.badge ? {item.badge} : null} +
+
+ + {item.value} + + {item.hint ? ( + typeof item.hint === 'string' ? ( + · {item.hint} + ) : ( + · {item.hint} + ) + ) : null} +
+
+
+ ) + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + item.onClick?.() + } + } + : undefined + } + > + {content} + + ) + })} +
+ ) +} diff --git a/apps/web/src/components/skeletons.tsx b/apps/web/src/components/skeletons.tsx new file mode 100644 index 0000000..6fc49dc --- /dev/null +++ b/apps/web/src/components/skeletons.tsx @@ -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 ( + ({ + icon: , + label: , + value: , + }))} + /> + ) +} + +export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) { + return ( + + +
+
+ {Array.from({ length: cols }).map((_, i) => ( + + ))} +
+ {Array.from({ length: rows }).map((_, r) => ( +
+ {Array.from({ length: cols }).map((_, c) => ( + + ))} +
+ ))} +
+
+
+ ) +} diff --git a/apps/web/src/components/status-badge.tsx b/apps/web/src/components/status-badge.tsx new file mode 100644 index 0000000..5a94e6a --- /dev/null +++ b/apps/web/src/components/status-badge.tsx @@ -0,0 +1,30 @@ +import type { ComponentProps } from 'react' + +import { Badge } from '@/components/reui/badge' + +type BadgeVariant = NonNullable['variant']> + +const STATUS_VARIANT: Record = { + 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 {label ?? status} +} diff --git a/apps/web/src/components/theme-provider.tsx b/apps/web/src/components/theme-provider.tsx new file mode 100644 index 0000000..a9f4378 --- /dev/null +++ b/apps/web/src/components/theme-provider.tsx @@ -0,0 +1,16 @@ +import { ThemeProvider as NextThemesProvider } from 'next-themes' +import type { ReactNode } from 'react' + +export function ThemeProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/web/src/components/truncated-text.tsx b/apps/web/src/components/truncated-text.tsx new file mode 100644 index 0000000..550c26d --- /dev/null +++ b/apps/web/src/components/truncated-text.tsx @@ -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 {children} + } + + return ( + + + }>{children} + {tip} + + + ) +} diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts new file mode 100644 index 0000000..9bfbc3e --- /dev/null +++ b/apps/web/src/lib/api-client.ts @@ -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): 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 { + 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(path: string, init?: RequestInit): Promise { + const res = await apiFetch(path, init) + return parseResponse(res) +} + +/** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */ +export async function apiMutate( + path: string, + method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', + body?: unknown, + opts?: { idempotent?: boolean }, +): Promise { + const headers: Record = {} + 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(res) +} + +async function parseResponse(res: Response): Promise { + 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 { + const pollMs = opts?.pollMs ?? 400 + const timeoutMs = opts?.timeoutMs ?? 120000 + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const j = await apiJSON(`/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(path: string, limit = 500): Promise { + 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 { + const items = await apiPageAll(`/v1/revisions/${revisionId}/prefixes`) + return items +} + +export async function fetchRevisionPrefixesResponse( + revisionId: string, + cursor?: string | null, + limit = 500, +): Promise { + const query = new URLSearchParams({ limit: String(limit) }) + if (cursor) query.set('cursor', cursor) + return apiJSON(`/v1/revisions/${revisionId}/prefixes?${query.toString()}`) +} + +export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) { + if (moduleType === 'DOMAINS') { + const entries = await apiPageAll( + `/v1/modules/${moduleId}/domain-entries`, + ) + return { domains: entries } + } + if (moduleType === 'AS_PREFIXES') { + const entries = await apiPageAll( + `/v1/modules/${moduleId}/as-entries`, + ) + return { asns: entries } + } + if (moduleType === 'CDN_CIDRS') { + const entries = await apiPageAll( + `/v1/modules/${moduleId}/cdn-sources`, + ) + return { cdnSources: entries } + } + if (moduleType === 'IP_RANGES') { + const entries = await apiPageAll( + `/v1/modules/${moduleId}/ip-range-entries`, + ) + return { ipRanges: entries } + } + return {} +} diff --git a/apps/web/src/lib/queryClient.ts b/apps/web/src/lib/queryClient.ts new file mode 100644 index 0000000..d624f31 --- /dev/null +++ b/apps/web/src/lib/queryClient.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}) diff --git a/apps/web/src/lib/router.ts b/apps/web/src/lib/router.ts new file mode 100644 index 0000000..f4ebc66 --- /dev/null +++ b/apps/web/src/lib/router.ts @@ -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 + } +} + +export function createRouter(opts?: { context?: { queryClient: QueryClient } }) { + return tanstackCreateRouter({ + routeTree, + context: opts?.context ?? { queryClient }, + defaultPreload: 'intent', + scrollRestoration: true, + }) +} + +export { rootRouteId } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..8eb77f8 --- /dev/null +++ b/apps/web/src/main.tsx @@ -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( + + + + + + + + , +) diff --git a/apps/web/src/queries/api-keys.ts b/apps/web/src/queries/api-keys.ts new file mode 100644 index 0000000..fffab98 --- /dev/null +++ b/apps/web/src/queries/api-keys.ts @@ -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({ + queryKey: apiKeysKeys.list(), + queryFn: async () => { + const page = await apiJSON('/v1/api-keys?limit=500') + return page.items ?? [] + }, + staleTime: 60_000, + }) +} diff --git a/apps/web/src/queries/auth.ts b/apps/web/src/queries/auth.ts new file mode 100644 index 0000000..5232c07 --- /dev/null +++ b/apps/web/src/queries/auth.ts @@ -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({ + queryKey: authKeys.session(), + queryFn: () => apiJSON('/v1/auth/session'), + retry: false, + staleTime: 30_000, + }) +} diff --git a/apps/web/src/queries/directories.ts b/apps/web/src/queries/directories.ts new file mode 100644 index 0000000..925f218 --- /dev/null +++ b/apps/web/src/queries/directories.ts @@ -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({ + queryKey: directoriesKeys.communities(), + queryFn: () => apiJSON('/v1/communities?limit=200'), + staleTime: 30_000, + }) +} + +export function directoriesDohQueryOptions() { + return queryOptions({ + queryKey: directoriesKeys.doh(), + queryFn: () => apiJSON('/v1/doh-profiles?limit=200'), + staleTime: 30_000, + }) +} diff --git a/apps/web/src/queries/modules.ts b/apps/web/src/queries/modules.ts new file mode 100644 index 0000000..828edf4 --- /dev/null +++ b/apps/web/src/queries/modules.ts @@ -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({ + queryKey: modulesKeys.list(), + queryFn: () => apiJSON('/v1/modules?limit=200'), + }) +} + +export function moduleDetailQueryOptions(id: string) { + return queryOptions({ + queryKey: modulesKeys.detail(id), + queryFn: () => apiJSON(`/v1/modules/${id}`), + }) +} + +export type ModuleEntriesPage = Page> + +export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) { + const pathByType: Record = { + 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 = { + DOMAINS: modulesKeys.domainEntries(id), + AS_PREFIXES: modulesKeys.asEntries(id), + CDN_CIDRS: modulesKeys.cdnSources(id), + IP_RANGES: modulesKeys.ipRangeEntries(id), + } + return queryOptions({ + queryKey: keyByType[type], + queryFn: () => apiJSON(path), + }) +} diff --git a/apps/web/src/queries/monitoring.ts b/apps/web/src/queries/monitoring.ts new file mode 100644 index 0000000..733cccf --- /dev/null +++ b/apps/web/src/queries/monitoring.ts @@ -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 +} + +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 { + 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({ + queryKey: monitoringKeys.health(), + queryFn: fetchHealth, + staleTime: 15_000, + }) +} + +export function monitoringReadyQueryOptions() { + return queryOptions({ + queryKey: monitoringKeys.ready(), + queryFn: () => apiJSON('/v1/ready'), + staleTime: 15_000, + }) +} + +export function monitoringVersionQueryOptions() { + return queryOptions({ + queryKey: monitoringKeys.version(), + queryFn: () => apiJSON('/v1/version'), + staleTime: 60_000, + }) +} + +export async function fetchLog(endpoint: string): Promise { + const res = await apiFetch(endpoint, { method: 'GET' }) + return await res.text() +} + +export async function clearLog(endpoint: string): Promise { + await apiMutate(endpoint, 'DELETE', {}) +} diff --git a/apps/web/src/queries/network.ts b/apps/web/src/queries/network.ts new file mode 100644 index 0000000..19635f4 --- /dev/null +++ b/apps/web/src/queries/network.ts @@ -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({ + queryKey: networkKeys.peers(), + queryFn: () => apiJSON('/v1/peers?limit=200&live=1'), + staleTime: 15_000, + }) +} + +export function networkSpeakersQueryOptions() { + return queryOptions({ + queryKey: networkKeys.speakers(), + queryFn: () => apiJSON('/v1/speakers?limit=200&live=1'), + staleTime: 15_000, + }) +} + +export function networkBirdQueryOptions() { + return queryOptions({ + queryKey: networkKeys.bird(), + queryFn: () => apiJSON('/v1/bird/status'), + staleTime: 15_000, + }) +} diff --git a/apps/web/src/queries/operations.ts b/apps/web/src/queries/operations.ts new file mode 100644 index 0000000..8b8a1a4 --- /dev/null +++ b/apps/web/src/queries/operations.ts @@ -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({ + queryKey: operationsKeys.revisions(), + queryFn: () => apiJSON('/v1/revisions?limit=100'), + staleTime: 30_000, + }) +} + +export function operationsJobsQueryOptions(params?: { status?: string; kind?: string }) { + return queryOptions({ + 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(`/v1/jobs?${sp.toString()}`) + }, + staleTime: 10_000, + }) +} + +export function operationsDiffQueryOptions(a: string, b: string) { + return queryOptions({ + queryKey: operationsKeys.diff(a, b), + queryFn: () => apiJSON(`/v1/revisions/${a}/diff/${b}`), + enabled: Boolean(a) && Boolean(b), + }) +} diff --git a/apps/web/src/queries/overview.ts b/apps/web/src/queries/overview.ts new file mode 100644 index 0000000..c2bb85a --- /dev/null +++ b/apps/web/src/queries/overview.ts @@ -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({ + queryKey: overviewKeys.modules(), + queryFn: () => apiJSON('/v1/modules?limit=200'), + staleTime: 60_000, + }) +} + +export function overviewPeersQueryOptions() { + return queryOptions({ + queryKey: overviewKeys.peers(), + queryFn: () => apiJSON('/v1/peers?limit=200&live=1'), + staleTime: 30_000, + }) +} + +export function overviewSpeakersQueryOptions() { + return queryOptions({ + queryKey: overviewKeys.speakers(), + queryFn: () => apiJSON('/v1/speakers?limit=200&live=1'), + staleTime: 30_000, + }) +} + +export function overviewRevisionsQueryOptions() { + return queryOptions({ + queryKey: overviewKeys.revisions(), + queryFn: () => apiJSON('/v1/revisions?limit=10'), + staleTime: 60_000, + }) +} + +export function overviewJobsQueryOptions() { + return queryOptions({ + queryKey: overviewKeys.jobs(), + queryFn: () => apiJSON('/v1/jobs?limit=10'), + staleTime: 15_000, + }) +} + +export function overviewHealthQueryOptions() { + return queryOptions({ + 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 { + return new Map(modules.map((m) => [m.id, m.name])) +} + +export function recentRevisions(revisions: RevisionRow[], n = 10): RevisionRow[] { + return revisions.slice(0, n) +} diff --git a/apps/web/src/queries/settings.ts b/apps/web/src/queries/settings.ts new file mode 100644 index 0000000..52f1dd1 --- /dev/null +++ b/apps/web/src/queries/settings.ts @@ -0,0 +1,128 @@ +import { queryOptions } from '@tanstack/react-query' +import { apiJSON } from '@/lib/api-client' + +export type AppSettings = Record + +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([ + 'bird_local_asn', + 'revision_retention_minutes', + 'runtime_logs_max_file_mb', +]) + +export const BOOLEAN_SETTING_KEYS = new Set(['runtime_logs_auto_enabled']) + +export const settingsKeys = { + all: ['settings'] as const, +} + +export function settingsQueryOptions() { + return queryOptions({ + queryKey: settingsKeys.all, + queryFn: () => apiJSON('/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> + revision: Partial> + runtimeLogs: Partial> + additional: { id: number; key: string; value: string }[] +} + +export function partitionSettings(settings: AppSettings): PartitionedSettings { + const bird: Partial> = {} + const revision: Partial> = {} + const runtimeLogs: Partial> = {} + 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, +): Record { + const payload: Record = {} + 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 +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx new file mode 100644 index 0000000..4dbb9ef --- /dev/null +++ b/apps/web/src/routes/__root.tsx @@ -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()({ + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + ) +} diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx new file mode 100644 index 0000000..96ba1d2 --- /dev/null +++ b/apps/web/src/routes/_auth.tsx @@ -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 +} diff --git a/apps/web/src/routes/_auth/access.tsx b/apps/web/src/routes/_auth/access.tsx new file mode 100644 index 0000000..0c716e8 --- /dev/null +++ b/apps/web/src/routes/_auth/access.tsx @@ -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 ( +
+ + + {session ? ( + + + Текущая сессия + Tenant и роль ключа, с которым открыта панель. + + +
+

Tenant

+

{session.tenant_id}

+
+
+

Роль

+

{session.role}

+
+
+
+ ) : null} + + {isOperator ? ( + keysQuery.refetch()} + /> + ) : session ? ( + + + Управление API-ключами доступно только роли operator. Текущая роль:{' '} + {session.role}. + + + ) : null} +
+ ) +} + +function ApiKeysCard({ + items, + isLoading, + isError, + error, + onRetry, +}: { + items: import('@/types/api').ApiKey[] + isLoading: boolean + isError: boolean + error: unknown + onRetry: () => void +}) { + const [revealedToken, setRevealedToken] = useState(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(`/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 ( + + +
+ API-ключи + + Управление ключами tenant. Полный токен показывается только при создании и ротации. + +
+ +
+ + + {(data) => ( +
+ + + Имя + Роль + Префикс + Статус + + + + + {data.map((k) => ( + + {k.name} + {k.role} + {k.prefix}… + + {k.revoked_at ? ( + отозван + ) : ( + активен + )} + + +
+ + + + } + title="Ротировать ключ?" + description="Старый токен перестанет работать сразу." + confirmLabel="Ротировать" + onConfirm={() => rotate.mutate(k.id)} + /> + + + + } + title="Отозвать API-ключ?" + description={`${k.name} (${k.prefix}…)`} + confirmLabel="Отозвать" + destructive + onConfirm={() => revoke.mutate(k.id)} + /> +
+
+
+ ))} +
+
+ )} + + + + {revealedToken ? ( +
+
Новый токен (сохраните сейчас):
+
+ {revealedToken} +
+
+ + +
+
+ ) : null} + + ) +} diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx new file mode 100644 index 0000000..a101d04 --- /dev/null +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -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(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: , + hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'), + onClick: () => window.location.assign('/modules'), + }, + { + label: 'Пиры', + value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`, + icon: , + 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: , + 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: , + hint: countBadge(revisions.length, revisionsHasMore, 'configs'), + onClick: () => window.location.assign('/operations'), + }, + { + label: 'Активных задач', + value: initialLoading ? '—' : String(running), + icon: , + hint: 'queued и running', + onClick: () => window.location.assign('/operations?tab=jobs'), + }, + ] + + return ( +
+ + + Обновить + + } + /> + + + + Панель управления EvoBGP + + Сводка по модулям, сети и фоновым задачам. BGP и ноды — «Сеть», префиксы — «Модули», + деплой — «Операции», здоровье API — «Мониторинг». + + + + + + {initialLoading ? : } + +
+ + + +
+ + + + Быстрые действия + Частые переходы к настройке и деплою + + + + + + + + + + +
+ ) +} + +function HealthAlert({ + loading, + ok, + loadError, +}: { + loading: boolean + ok: boolean | undefined + loadError: string | null +}) { + if (loading) { + return ( + + + Проверка API… + + Запрос к /v1/health + + + ) + } + if (ok && !loadError) { + return ( + + + API работает + Сервер отвечает на запросы health-check. + + ) + } + if (ok && loadError) { + return ( + + + API доступен, данные не загружены + {loadError}. Проверьте Bearer-токен в «Настройках». + + ) + } + return ( + + + API недоступен + + Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080) и в dev работает + прокси Vite. + + + ) +} + +function RecentJobsCard({ + jobs, + nameById, + loading, +}: { + jobs: import('@/types/api').JobRow[] + nameById: Map + loading: boolean +}) { + return ( + + + Недавние задачи + Последние фоновые операции + + + {loading && jobs.length === 0 ? ( + + ) : jobs.length === 0 ? ( +

Нет задач

+ ) : ( +
    + {jobs.slice(0, 8).map((j) => ( +
  • + + {j.kind} + + {j.meta?.module_id ? nameById.get(String(j.meta.module_id)) ?? '' : ''} + + + + {j.status} + +
  • + ))} +
+ )} +
+
+ ) +} + +function RecentRevisionsCard({ + revisions, + loading, +}: { + revisions: import('@/types/api').RevisionRow[] + loading: boolean +}) { + return ( + + + Последние ревизии + История конфигураций + + + {loading && revisions.length === 0 ? ( + + ) : revisions.length === 0 ? ( +

Нет ревизий

+ ) : ( +
    + {revisions.slice(0, 8).map((r) => ( +
  • + {r.id.slice(0, 10)}… + + {new Date(r.created_at).toLocaleString('ru-RU')} + +
  • + ))} +
+ )} +
+
+ ) +} + +function NetworkStatusCard({ + peers, + speakers, + loading, +}: { + peers: import('@/types/api').PeerRow[] + speakers: import('@/types/api').SpeakerRow[] + loading: boolean +}) { + const m = aggregateNetworkMetrics(peers, speakers) + return ( + + + Состояние сети + BGP-сессии и спикеры + + + {loading && peers.length === 0 && speakers.length === 0 ? ( + + ) : ( +
+ + + {m.peersMismatch > 0 ? ( + + ) : null} +
+ )} +
+
+ ) +} + +function Row({ + label, + value, + variant = 'default', +}: { + label: string + value: string + variant?: 'default' | 'warning' +}) { + return ( +
+ {label} + + {value} + +
+ ) +} diff --git a/apps/web/src/routes/_auth/directories.tsx b/apps/web/src/routes/_auth/directories.tsx new file mode 100644 index 0000000..49bb5fa --- /dev/null +++ b/apps/web/src/routes/_auth/directories.tsx @@ -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: , + hint: 'теги префиксов в AS- и CDN-модулях', + }, + { + label: 'DoH профили', + value: dohProfiles.length, + icon: , + hint: 'резолвинг доменных модулей', + }, + { + label: 'Справочники', + value: 'Общие', + icon: , + hint: 'используются всеми модулями tenant', + }, + ] + + return ( +
+ { + void communitiesQ.refetch() + void dohQ.refetch() + }} + disabled={loading} + > + + Обновить + + } + /> + + + + О справочниках + + Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили — в + доменных модулях для DNS-over-HTTPS резолвинга. + + + + {loading ? : } + + + + Сообщества BGP + DoH профили + + + + + + Сообщества BGP + Теги для префиксов в фильтрах BIRD + + + } + onRetry={() => communitiesQ.refetch()} + > + {(items) => ( + + + + Название + Значение + Тип + + + + {items.map((c) => ( + + {c.title} + {c.community} + + community + + + ))} + +
+ )} +
+
+
+
+ + + + + DoH профили + Резолверы DNS-over-HTTPS для доменных модулей + + + } + onRetry={() => dohQ.refetch()} + > + {(items) => ( + + + + Название + URL + По умолчанию + + + + {items.map((p) => ( + + {p.name ?? p.url} + {p.url} + + + + + ))} + +
+ )} +
+
+
+
+
+
+ ) +} diff --git a/apps/web/src/routes/_auth/modules/$moduleId.tsx b/apps/web/src/routes/_auth/modules/$moduleId.tsx new file mode 100644 index 0000000..1af2a96 --- /dev/null +++ b/apps/web/src/routes/_auth/modules/$moduleId.tsx @@ -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 ( +
+ + + + + } + /> + + } + onRetry={() => detail.refetch()} + > + {(m) => ( + <> +
+ + + Параметры + + + {m.id}} /> + {m.type}} /> + {m.priority}} /> + + ) : ( + + ) + } + /> + + {m.cron_expr} : '—'} + /> + + + + + + + Маршрутные списки + Источник префиксов для модуля + + + } + onRetry={() => entriesQuery.refetch()} + > + {(items) => } + + + +
+ + )} +
+
+ ) +} + +function Field({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ) +} + +function EntriesTable({ + items, + moduleType, +}: { + items: Record[] + 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 ( + + + + {primary} + {secondary ? {secondary} : null} + + + + {items.map((item, idx) => { + const id = String(item.id ?? idx) + const primaryVal = String(item[primary] ?? '—') + return ( + + {primaryVal} + {secondary ? ( + + {String(item[secondary] ?? '—')} + + ) : null} + + ) + })} + +
+ ) +} + +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 diff --git a/apps/web/src/routes/_auth/modules/index.tsx b/apps/web/src/routes/_auth/modules/index.tsx new file mode 100644 index 0000000..4f21b95 --- /dev/null +++ b/apps/web/src/routes/_auth/modules/index.tsx @@ -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 ( +
+ + + + } + /> + + + + Все модули + + + + } + onRetry={() => query.refetch()} + > + {(items) => } + + + +
+ ) +} + +function ModulesTable({ items }: { items: ModuleRow[] }) { + return ( + + + + Название + Тип + Приоритет + Состояние + Обновлено + + + + {items.map((m) => ( + (window.location.href = `/modules/${m.id}`)} + > + +
+ + {m.name} +
+
+ + {m.type} + + {m.priority} + + {m.enabled ? ( + включён + ) : ( + выключен + )} + + + {m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'} + +
+ ))} +
+
+ ) +} diff --git a/apps/web/src/routes/_auth/modules/new.tsx b/apps/web/src/routes/_auth/modules/new.tsx new file mode 100644 index 0000000..1c9e1fd --- /dev/null +++ b/apps/web/src/routes/_auth/modules/new.tsx @@ -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 ( +
+ + + +

+ Форма создания модуля будет добавлена позже. Сейчас модули можно создать через API: +

+
+{`POST /v1/modules
+{ "type": "DOMAINS", "name": "Мой список" }`}
+          
+ +
+
+
+ ) +} diff --git a/apps/web/src/routes/_auth/monitoring.tsx b/apps/web/src/routes/_auth/monitoring.tsx new file mode 100644 index 0000000..8d94b19 --- /dev/null +++ b/apps/web/src/routes/_auth/monitoring.tsx @@ -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) => ({ + 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: , + hint: overallHint({ health: healthQ.data, jobsFailed: failed }), + }, + { + label: 'BGP сессии', + value: birdQ.data + ? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}` + : '—', + icon: , + hint: birdQ.data?.birdc_configured + ? 'Established / total на API-хосте' + : 'birdc не настроен', + }, + { + label: 'Задачи', + value: running, + icon: , + hint: `активных из ${jobs.length}`, + variant: failed > 0 ? 'warning' : 'default', + }, + { + label: 'Версия', + value: versionText, + icon: , + 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 ( +
+ + + Обновить + + } + /> + + + + Система + PostgreSQL + Файловые логи + + + + {refreshing ? : } + +
+ + + Доступность и готовность + GET /v1/health · GET /v1/ready + + + } + onRetry={() => readyQ.refetch()} + > + {(ready) => } + + + + + + + + + BGP на API-хосте + + GET /v1/bird/status + + + } + onRetry={() => birdQ.refetch()} + > + {(bird) => } + + + +
+ +
+ + + + + Задачи + + Последние 100 задач · GET /v1/jobs + + +
+ + 0 ? 'text-warning' : 'text-success'} + /> + +
+ + {failedJobs.length > 0 ? ( +
+

Последние ошибки

+
    + {failedJobs.map((job) => ( +
  • +
    +

    {job.kind}

    + {job.status} +
    + {job.error ? ( +

    + {job.error.slice(0, 120)} + {job.error.length > 120 ? '…' : ''} +

    + ) : null} +
  • + ))} +
+
+ ) : ( +

+ Критичных сбоев в последних 100 задачах нет. +

+ )} +
+
+ + + + + + Что проверять при деградации + + Короткая шпаргалка для triage + + + + + API недоступен + + Если /v1/health возвращает ошибку — проверьте процесс + API и его логи. + + + + + Readiness не «Готов» + + Сначала postgres, затем{' '} + store и jobs в checks. + + + + + Низкий ratio BGP + + Проверьте /v1/bird/status, затем состояние пиров в Сети. + + + + + Ошибки задач + + Откройте Операции и проверьте последние неуспешные jobs. + + + + +
+
+ + + + + PostgreSQL + Статус соединения и пул + + + + + Статус готовности + + PostgreSQL-соединение отображается в readiness-проверке на вкладке «Система» (check{' '} + postgres). + + + + + + + + + + Файловые логи + Логи API и pipeline + + + + + Логи на сервере + + Файловые логи настраиваются переменной EVOBGP_LOG_* и + управляются tenant-settings на странице «Настройки BIRD». + + + + + +
+
+ ) +} + +function Metric({ label, value, valueClass }: { label: string; value: number | string; valueClass?: string }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +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 = { + postgres: Database, + store: HardDrive, + jobs: ListTodo, + } + return ( + + + + Проверка + Статус + + + + + +
+ +
+

Liveness

+

/v1/health

+
+
+
+ + + {health?.ok ? 'OK' : 'Ошибка'} + + +
+ + +
+ +
+

Readiness

+

/v1/ready

+
+
+
+ + + {ready.status ?? '—'} + + +
+ {Object.entries(checks).map(([key, value]) => { + const ok = typeof value === 'boolean' ? value : value?.ok !== false + const Icon = iconByKey[key] ?? ListTodo + return ( + + +
+ +
+

{key}

+
+
+
+ + {ok ? 'OK' : 'Ошибка'} + +
+ ) + })} +
+
+ ) +} + +function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) { + if (!bird.birdc_configured) { + return ( +

+ {bird.message ?? 'birdc не настроен на API-хосте (EVOBGP_BIRDC_SOCKET).'} +

+ ) + } + const ratio = + bird.bgp_sessions_total > 0 + ? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100) + : null + return ( +
+
+ Established / total + + {bird.bgp_established} / {bird.bgp_sessions_total} + {ratio !== null ? ({ratio}%) : null} + +
+ {ratio !== null ? ( +
+
= 100 ? 'bg-success' : ratio >= 50 ? 'bg-warning' : 'bg-destructive' + }`} + style={{ width: `${ratio}%` }} + /> +
+ ) : null} + {bird.error ?

{bird.error}

: null} +
+ ) +} diff --git a/apps/web/src/routes/_auth/network.tsx b/apps/web/src/routes/_auth/network.tsx new file mode 100644 index 0000000..7fac04c --- /dev/null +++ b/apps/web/src/routes/_auth/network.tsx @@ -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) => ({ + 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 ( +
+ + + Обновить + + } + /> + + + + О сетевой конфигурации + + Вкладка «Обзор» — live-статус agent и BGP на CP и репликах. Apply и ревизии — на странице «Операции». + + + + + + Обзор + Пиры ({peers.length}) + Спикеры ({speakers.length}) + Control plane + + + +
+ + + Сводка сети + + + + + + + {net.peersMismatch > 0 ? ( + + ) : null} + + + + + BIRD (control plane) + Статус birdc на хосте API + + + } + onRetry={() => birdQ.refetch()} + > + {(bird) => } + + + +
+
+ + + + + Пиры + + + } + onRetry={() => peersQ.refetch()} + > + {(items) => } + + + + + + + + + Спикеры + + + } + onRetry={() => speakersQ.refetch()} + > + {(items) => } + + + + + + + + + Настройки Control Plane (BIRD) + Конфигурация tenant-level — в разделе «Настройки BIRD» + + + См. раздел «Настройки BIRD». + + + +
+
+ ) +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) { + return ( +
+ + + {bird.message ?

{bird.message}

: null} + {bird.error ?

{bird.error}

: null} +
+ ) +} + +function PeersTable({ items }: { items: import('@/types/api').PeerRow[] }) { + return ( + + + + Имя + Neighbor + ASN + Состояние + + + + {items.map((p) => ( + + {p.name ?? p.neighbor} + {p.neighbor} + {p.remote_asn ?? '—'} + + + {p.session_mismatch ? ( + + mismatch + + ) : null} + + + ))} + +
+ ) +} + +function SpeakersTable({ items }: { items: import('@/types/api').SpeakerRow[] }) { + return ( + + + + Endpoint + Роль + Agent + BGP + + + + {items.map((s) => ( + + {s.endpoint} + + {s.role} + + + {s.live?.agent_ok === true ? ( + + ) : s.live?.agent_ok === false ? ( + + ) : ( + + )} + + + {s.live ? ( + + {s.live.bgp_established ?? 0} / {s.live.bgp_sessions_total ?? 0} + + ) : ( + '—' + )} + + + ))} + +
+ ) +} diff --git a/apps/web/src/routes/_auth/operations.tsx b/apps/web/src/routes/_auth/operations.tsx new file mode 100644 index 0000000..c49730b --- /dev/null +++ b/apps/web/src/routes/_auth/operations.tsx @@ -0,0 +1,444 @@ +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 } 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, +} 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) => ({ + 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: , + hint: 'история конфигов', + }, + { + label: 'Активных задач', + value: running, + icon: , + hint: 'queued и running', + }, + { + label: 'Задач с ошибкой', + value: failed, + icon: , + 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 ( +
+ + + Обновить + + } + /> + + + + Три раздела на одной странице + + Ревизии — история конфигов и откат; Сравнение — diff + префиксов; Задачи — ingest, apply, rollback. + + + +
+ + Apply + + } + title="Применить конфигурацию на всех спикерах?" + description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator." + confirmLabel="Применить" + onConfirm={() => applyMutation.mutate()} + /> + + BIRD reload + + } + title="Перезагрузить BIRD?" + description="BIRD перезагрузит конфигурацию. Требуется роль operator." + confirmLabel="Перезагрузить" + onConfirm={() => birdReloadMutation.mutate()} + /> +
+ + {revisionsQ.isLoading ? : } + + + + Ревизии ({revisions.length}) + Сравнение + Задачи ({jobs.length}) + + + + + + История ревизий + + + revisionsQ.refetch()} + > + {(items) => } + + + + + + + + + + + + + Задачи + + + jobsQ.refetch()} + > + {(items) => } + + + + + +
+ ) +} + +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 ( + + + + ID + Создана + Префиксов + + + + + {items.map((r) => ( + + {r.id.slice(0, 12)}… + + {new Date(r.created_at).toLocaleString('ru-RU')} + + + {r.materialized_prefix_count} + + + + + + } + title={`Откатиться к ревизии ${r.id.slice(0, 8)}…?`} + description="Будет создана новая ревизия на основе выбранной. Требуется роль operator." + confirmLabel="Откатить" + destructive + onConfirm={() => rollbackMutation.mutate(r.id)} + /> + + + ))} + +
+ ) +} + +function JobsTable({ + items, + nameById, + qc, +}: { + items: JobRow[] + nameById: Map + 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 ( + + + + Вид + Статус + Создана + Завершена + + + + + {items.map((j) => ( + + +
+ {j.kind} + {j.meta?.module_id ? ( + + {nameById.get(String(j.meta.module_id)) ?? String(j.meta.module_id)} + + ) : null} +
+
+ + + + + {j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'} + + + {j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'} + + + {j.status === 'running' || j.status === 'queued' ? ( + + ) : null} + +
+ ))} +
+
+ ) +} + +function StatusBadgeColored({ status }: { status: string }) { + const cls = + status === 'succeeded' + ? 'text-success' + : status === 'failed' || status === 'cancelled' + ? 'text-destructive' + : 'text-info' + return {status} +} + +function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) { + const [a, setA] = useState('') + const [b, setB] = useState('') + const diffQ = useQuery(operationsDiffQueryOptions(a, b)) + + return ( + + + Сравнение ревизий + + +
+
+ Ревизия A + +
+
+ Ревизия B + +
+ +
+ + diffQ.refetch()} + > + {(diff) => } + +
+
+ ) +} + +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 ( +
+
+

Добавлено: {added.length}

+
+          {added.join('\n')}
+        
+
+
+

Удалено: {removed.length}

+
+          {removed.join('\n')}
+        
+
+
+ ) +} diff --git a/apps/web/src/routes/_auth/schedule.tsx b/apps/web/src/routes/_auth/schedule.tsx new file mode 100644 index 0000000..78f357f --- /dev/null +++ b/apps/web/src/routes/_auth/schedule.tsx @@ -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>({}) + + 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: , hint: 'в выборке' }, + { label: 'В работе', value: running, icon: , hint: 'queued и running' }, + { + label: 'С ошибкой', + value: failed, + icon: , + 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 ( +
+ { + void modulesQ.refetch() + void jobsQ.refetch() + }} + disabled={loading} + > + + Обновить + + } + /> + + + + Как работает расписание + + Планировщик использует refresh_interval_sec и опционально{' '} + cron_expr. Ручной запуск —{' '} + POST /v1/modules/{id}/refresh. + + + + {loading ? : } + + + + Модули + Расписание обновления и ручной запуск ingest + + + modulesQ.refetch()} + > + {(items) => ( + + + + Модуль + Тип + Расписание + Обновлено + Статус + + + + + {items.map((m) => ( + + {m.name} + + {m.type} + + + {m.cron_expr ?? (m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—')} + + + {m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'} + + + {m.enabled ? ( + Вкл + ) : ( + Выкл + )} + + + refreshMutation.mutate(m.id)} + > + + Обновить + + + + ))} + +
+ )} +
+
+
+ + + + Задачи + Последние задачи из API + + + + + +
+ ) +} + +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 ( + + + Все ({jobs.length}) + Обновление ({refresh.length}) + С ошибкой ({failed.length}) + + + + + + + + + + + + ) +} + +function JobsTable({ items, loading }: { items: JobRow[]; loading: boolean }) { + if (loading) return
Загрузка…
+ if (items.length === 0) + return
Нет задач
+ return ( + + + + Вид + Статус + Создана + Завершена + Ошибка + + + + {items.map((j) => ( + + {j.kind} + + + {j.status} + + + + {j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'} + + + {j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'} + + + {j.error ?? ''} + + + ))} + +
+ ) +} diff --git a/apps/web/src/routes/_auth/settings.tsx b/apps/web/src/routes/_auth/settings.tsx new file mode 100644 index 0000000..85096ee --- /dev/null +++ b/apps/web/src/routes/_auth/settings.tsx @@ -0,0 +1,99 @@ +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 } 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, +}) + +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 ( +
+ + + + + Подключение к API + + Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant — в + разделе «Права доступа». + + + +
+ + setTokenValue(e.target.value)} + placeholder="Bearer …" + /> +
+ + + Сохранить токен + + {session ? ( +

+ Активная сессия: tenant {session.tenant_id}, роль{' '} + {session.role}. +

+ ) : null} +
+
+ + + + Оформление + + Тема интерфейса. Быстрый переключатель также доступен в боковой панели. + + + + + + + +
+ ) +} diff --git a/apps/web/src/routes/_auth/tenant-settings.tsx b/apps/web/src/routes/_auth/tenant-settings.tsx new file mode 100644 index 0000000..312f6a1 --- /dev/null +++ b/apps/web/src/routes/_auth/tenant-settings.tsx @@ -0,0 +1,379 @@ +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, +} 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) => ({ + tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional' + ? search.tab + : 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional', + }), +}) + +const BIRD_LABELS: Record = { + 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>({}) + const [revisionForm, setRevisionForm] = useState>({}) + const [runtimeLogsForm, setRuntimeLogsForm] = useState>({}) + + 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) => + 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 ( +
+ + + + + Operator-only + + Изменение значений через PATCH /v1/settings требует роли + operator. При отсутствии прав API вернёт 403. + + + + + + BIRD + Ревизии + Файловые логи + Дополнительно + + + + + + BIRD control plane + + Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '} + PATCH /v1/settings (роль operator). + + + + + + Подстановка в конфиг + + Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса). + Пиры и спикеры настраиваются в разделе «Сеть». + + + } + onRetry={() => settingsQ.refetch()} + > + {() => ( +
+ {BIRD_SETTING_KEYS.map((key) => ( +
+ + setBirdForm((s) => ({ ...s, [key]: e.target.value }))} + placeholder={BIRD_LABELS[key]} + /> +

{key}

+
+ ))} +
+ + + Сохранить + +
+
+ )} +
+
+
+
+ + + + + Ревизии + Время хранения ревизий в БД + + + } + onRetry={() => settingsQ.refetch()} + > + {() => ( +
+ + + setRevisionForm((s) => ({ + ...s, + revision_retention_minutes: e.target.value, + })) + } + /> +

+ revision_retention_minutes +

+ + + Сохранить + +
+ )} +
+
+
+
+ + + + + Файловые логи + Автоматическая очистка логов + + + } + onRetry={() => settingsQ.refetch()} + > + {() => ( +
+
+ + +

+ runtime_logs_auto_enabled +

+
+
+ + + setRuntimeLogsForm((s) => ({ + ...s, + runtime_logs_max_file_mb: e.target.value, + })) + } + /> +

+ runtime_logs_max_file_mb +

+
+
+ + + setRuntimeLogsForm((s) => ({ + ...s, + runtime_logs_auto_schedule: e.target.value, + })) + } + /> +

+ runtime_logs_auto_schedule +

+
+
+ + +

+ runtime_logs_auto_mode +

+
+
+ + + Сохранить + +
+
+ )} +
+
+
+
+ + + + + Дополнительные параметры + + Параметры вне стандартных групп (readonly — изменяются только через API) + + + + } + onRetry={() => settingsQ.refetch()} + > + {(items) => ( + + + + Ключ + Значение + + + + {items.map((row) => ( + + {row.key} + {row.value} + + ))} + +
+ )} +
+
+
+
+
+
+ ) +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx new file mode 100644 index 0000000..8c2ddcb --- /dev/null +++ b/apps/web/src/routes/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + beforeLoad: () => { + throw redirect({ to: '/dashboard' }) + }, +}) diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts new file mode 100644 index 0000000..28a1d9a --- /dev/null +++ b/apps/web/src/types/api.ts @@ -0,0 +1,345 @@ +// ---- Pagination ---- +export type Page = { + 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 + +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> + +// ---- 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 + +// ---- 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 & { refresh_interval_sec?: number | null } +export type CdnSourcesResponse = Page + +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 + +// ---- 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 + +// ---- 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 +export type DohProfilesResponse = Page + +// ---- Communities ---- +export type BgpCommunity = { + id: string + community: string + title: string +} +export type BgpCommunityCreate = { + community: string + title?: string +} +export type BgpCommunityPatch = Partial +export type CommunitiesResponse = Page + +// ---- 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 & { 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 + +// ---- 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 + agent_secret?: string + live?: SpeakerLiveStatus +} +export type SpeakersResponse = Page +export type BgpSpeakerCreate = { + endpoint: string + role?: string + meta_json?: string +} +export type BgpSpeakerPatch = Partial + +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 + +export type RevisionPrefix = { + /** Обычно CIDR; для AS-модуля в снимке ревизии — строка вида `as:<номер_asn>`. */ + prefix: string + /** Источник материализации (например, domain:, as:, cdn:, ip_range). */ + source?: string + community_id?: string | null +} +export type RevisionPrefixesResponse = Page + +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 +} +export type JobsResponse = Page + +// ---- Settings ---- +export type AppSettings = Record + +// ---- 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 + +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 +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..08bfd3e --- /dev/null +++ b/apps/web/tsconfig.json @@ -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"] +} diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo new file mode 100644 index 0000000..ba840f3 --- /dev/null +++ b/apps/web/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..da9084c --- /dev/null +++ b/apps/web/vite.config.ts @@ -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, + }, +}) diff --git a/deploy/docker/evobgp-web/Dockerfile b/deploy/docker/evobgp-web/Dockerfile index 8988ccf..84f6f8d 100644 --- a/deploy/docker/evobgp-web/Dockerfile +++ b/deploy/docker/evobgp-web/Dockerfile @@ -1,19 +1,26 @@ # syntax=docker/dockerfile:1.7 -# Статическая панель EvoBGP (SvelteKit) + nginx. +# React + Vite статическая панель EvoBGP + nginx. # Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build). +# Сборка ведётся из корня репозитория (context = "../.." в docker-bake.hcl). + FROM public.ecr.aws/docker/library/node:22-alpine AS deps -WORKDIR /web -COPY web/package.json web/package-lock.json ./ -RUN --mount=type=cache,target=/root/.npm,sharing=locked \ - npm ci +WORKDIR /repo +RUN corepack enable +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ +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 -COPY web/ ./ -RUN npm run build +COPY tsconfig.base.json ./ +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 ARG EVOBGP_UPSTREAM=evobgp-api 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 \ && 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 diff --git a/package.json b/package.json index 124d12e..4204d83 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,13 @@ { "name": "evobgp-release", "private": true, + "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": { "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", diff --git a/packages/ui/components.json b/packages/ui/components.json new file mode 100644 index 0000000..5ffe925 --- /dev/null +++ b/packages/ui/components.json @@ -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" + } +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..ae51895 --- /dev/null +++ b/packages/ui/package.json @@ -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" + } +} diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx new file mode 100644 index 0000000..aad4012 --- /dev/null +++ b/packages/ui/src/components/alert-dialog.tsx @@ -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 +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ) +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( +