Compare commits

..
1 Commits
Author SHA1 Message Date
Denozordec a37c931ee7 feat(monorepo): restructure web components and update configurations
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 38s
CI / go (push) Successful in 2m36s
CI / bird2 (push) Successful in 15s
CI / release (push) Failing after 3m7s
Refactored the project structure to support a monorepo setup, moving the web application to `apps/web/` and updating related configurations. Adjusted pre-commit hooks to use `pnpm` for linting and formatting. Updated CI workflows to reflect the new directory structure and dependencies. Removed legacy files and configurations from the previous `web/` directory, streamlining the project for better maintainability and clarity.
2026-06-30 23:54:28 +07:00
624 changed files with 4430 additions and 34520 deletions
-119
View File
@@ -1,119 +0,0 @@
---
name: shadcn-react
description: Управление shadcn/ui (React) + ReUI компонентами в EvoBGP — registry, CLI, импорты, матрица выбора. Использовать при любых UI-задачах в apps/web/ или packages/ui/ (новые экраны, компоненты, формы, data-grid, filters и др.).
---
# shadcn/ui (React) + ReUI в EvoBGP
EvoBGP использует **React 19 + shadcn/ui (base-nova) + ReUI** (`style: base-nova`, не Radix). Источники правды: MCP `plugin-shadcn-shadcn` + [ui.shadcn.com/docs](https://ui.shadcn.com/docs/components) + [reui.io/docs](https://reui.io/docs/components/base/).
См. `.cursor/rules/web-shadcn.mdc` (основные правила) и `.cursor/rules/context7-stack.mdc` (Context7 ID стека).
## Порядок UI-задачи (строго)
0. **Codegraph** `codegraph_explore` — найти существующие реализации и shared-обёртки.
1. **MCP `plugin-shadcn-shadcn`**`get_project_registries` (должны быть `@shadcn` и `@reui`).
2. **`search_items_in_registries`** — компонент/block/example:
- shadcn primitives/blocks → omit `registries` или `["@shadcn"]`
- Data Grid, Filters, Stepper, Kanban, Autocomplete и др. → `registries: ["@reui"]`
3. **`get_item_examples_from_registries`** — полный код примера перед JSX.
4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...`.
5. Выполнить add **из `apps/web`** (не из корня монорепо, не из `packages/ui`).
6. **CLI docs (обязательно):**
- `@shadcn/*``cd apps/web && pnpm dlx shadcn@latest docs <component>` — [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components)
- `@reui/*` → [ReUI docs](https://reui.io/docs/components/base/<name>) + [llms.txt](https://reui.io/llms.txt)
7. Сверить examples из MCP с API из docs CLI — реализовать только после совпадения.
8. Адаптировать под TanStack Router / Query → `apps/web/src/`.
9. **`get_audit_checklist`** — перед merge PR.
## Размещение и импорты
| Слой | Путь | Импорт |
|------|------|--------|
| shadcn primitives | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
| ReUI enterprise | `apps/web/src/components/reui/` | `@/components/reui/*` |
| Проектные обёртки | `apps/web/src/components/` | `@/components/<name>` |
```bash
cd apps/web
pnpm dlx shadcn@latest add button # @shadcn primitive → packages/ui/src/components/
pnpm dlx shadcn@latest add @reui/data-grid # ReUI enterprise → apps/web/src/components/reui/
```
## Матрица выбора shadcn vs ReUI
| Задача | Registry | Импорт |
|--------|----------|--------|
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` |
| Мультифильтры | `@reui` | `@/components/reui/filters` |
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
| Autocomplete | `@reui` | `@/components/reui/autocomplete``AutoCompleteInput` |
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid (через `DataGridCard`), не shadcn Data Table.
## Уже установленные shared-обёртки
В `apps/web/src/components/`:
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
- `QueryState` — обёртка loading/error/empty для TanStack Query
- `EmptyState` — пустые списки
- `ConfirmDialog` — подтверждения (не `window.confirm`)
- `LoadingButton` — кнопка с loading-состоянием
- `StatusBadge` — статусные бейджи
- `SectionCards` — сетка KPI-карточек
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
- `TruncatedText` — текст с тултипом
- `ModeToggle` — переключатель темы
Перед созданием новой обёртки — проверить существующие через Codegraph.
## Уже установленные ReUI-компоненты
В `apps/web/src/components/reui/`:
- `autocomplete`, `badge`, `data-grid/*`, `date-selector`, `filters`, `number-field`
Перед добавлением дубликата — проверить Codegraph и существующие обёртки.
## Зависимости (только `apps/web`, не `packages/ui`)
| npm-пакет | ReUI-компоненты |
|-----------|-----------------|
| `@tanstack/react-table` | data-grid |
| `@tanstack/react-virtual` | data-grid (virtual) |
| `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/modifiers`, `@dnd-kit/utilities` | data-grid dnd, sortable, kanban |
| `date-fns`, `react-day-picker` | date-selector |
После `shadcn add @reui/...` — проверить, что CLI добавил недостающие deps в `apps/web/package.json`.
## Semantic tokens (Styling)
ReUI расширяет тему shadcn токенами `--success`, `--info`, `--warning`, `--destructive-foreground`, `--invert` — уже в `packages/ui/src/styles/globals.css`.
- Badge/Alert: `variant="success"` / `"info"` / `"warning"` — не `bg-emerald-*`
- Базовая тема: `pnpm dlx shadcn@latest apply b2fA --only theme`
- ReUI-токены: по [Styling guide](https://reui.io/docs/styling); не править `globals.css` вручную без сверки с docs
## Запрещено
- Писать UI по памяти, не проверив MCP
- Копипаст с ui.shadcn.com без examples/add из MCP
- Самописные примитивы, если есть item в registry
- Пропускать MCP «потому что компонент простой»
- Класть ReUI в `packages/ui` или импортировать как `@evobgp/ui`
- Radix-варианты (`/docs/components/radix/...`) — только Base UI
- Raw Tailwind-цвета вместо ReUI semantic `variant`
- Использовать Tabler/Bootstrap/Material UI
## Чеклист перед завершением UI-задачи
- [ ] MCP search (с правильным registry) + examples
- [ ] `shadcn add <name>` (или `@reui/<name>`) из `apps/web`
- [ ] Импорты: `@evobgp/ui/components/*` для shadcn, `@/components/reui/*` для ReUI
- [ ] Зависимости в `apps/web/package.json`
- [ ] `pnpm --filter @evobgp/web run typecheck` — exit 0
- [ ] `pnpm --filter @evobgp/web run lint` — exit 0
- [ ] `pnpm --filter @evobgp/web run build` — exit 0
+34
View File
@@ -0,0 +1,34 @@
# shadcn-svelte в EvoBGP monorepo
Использовать при добавлении примитивов, блоков, правке темы в `apps/web` / `packages/ui`.
## Структура
- Примитивы: `packages/ui/src/components/``@evobgp/ui/components/*`
- Паттерны приложения: `apps/web/src/lib/components/patterns/`
- CLI всегда из `apps/web/`
## Workflow
1. Проверить, есть ли компонент в `packages/ui/src/components/`
2. `cd apps/web && pnpm dlx shadcn-svelte@latest add <name> -y -o`
3. Если файлы в `apps/web/@evobgp/ui/` — перенести в `packages/ui/src/components/`
4. Импорты shadcn в app: `@evobgp/ui/components/<name>/index.js`
5. `pnpm --filter @evobgp/web check && pnpm --filter @evobgp/web lint`
## Тема
- `packages/ui/src/styles/globals.css` с `@source`
- Импорт в `apps/web/src/routes/layout.css`: `@import '@evobgp/ui/styles/globals.css';`
## Не использовать
- React shadcn/ui примеры без адаптации под Svelte 5
- ReUI (`@reui/*`) — только React
- Legacy `$lib/components/ui/` re-exports
## Документация
- https://shadcn-svelte.com/docs
- https://shadcn-svelte.com/llms.txt
- Monorepo: `.cursor/rules/frontend-monorepo.mdc`
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 43636,
"pid": 46304,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1783570299043
"startedAt": 1782824332519
}
@@ -1,345 +0,0 @@
---
name: EvoBGP → React+shadcn/ui+ReUI
overview: "Big-bang миграция web UI EvoBGP с SvelteKit+Svelte5 на React 19 + Vite + TanStack Router/Query + shadcn/ui (Base UI, base-nova) + ReUI registry, идентично эталону vps-tracker. Структура — pnpm workspaces monorepo (apps/web + packages/ui как @evobgp/ui). Скоуп: только фронтенд; Go-бэкенд, OpenAPI, nginx, compose и bake-конфиги сохраняются с минимальной правкой путей сборки. Старый web/ заменяется полностью; перенос доменных экранов идёт по одному роуту через MCP-shadcn с проверкой по docs."
todos:
- id: "1"
content: "Этап 0: Установить MCP в workspace EvoBGP (plugin-shadcn-shadcn с @reui, plugin-context7, cursor-ide-browser) — скопировать .cursor/mcp.json из vps-tracker"
status: pending
- id: "2"
content: "Этап 1: Архивировать web/ → web-legacy-svelte/, зафиксировать инвентарь 13 роутов и компонентов"
status: pending
- id: "3"
content: "Этап 2: Создать pnpm workspaces монорепо — pnpm-workspace.yaml, корневой package.json, tsconfig.base.json, .npmrc, .nvmrc"
status: pending
- id: "4"
content: "Этап 3: Создать packages/ui (@evobgp/ui) — package.json, components.json (base-nova + @reui), src/styles/globals.css (копия vps-tracker), lib/utils.ts, hooks/use-mobile.ts"
status: pending
- id: "5"
content: "Этап 4: Добавить shadcn-примитивы в packages/ui через MCP+CLI (33 компонента идентично vps-tracker)"
status: pending
- id: "6"
content: "Этап 5: Создать apps/web каркас — package.json (@evobgp/web), components.json, tsconfig, vite.config.ts (TanStackRouterPlugin+react+tailwindcss, alias @ и @evobgp/ui/*, proxy /v1)"
status: pending
- id: "7"
content: "Этап 6: React-инициализация — main.tsx (StrictMode→ThemeProvider→QC→Router+Toaster), lib/queryClient.ts, lib/router.ts (Register augmentation), routes/__root.tsx, theme-provider.tsx, index.html"
status: pending
- id: "8"
content: "Этап 7: Перенос app-shell (sidebar-07) и shared-обёрток (PageShell, PageHeader, EmptyState, QueryState, ConfirmDialog, StatusBadge, DataGridCard, FormSheet, FormField, LoadingButton, skeletons) из vps-tracker с адаптацией брендинга"
status: pending
- id: "9"
content: "Этап 8: Перенос API-клиента и типов из legacy — api-client.ts (Bearer localStorage, Idempotency-Key, RFC 9457 Problem, waitForJob, apiPageAll), types/api.ts, queries/* по доменам"
status: pending
- id: "10"
content: "Этап 9: Добавить ReUI enterprise через MCP+CLI — @reui/data-grid, filters, autocomplete, date-selector, number-field, color-picker, badge"
status: pending
- id: "11"
content: "Этап 10.1: Роуты _auth layout + settings (token+theme) + access (session+api-keys) — простые экраны"
status: pending
- id: "12"
content: "Этап 10.2: Роуты index (dashboard KPI+recent) + modules/index (DataGridCard) + modules/$moduleId (детали с cards)"
status: pending
- id: "13"
content: "Этап 10.3: Роуты network (peers/speakers/BIRD tabs, live refetchInterval) + operations (jobs/revisions/diff, waitForJob) + schedule"
status: pending
- id: "14"
content: "Этап 10.4: Роуты directories (communities/DoH) + monitoring (bird/postgres/runtime) + tenant-settings + редиректы peers→network, revisions→operations"
status: pending
- id: "15"
content: "Этап 11: Обновить deploy/docker/evobgp-web/Dockerfile (pnpm+corepack, COPY apps/web + packages, dist вместо build); nginx.conf НЕ трогать; проверить bake"
status: pending
- id: "16"
content: "Этап 12: Обновить .gitea/workflows/ci.yaml web job (pnpm, tsc --noEmit, eslint, build) + path-filter apps/web/** + packages/ui/**"
status: pending
- id: "17"
content: "Этап 13: Cursor rules — удалить web-shadcn.mdc; скопировать shadcn-mcp/reui-mcp/frontend-* из vps-tracker; обновить engineering.mdc (DEP-04, TEST-04, DOC-SYNC) и context7-stack.mdc; создать .agents/skills/{shadcn,reui}"
status: pending
- id: "18"
content: "Этап 14: Финал — pnpm build без ошибок, MCP get_audit_checklist, cursor-ide-browser smoke 13 роутов, удалить web-legacy-svelte/, коммит feat(frontend)"
status: pending
isProject: false
---
# Plan: Миграция EvoBGP web UI на React + shadcn/ui + ReUI
## Контекст
**Цель:** Перевести `web/` EvoBGP с SvelteKit 2.50 + Svelte 5.54 + shadcn-svelte на стек идентичный `vps-tracker/apps/web`:
- **Стек:** React 19 + Vite 7 + TanStack Router/Query v5 + shadcn/ui (Base UI, `style: base-nova`) + ReUI registry `@reui` + Tailwind v4 monorepo + lucide-react + react-hook-form + Zod + recharts + sonner + next-themes
- **Структура:** pnpm workspaces monorepo — `apps/web` (SPA) + `packages/ui` (`@evobgp/ui` barrel)
- **Скоуп:** только frontend. Go-бэкенд (`internal/*`), OpenAPI (`docs/openapi.yaml`), compose/bake/nginx — не трогаются (минимальная правка только путей сборки в Dockerfile)
- **Стратегия:** big-bang. Старый `web/` архивируется в `web-legacy-svelte/` и удаляется в финале. Рабочий UI создаётся с нуля
- **Эталон:** `c:\Users\shats\Dev\vps-tracker\apps\web\` + `c:\Users\shats\Dev\vps-tracker\packages\ui\`
**Инвентарь существующего EvoBGP web** (из исследования):
- 13 роутов SvelteKit: `/`, `/modules`, `/modules/[id]`, `/network` (+редирект `/peers`), `/operations` (+редирект `/revisions`), `/monitoring`, `/schedule`, `/directories`, `/access`, `/tenant-settings`, `/settings`
- ~130 доменных `.svelte`-компонентов в `web/src/lib/components/{modules,network,operations,monitoring,tenant-settings,access,...}`
- API-клиент `web/src/lib/api/client.ts` + типы `web/src/lib/api/types.ts` (4301 строка OpenAPI, 76 эндпоинтов)
- Токен в localStorage (`evobgp_api_token`), Bearer, RFC 9457 errors, cursor-пагинация, Idempotency-Key, polling jobs
- Тема `neutral` в `web/src/routes/layout.css` с semantic tokens `--success/--warning/--info`
- nginx.conf — SPA fallback `try_files $uri $uri/ /index.html` (нейтрален к фреймворку, не меняется)
- Сейчас npm (НЕ pnpm), корневого `pnpm-workspace.yaml` нет
**Архитектура после миграции**:
```mermaid
flowchart TB
subgraph EvoBGP[EvoBGP repo root]
PWS[pnpm-workspace.yaml]
RP[package.json + pnpm-lock.yaml]
TSB[tsconfig.base.json]
NVM[.nvmrc Node 22]
MCP[.cursor/mcp.json]
subgraph Apps
AW[apps/web — React SPA]
end
subgraph Packages
UI[packages/ui — @evobgp/ui]
end
Deploy[deploy/ nginx+compose+bake]
GoAPI[internal/httpapi — Go API]
end
AW -->|imports| UI
AW -->|/v1 proxy dev| GoAPI
Deploy -->|Dockerfile build| AW
Deploy -->|nginx /v1 proxy| GoAPI
UI -->|globals.css @source| AW
```
---
## Этапы (последовательность, safe-by-design)
### Этап 0 — MCP environment (предварительный)
Установить MCP-серверы в workspace EvoBGP. Источник: `c:\Users\shats\.cursor\projects\c-Users-shats-Dev-vps-tracker\mcps\` — там работают:
- `plugin-shadcn-shadcn` (serverName `shadcn`) — даёт инструменты `get_project_registries`, `search_items_in_registries` (с поддержкой `registries: ["@reui"]`), `get_item_examples_from_registries`, `get_add_command_for_items`, `view_items_in_registries`, `list_items_in_registries`, `get_audit_checklist`
- `plugin-context7-plugin-context7` — docs-lookup React/TanStack/Recharts
- `cursor-ide-browser` — визуальная проверка UI
Скопировать `.cursor/mcp.json` из vps-tracker → `c:\Users\shats\Dev\EvoBGP\.cursor\mcp.json`. Enable серверы в Cursor Settings. ReUI MCP и codegraph — **не существуют как отдельные MCP** (исследовано); ReUI доступен **через** `plugin-shadcn-shadcn` с `registries: ["@reui"]`.
### Этап 1 — Подготовка
1. **Зафиксировать инвентарь**: список 13 роутов + ~130 компонентов + API-клиент + типы. Не удалять пока ничего.
2. **Архивировать старый стек**: `web/``web-legacy-svelte/`. Оставить до финала как референс при переносе экранов.
### Этап 2 — Монорепо-каркас
Корневые файлы:
- `pnpm-workspace.yaml`: `packages: ['apps/*', 'packages/*']`
- Корневой `package.json`: `{"private": true, "scripts": {"dev": "pnpm --filter @evobgp/web dev", "build": "pnpm --filter @evobgp/web build", "lint": "pnpm -r lint"}}`. Существующий корневой `package.json` (semantic-release/commitlint) — слить в один или оставить как `package.release.json` (решить по ходу).
- `tsconfig.base.json`: `strict: true`, `target: ES2022`, `moduleResolution: bundler`, `jsx: react-jsx`, `paths: {"@/*": ["./apps/web/src/*"], "@evobgp/ui/components/*": ["./packages/ui/src/components/*"], "@evobgp/ui/hooks/*": ["./packages/ui/src/hooks/*"], "@evobgp/ui/lib/utils": ["./packages/ui/src/lib/utils.ts"]}`
- `.npmrc`: `engine-strict=true`
- `.nvmrc`: `22` (приводим к единой версии с Dockerfile)
- `.gitignore`: добавить `node_modules/`, `dist/`, `routeTree.gen.ts` (опц.)
### Этап 3 — `packages/ui` (@evobgp/ui)
Структура полностью повторяет `c:\Users\shats\Dev\vps-tracker\packages\ui\`:
- `packages/ui/package.json`:
- `name: "@evobgp/ui"`
- `exports`: `./components/*`, `./hooks/*`, `./lib/utils`, `./globals.css`
- `dependencies`: `@base-ui/react`, `class-variance-authority`, `clsx`, `cmdk`, `date-fns`, `lucide-react`, `next-themes`, `react-day-picker`, `recharts`, `sonner`, `tailwind-merge`
- `peerDependencies`: `react`, `react-dom` (19)
- `packages/ui/components.json` (точная копия vps-tracker с заменой `@cfdm``@evobgp`):
```json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {"config": "", "css": "src/styles/globals.css", "baseColor": "neutral", "cssVariables": true},
"iconLibrary": "lucide",
"registries": {"@reui": "https://reui.io/r/{style}/{name}.json"},
"aliases": {"components": "@evobgp/ui/components", "utils": "@evobgp/ui/lib/utils", "hooks": "@evobgp/ui/hooks", "lib": "@evobgp/ui/lib", "ui": "@evobgp/ui/components"}
}
```
- `packages/ui/src/styles/globals.css`: **точная копия** `c:\Users\shats\Dev\vps-tracker\packages\ui\src\styles\globals.css` (Tailwind v4 + `tw-animate-css` + `@source "../"` + `@source "../../../apps/web/src"` + `:root`/`.dark` со всеми oklch-токенами + ReUI semantic tokens `--success/--info/--warning/--destructive-foreground/--invert/--focus` + chart-1..5 + sidebar-* + `@theme inline` + `@layer base`)
- `packages/ui/src/lib/utils.ts`: `cn()` через `clsx` + `tailwind-merge`
- `packages/ui/src/hooks/use-mobile.ts`
### Этап 4 — shadcn-примитивы через MCP
Перед каждым `add` — обязательно через MCP:
1. `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`
2. CLI `pnpm dlx shadcn@latest docs <component>` сверка с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components)
Компоненты (идентично набору `vps-tracker/packages/ui/src/components/`):
```
button button-group card input textarea label select checkbox field
separator scroll-area table badge tabs dialog alert-dialog sheet popover
dropdown-menu tooltip breadcrumb sidebar skeleton sonner spinner alert
command kbd calendar slider chart input-group input-otp toggle
```
Команда: `cd apps/web && pnpm dlx shadcn@latest add button card input ...` (CLI пишет в `packages/ui/src/components/` через aliases).
Применение темы: `pnpm dlx shadcn@latest apply b2fA --only theme -y` (обновит `:root`/`.dark` в `globals.css`).
### Этап 5 — `apps/web` каркас
- `apps/web/package.json` (`@evobgp/web`): deps из `vps-tracker/apps/web/package.json` с заменой `@cfdm/*` → `@evobgp/*`:
- `react`, `react-dom` 19
- `@tanstack/react-router`, `@tanstack/react-router-devtools`, `@tanstack/react-query`, `@tanstack/react-query-devtools`, `@tanstack/react-table`, `@tanstack/react-virtual`
- `@hookform/resolvers`, `react-hook-form`, `zod` (v3 для совместимости с RHF-resolvers — как в vps-tracker)
- `class-variance-authority`, `cmdk`, `date-fns`, `lucide-react`, `next-themes`, `react-day-picker`, `recharts`, `sonner`
- `@dnd-kit/core`, `@dnd-kit/modifiers`, `@dnd-kit/sortable`, `@dnd-kit/utilities`
- devDeps: `@tailwindcss/vite`, `@tanstack/router-plugin`, `@types/react`, `@types/react-dom`, `@vitejs/plugin-react`, `happy-dom`, `tailwindcss`, `tw-animate-css`, `typescript`, `vite`, `vitest`
- `apps/web/components.json` (алиасы web-side: `@/components`, `@/hooks`, `@/lib`, `utils: @evobgp/ui/lib/utils`, `ui: @evobgp/ui/components`; registries `@reui`; css `../../packages/ui/src/styles/globals.css`)
- `apps/web/vite.config.ts` (точная копия vps-tracker с заменой `@cfdm` → `@evobgp`): плагины `TanStackRouterPlugin({ target: 'react', autoCodeSplitting: true })` → `react()` → `tailwindcss()`; alias `@`, `@evobgp/ui/*`; server port 5173, proxy `/v1` и `/metrics` → `http://127.0.0.1:8080`
- `apps/web/tsconfig.json` extends `../../tsconfig.base.json`
- `apps/web/index.html` (#root, anti-FOUC тема-скрипт `evobgp-theme`)
### Этап 6 — React-инициализация
Точная копия vps-tracker с заменой путей:
- `apps/web/src/main.tsx`: `StrictMode → ThemeProvider → QueryClientProvider → RouterProvider + <Toaster richColors position="top-right" />`, единственный `import '@evobgp/ui/globals.css'`
- `apps/web/src/lib/queryClient.ts`: `staleTime: 60_000, retry: 1, refetchOnWindowFocus: false`
- `apps/web/src/lib/router.ts`: `createRouter({ routeTree, context, defaultPreload: 'intent', scrollRestoration: true })` + `declare module '@tanstack/react-router'` Register
- `apps/web/src/components/theme-provider.tsx`: next-themes `attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange`
- `apps/web/src/routes/__root.tsx`: `createRootRouteWithContext<{ queryClient }>` + `<AppShell><Outlet /></AppShell>`
### Этап 7 — Перенос app-shell и shared-обёрток
Перенести из `vps-tracker/apps/web/src/components/` с адаптацией брендинга/навигации:
- `layout/app-shell.tsx` (block sidebar-07, `SidebarProvider → Sidebar collapsible="icon" → SidebarHeader/SidebarContent(navGroups)/SidebarFooter + SidebarInset(header sticky h-16 backdrop-blur + SidebarTrigger + Breadcrumb + actions + main)`). `render={<Link to={item.to} />}` — **Base UI render-prop, не Radix asChild**. Навигация подставляется под EvoBGP-экраны.
- `mode-toggle.tsx`, `page-shell.tsx`, `page-header.tsx`, `empty-state.tsx`, `query-state.tsx`, `confirm-dialog.tsx`, `status-badge.tsx`, `section-cards.tsx`, `form-sheet.tsx`, `form-field.tsx`, `loading-button.tsx`, `skeletons.tsx`
- `data-grid-card.tsx` (обёртка над `@reui/data-grid`) — полная копия с типизированным `DataGridCardProps<TData>`
### Этап 8 — API-клиент и типы (перенос из legacy)
- `apps/web/src/lib/api-client.ts`: перенос логики из `web-legacy-svelte/src/lib/api/client.ts`:
- `TOKEN_STORAGE_KEY = 'evobgp_api_token'`
- `mergeHeaders`: Accept JSON + Bearer из localStorage
- `apiFetch`, `apiJSON<T>`, `apiMutate<T>` с auto-Idempotency-Key
- `parseResponse<T>`: 204/205→undefined, ошибки → `ApiError` с RFC 9457 Problem
- `waitForJob(jobId, opts?)`: poll `GET /v1/jobs/{id}` каждые 400ms
- `apiPageAll<T>`: cursor-пагинация (`items`/`next_cursor`/`has_more`)
- `apps/web/src/types/api.ts`: перенос всех типов из `web-legacy-svelte/src/lib/api/types.ts` (`ModuleRow`, `BgpPeer`, `SpeakerRow`, `RevisionRow`, `JobRow`, `AuthSession`, `ApiKey`, `PostgresOverview`, etc.)
- `apps/web/src/queries/` — по доменам: `auth.ts`, `modules.ts`, `network.ts`, `operations.ts`, `monitoring.ts`, `directories.ts`, `access.ts`, `settings.ts` (queryOptions + key factories)
### Этап 9 — ReUI enterprise-компоненты
Через MCP `search_items_in_registries` с `registries: ["@reui"]` → `get_item_examples` → `get_add_command`:
```bash
cd apps/web
pnpm dlx shadcn@latest add @reui/data-grid
pnpm dlx shadcn@latest add @reui/filters
pnpm dlx shadcn@latest add @reui/autocomplete
pnpm dlx shadcn@latest add @reui/date-selector
pnpm dlx shadcn@latest add @reui/number-field
pnpm dlx shadcn@latest add @reui/color-picker
pnpm dlx shadcn@latest add @reui/badge
```
Документация для `@reui/*`: [reui.io/docs/components/base/](https://reui.io/docs/components/base) + [llms.txt](https://reui.io/llms.txt) — **не** ui.shadcn.com для ReUI.
Ложатся в `apps/web/src/components/reui/` (НЕ в packages/ui). Post-add: импорты shadcn-примитивов внутри ReUI → `@evobgp/ui/components/*`. Проверить что `@tanstack/react-table`, `@tanstack/react-virtual`, `@dnd-kit/*`, `date-fns`, `react-day-picker` попали в `apps/web/package.json`.
### Этап 10 — Реализация 13 роутов (поэтапно)
Каждый роут — отдельная подзадача. Порядок от простого к сложному (позволяет рано верифицировать стек):
| # | Файл (TanStack file-based) | Что делает | Источник (legacy) |
|---|---|---|---|
| 1 | `routes/_auth.tsx` + `_auth.tsx` layout | auth guard (токен в localStorage) | `routes/+layout.ts` |
| 2 | `routes/_auth/settings.tsx` | API-токен + тема (свет/тёмн/сист) | `routes/settings/+page.svelte` |
| 3 | `routes/_auth/access.tsx` | `GET /v1/auth/session` + список API-ключей | `routes/access/+page.svelte` |
| 4 | `routes/index.tsx` | dashboard: KPI + recent jobs/revisions + network status | `routes/+page.svelte` |
| 5 | `routes/_auth/modules/index.tsx` | список модулей (DataGridCard) | `routes/modules/+page.svelte` |
| 6 | `routes/_auth/modules/$moduleId.tsx` | детали модуля (cards: AS/Cdn/Domain/IpRange) | `routes/modules/[moduleId]/+page.svelte` |
| 7 | `routes/_auth/network.tsx` | peers + speakers + BIRD settings (tabs) | `routes/network/+page.svelte` |
| 8 | `routes/_auth/operations.tsx` | jobs + revisions + diff (tabs, waitForJob) | `routes/operations/+page.svelte` |
| 9 | `routes/_auth/schedule.tsx` | refresh jobs (tabs all/refresh/failed) | `routes/schedule/+page.svelte` |
| 10 | `routes/_auth/directories.tsx` | BGP communities + DoH profiles (tabs) | `routes/directories/+page.svelte` |
| 11 | `routes/_auth/monitoring.tsx` | bird status + version + postgres + runtime logs | `routes/monitoring/+page.svelte` |
| 12 | `routes/_auth/tenant-settings.tsx` | настройки BIRD/ревизий/runtime (tabs) | `routes/tenant-settings/+page.svelte` |
| 13 | редиректы | `/peers`→`/network`, `/revisions`→`/operations?tab=revisions` | `routes/peers`, `routes/revisions` |
Для каждого роута: MCP-shadcn search+examples → `pnpm dlx shadcn@latest docs <name>` сверка с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components) → ReUI для data-grid/filters → композиция @evobgp/ui + DataGridCard; логика переносится из соответствующего `+page.svelte` (но на TanStack Query вместо инлайн fetch). Live-данные (network, monitoring) — `refetchInterval` вместо кастомного `setInterval`.
### Этап 11 — Инфраструктура деплоя
**Минимальные правки (compose/bake не трогать):**
`deploy/docker/evobgp-web/Dockerfile` (3 стадии → 3 стадии):
- `deps`: установить pnpm (corepack), `COPY pnpm-workspace.yaml package.json apps/web/package.json packages/ui/package.json ./` + `COPY apps/web/ apps/web/` + `COPY packages/ packages/` → `pnpm install --frozen-lockfile`
- `build`: `COPY . .` → `pnpm --filter @evobgp/web build`
- `web`: `COPY --from=web-artifacts /app/apps/web/dist /usr/share/nginx/html` (вместо `/web/build`)
`deploy/docker/evobgp-web/nginx.conf` — **не меняется** (SPA fallback совместим).
`deploy/docker/docker-bake.hcl` — проверить что target `web-build` корректно прокидывает контекст.
### Этап 12 — CI/CD
`.gitea/workflows/ci.yaml`, job `web` (строки 164-181):
- `actions/setup-node@v4` → `node-version: "22"`, `cache: pnpm`, `cache-dependency-path: pnpm-lock.yaml`
- Добавить `pnpm install` (через corepack)
- Заменить `npm run check` → `pnpm --filter @evobgp/web exec tsc --noEmit`
- Заменить `npm run lint` → `pnpm --filter @evobgp/web lint` (eslint)
- Добавить `pnpm --filter @evobgp/web build` (раньше проверялось только в Docker)
Job `changes` — path-filter `web/*` → `apps/web/**` + `packages/ui/**` (расширить globs).
### Этап 13 — Cursor rules + skills
**Удалить** (Svelte-специфика):
- `.cursor/rules/web-shadcn.mdc` (WEB-01..WEB-19 — всё про shadcn-svelte)
**Скопировать из vps-tracker `.cursor/rules/`** (с заменой `@cfdm` → `@evobgp`, `Vps*` → `EvoBgp*`):
- `shadcn-mcp.mdc`, `reui-mcp.mdc`, `shadcn-ui-production.mdc`, `frontend-shadcn.mdc`, `frontend-monorepo.mdc`, `frontend-ui-patterns.mdc`, `vite-tanstack-frontend.mdc`
**Обновить** `engineering.mdc`:
- `DEP-04`: «shadcn-svelte/bits-ui» → «shadcn/ui React (Base UI) + ReUI registry»
- `TEST-04`: `npm run check` → `pnpm --filter @evobgp/web exec tsc --noEmit`; `npm run lint` → eslint
- `DOC-SYNC-06/07`: `shadcn-svelte.com` → `ui.shadcn.com/docs` + `reui.io/docs`
**Обновить** `context7-stack.mdc` Web UI таблица: убрать svelte/sveltekit/bits-ui/formsnap/`@lucide/svelte`; добавить React 19, TanStack Router/Query/Table/Virtual, ReUI llms.txt, `lucide-react`.
**Создать** `c:\Users\shats\Dev\EvoBGP\.agents\skills\` (сейчас не существует):
- `shadcn/SKILL.md` — скопировать из cloudflare-domain-manager
- `reui/SKILL.md` — скопировать из cloudflare-domain-manager, заменить обёртки под EvoBGP (`DataGridCard`, `NetworkFiltersToolbar`)
### Этап 14 — Финальная проверка и очистка
1. `pnpm install && pnpm --filter @evobgp/web build` без ошибок
2. MCP `get_audit_checklist` — пройти по чек-листу
3. `cursor-ide-browser` smoke-тест всех 13 роутов (login через dev-токен, навигация, CRUD, dark/light toggle)
4. Удалить `web-legacy-svelte/`
5. Проверить что compose поднимается (`docker compose --profile reference up`)
6. Коммит на main (gitflow по `.cursor/rules/conventional-commits.mdc`): `feat(frontend): миграция web UI на React + shadcn/ui + ReUI`
---
## Цитаты ключевых файлов эталона
- Конфиги: [apps/web/components.json](c:\Users\shats\Dev\vps-tracker\apps\web\components.json), [packages/ui/components.json](c:\Users\shats\Dev\vps-tracker\packages\ui\components.json)
- Стили: [packages/ui/src/styles/globals.css](c:\Users\shats\Dev\vps-tracker\packages\ui\src\styles\globals.css)
- Инициализация: [apps/web/src/main.tsx](c:\Users\shats\Dev\vps-tracker\apps\web\src\main.tsx), [lib/queryClient.ts](c:\Users\shats\Dev\vps-tracker\apps\web\src\lib\queryClient.ts), [lib/router.ts](c:\Users\shats\Dev\vps-tracker\apps\web\src\lib\router.ts)
- Layout: [components/layout/app-shell.tsx](c:\Users\shats\Dev\vps-tracker\apps\web\src\components\layout\app-shell.tsx)
- Vite: [apps/web/vite.config.ts](c:\Users\shats\Dev\vps-tracker\apps\web\vite.config.ts)
- ReUI обёртка: [components/data-grid-card.tsx](c:\Users\shats\Dev\vps-tracker\apps\web\src\components\data-grid-card.tsx)
- API-клиент legacy: [web/src/lib/api/client.ts](c:\Users\shats\Dev\EvoBGP\web\src\lib\api\client.ts), [web/src/lib/api/types.ts](c:\Users\shats\Dev\EvoBGP\web\src\lib\api\types.ts)
## Документация
- [shadcn/ui Installation](https://ui.shadcn.com/docs/installation)
- [shadcn/ui Components](https://ui.shadcn.com/docs/components)
- [shadcn/ui Monorepo](https://ui.shadcn.com/docs/monorepo)
- [shadcn/ui MCP Server](https://ui.shadcn.com/docs/mcp)
- [ReUI Get Started](https://reui.io/docs/get-started)
- [ReUI Styling](https://reui.io/docs/styling)
- [ReUI MCP](https://reui.io/docs/mcp)
- [ReUI llms.txt](https://reui.io/llms.txt)
## Риски и митигация
| Риск | Митигация |
|---|---|
| Big-bang = длинное окно неработающего UI в dev | Этапы 3-9 делаются параллельно с рабочим `web-legacy-svelte/`; переключение атомарно в финале |
| ReUI на React 19 / Base UI может иметь breaking changes | MCP `get_item_examples` + dry-run `--dry-run` перед add |
| OpenAPI drift (типы в legacy vs контракт) | Типы переносятся как есть (frontend-only scope); Zod-схемы по желанию позже |
| npm → pnpm меняет lock-файлы и CI | Пункт 12 явно покрывает CI; corepack в Dockerfile |
| 13 роутов = большой объём работы | Этап 10 разбит по сложности; можно остановиться после базовых и продолжить инкрементально |
File diff suppressed because it is too large Load Diff
+12 -19
View File
@@ -32,28 +32,23 @@ alwaysApply: true
---
## Web UI (`apps/web/` + `packages/ui/`)
## Web UI (`web/`)
| Библиотека | Context7 ID | Версия в проекте | Когда |
|------------|-------------|------------------|-------|
| 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) |
| Svelte | `/websites/svelte_dev` | ^5.54 | runes, компоненты, реактивность |
| SvelteKit | `/sveltejs/kit` | ^2.50 | routing, `load`, adapters, SSR |
| 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/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 | иконки |
| 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`, колонки, сортировка |
UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (MCP + shadcn/ui React docs — первичный источник для компонентов).
UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (shadcn-svelte docs — первичный источник для компонентов).
---
@@ -82,14 +77,12 @@ UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (MCP +
1. **Контракт HTTP** — `docs/openapi.yaml` (не Context7).
2. **Context7** — синтаксис и API библиотек из таблицы.
3. **Локальные docs** — `docs/`, `web/README.md`, `AGENTS.md`.
4. **Официальный сайт** — BIRD: https://bird.nic.cz/?get_doc (если Context7 не покрыл кейс).
4. **Официальный сайт** — BIRD: https://bird.network.cz/?get_doc (если Context7 не покрыл кейс).
## Примеры запросов
```
/docs /facebook/react hooks useState useEffect
/docs /tanstack/router createFileRoute useSearch Link
/docs /tanstack/query useQuery useMutation queryOptions
/docs /websites/svelte_dev runes $state $derived
/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
+6 -6
View File
@@ -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/ui (React) + ReUI (см. `web-shadcn.mdc`).
*Проверка:* `apps/web/package.json`, `packages/ui/package.json` review.
**DEP-04** | MUST | Web UI-библиотеки — только экосистема shadcn-svelte/bits-ui (см. `web-shadcn.mdc`).
*Проверка:* `web/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 | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`.
**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`.
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
@@ -207,13 +207,13 @@ alwaysApply: true
| OpenAPI / problem+json | `docs/openapi.yaml`, RFC 9457 |
| Svelte / Kit | https://svelte.dev/docs , https://kit.svelte.dev/docs |
| shadcn-svelte | https://shadcn-svelte.com/docs |
| BIRD 2 | https://bird.nic.cz/?get_doc |
| BIRD 2 | https://bird.network.cz/?get_doc |
| Prometheus Go | https://pkg.go.dev/github.com/prometheus/client_golang |
**DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией.
**DOC-SYNC-02** | NEVER | Устаревшие примеры (Svelte 4 `export let`, deprecated pgx).
**DOC-SYNC-03** | MUST | Конфликт docs: **OpenAPI (HTTP)** → **код** → обзорные `docs/`; `.cursor/plans/` не контракт.
**DOC-SYNC-04** | MUST | Сомнения по React/shadcn/ReUI — MCP `plugin-shadcn-shadcn` + `pnpm --filter @evobgp/web run typecheck`.
**DOC-SYNC-04** | MUST | Сомнения по Svelte — Svelte MCP / `npm run check`.
**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: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
# web: cd web; npm run check; npm run lint (или scripts/lint-web.ps1)
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
# birdfmt: go test ./internal/birdfmt/... -count=1
```
+75
View File
@@ -0,0 +1,75 @@
---
description: shadcn-svelte Monorepo — apps/web + packages/ui + packages/shared, CLI workflow
globs: apps/web/**/*,packages/ui/**/*,packages/shared/**/*
alwaysApply: false
---
# Frontend Monorepo (shadcn-svelte)
Структура по образцу vps-tracker, стек — **SvelteKit 2 + Svelte 5 + shadcn-svelte** (не React/ReUI).
## Layout
```
apps/web/ # SvelteKit (routes, queries, domain components)
packages/ui/ # @evobgp/ui — shadcn-svelte primitives (только CLI output)
packages/shared/ # @evobgp/shared — Zod contracts, API types
```
Go API в корне репозитория (`internal/`, `cmd/`) без изменений.
## Два components.json
| Файл | Назначение |
|------|------------|
| `apps/web/components.json` | App aliases; `ui` → `@evobgp/ui/components` |
| `packages/ui/components.json` | UI package aliases |
**Синхронизировать:** `style`, `iconLibrary`, `baseColor` в обоих файлах.
## CLI — только из apps/web
```powershell
cd apps/web
pnpm dlx shadcn-svelte@latest add button -y -o
pnpm dlx shadcn-svelte@latest add sidebar -y -o
pnpm dlx shadcn-svelte@latest add breadcrumb -y -o
```
Перед обновлением существующих компонентов: `pnpm dlx shadcn-svelte@latest add button --dry-run`
## Куда CLI кладёт файлы
| Команда | Куда |
|---------|------|
| `add button` | `packages/ui/src/components/button/` |
| `add sidebar` | `packages/ui/src/components/sidebar/` |
Если CLI кладёт в `apps/web/@evobgp/ui/` — перенести в `packages/ui/src/components/`.
## Импорты
```svelte
import { Button } from '@evobgp/ui/components/button/index.js';
import { cn } from '@evobgp/ui/lib/utils';
import '@evobgp/ui/styles/globals.css'; // только в routes/layout.css
import type { ModuleRow } from '@evobgp/shared/types/api.js';
import { moduleCreateSchema } from '@evobgp/shared/contracts/modules.js';
```
| Запрещено | Разрешено |
|-----------|-----------|
| `$lib/ui/core/*` | `@evobgp/ui/components/*` |
| `apps/web/src/lib/components/ui/` (legacy re-export) | `packages/ui/src/components/` |
| Ручное редактирование темы вне CLI | shadcn-svelte theming docs |
## globals.css
`packages/ui/src/styles/globals.css` с `@source` на `packages/ui` и `apps/web/src`.
## Проверка после правок
```powershell
pnpm --filter @evobgp/web check
pnpm --filter @evobgp/web lint
```
+65
View File
@@ -0,0 +1,65 @@
---
description: UI-паттерны apps/web — shared components, spacing, матрица (Svelte, без ReUI)
globs: apps/web/**/*
alwaysApply: false
---
# Frontend UI Patterns (Svelte)
Эталон UX: vps-tracker (`PageShell`, `QueryState`, `SectionCards`, `DataTableCard`). ReUI **не используется** — Data Table = shadcn-svelte + `AppDataTable` / `DataTableCard`.
## Иерархия
```
@evobgp/ui/components/* ← shadcn-svelte CLI (packages/ui)
apps/web/src/lib/components/ ← shared + domain + layout
page-shell.svelte
query-state.svelte
section-cards.svelte
data-table-card.svelte
status-badge.svelte
form-sheet.svelte
crud-list-page.svelte
list-filters-bar.svelte
patterns/ ← AppDataTable, FormField, ConfirmDialog, EmptyState
layout/app-shell.svelte
domain-*/ ← modules, network, operations…
```
## Матрица
| Элемент | Компонент | Primitive |
|---------|-----------|-----------|
| Page wrapper | `PageShell` | — |
| Page title | `PageHeader` (`ui/app/page-header`) | — |
| Stat metrics | `SectionCards` | `Card` |
| Data list | `DataTableCard` + `AppDataTable` | `Table` |
| Filters | `ListFiltersBar` | `Select`, `Badge` |
| Empty | `EmptyState` | — |
| Loading / Error | `QueryState` | `Skeleton`, `Alert` |
| Status | `StatusBadge` | `Badge` |
| Create/Edit form | `FormSheet` / Dialog | `Sheet`, `Field` |
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
| Nav | `AppShell` | `Sidebar` |
| Breadcrumbs | `AppShell` header | `Breadcrumb` |
## Spacing
- `flex` + `gap-*`, не `space-y-*`
- Page: `gap-4 md:gap-6`, padding `p-4 md:p-6`
- Loading страницы → `Skeleton`, не page-level `Spinner`
- Max 1 primary CTA на экран
## Docs
- shadcn-svelte: https://shadcn-svelte.com/docs
- llms.txt: https://shadcn-svelte.com/llms.txt
- Data layer: `@tanstack/svelte-query` в `apps/web/src/lib/queries/`
## Overlay
| Сценарий | Компонент |
|----------|-----------|
| Create/edit | `Sheet` / `FormSheet` |
| Destructive | `ConfirmDialog` |
| Preview | `Dialog` |
+2 -2
View File
@@ -23,7 +23,7 @@ alwaysApply: false
| Параметры BIRD tenant | Global settings: `bird_router_id`, `bird_local_asn`, … (`docs/manual.md`) |
| BGP peers | `BGPPeer` + `ParsePeerNeighbor` |
**BIRD2 docs:** https://bird.nic.cz/?get_doc
**BIRD2 docs:** https://bird.network.cz/?get_doc
---
@@ -199,7 +199,7 @@ alwaysApply: false
## Documentation Sync
**DOC-SYNC-05** | MUST | BIRD — https://bird.nic.cz/?get_doc
**DOC-SYNC-05** | MUST | BIRD — https://bird.network.cz/?get_doc
**DOC-SYNC-08** | MUST | BGP policy — RFC 4271, 4760, 7454 + BIRD docs + `birdfmt`
**DOC-SYNC-09** | MUST | CIDR — https://pkg.go.dev/net/netip ; примеры — RFC 5737, 3849
+31 -107
View File
@@ -1,131 +1,55 @@
---
description: EvoBGP WebUI — React 19, shadcn/ui (base-nova), ReUI, TanStack Router/Query
description: EvoBGP WebUI — shadcn-svelte monorepo, Svelte 5, @evobgp/ui
globs:
- apps/web/**
- packages/ui/**
- packages/shared/**
alwaysApply: false
---
# Web UI — React + shadcn/ui + ReUI
# Web UI — shadcn-svelte (monorepo)
**Источники правды:**
- shadcn/ui React: https://ui.shadcn.com/docs/components
- ReUI Base UI: https://reui.io/docs/components/base/<name>
- ReUI llms.txt: https://reui.io/llms.txt
- MCP `plugin-shadcn-shadcn` (registries: `@shadcn`, `@reui`) — перед любой UI-задачей
**Источник правды:** https://shadcn-svelte.com/docs
Общие правила Go/API: `.cursor/rules/engineering.mdc`. Стек ID: `.cursor/rules/context7-stack.mdc`.
Monorepo: [`frontend-monorepo.mdc`](frontend-monorepo.mdc), паттерны: [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc). Skill: [`.agents/skills/shadcn-svelte/SKILL.md`](.agents/skills/shadcn-svelte/SKILL.md).
## Слои UI
## Слои
| Слой | Путь | Назначение |
|------|------|------------|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
| Примитивы | `packages/ui/src/components/` | shadcn-svelte (только CLI) |
| Паттерны | `apps/web/src/lib/components/patterns/` | AppDataTable, FormField, ConfirmDialog |
| Shared UI | `apps/web/src/lib/components/` | PageShell, QueryState, SectionCards |
| App chrome | `apps/web/src/lib/ui/app/` | PageHeader, toast, nav |
| Контракты | `packages/shared/` | types + Zod |
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
Тема: `packages/ui/src/styles/globals.css` → импорт в `apps/web/src/routes/layout.css`.
## Правила
CLI из `apps/web/`:
**WEB-01** | MUST | Перед новым UI — MCP `plugin-shadcn-shadcn`: `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`. Только после — JSX.
*Rationale:* единый источник правды и API.
*Проверка:* review; нет самописных примитивов, если есть registry item.
**WEB-02** | MUST | Отсутствующий shadcn-примитив — `pnpm dlx shadcn@latest add <component>` (из `apps/web`). ReUI — `pnpm dlx shadcn@latest add @reui/<name>`.
*Проверка:* файлы в `packages/ui/src/components/` (для shadcn) или `apps/web/src/components/reui/` (для ReUI).
**WEB-03** | NEVER | Альтернативные UI-kit'ы (Material, Vuetify, Tabler, Bootstrap утилиты).
*Проверка:* `apps/web/package.json` review.
**WEB-04** | MUST | Композиция по docs: использовать под-компоненты (`CardHeader`, `TableRow`, `TabsList`, …).
*Проверка:* сверка с shadcn/ReUI docs.
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
*Проверка:* https://ui.shadcn.com/docs/components/form
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
*Проверка:* https://ui.shadcn.com/docs/components/sonner
**WEB-08** | MUST | Иконки — `lucide-react` (`components.json` → `iconLibrary: lucide`).
*Проверка:* imports; нет `@tabler/icons-react`, `@lucide/svelte`.
**WEB-09** | MUST | Цвета — CSS-переменные `globals.css` и ReUI semantic токены (`variant="success"/"info"/"warning"`); не hex/rgb на страницах.
*Проверка:* grep `#[0-9a-f]{3,6}` в `apps/web/src/routes/`.
**WEB-10** | SHOULD | Кастомизация — правка `packages/ui`/`reui` (Open Code), не `!important` поверх API.
**WEB-11** | MUST | `apps/web/src/routes/**` — композиция `@evobgp/ui/components/*` + `@/components/*` + `@/components/reui/*`; не копировать целые примитивы в route.
*Проверка:* review.
**WEB-12** | NEVER | Примеры Svelte/SvelteKit, Tabler, Bootstrap — без адаптации под текущий React-стек.
*Проверка:* `pnpm --filter @evobgp/web run typecheck`.
**WEB-13** | MUST | Реактивность — React 19 (`useState`, `useEffect`, TanStack Query/Router хуки); не Svelte runes, не `export let`.
*Проверка:* `pnpm --filter @evobgp/web run typecheck`.
**WEB-14** | SHOULD | Нетривиальный UI — прочитать страницу компонента shadcn/ReUI (props, a11y).
**WEB-15** | MUST | Сомнения — MCP `plugin-shadcn-shadcn` + shadcn CLI docs + `pnpm --filter @evobgp/web run typecheck`.
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
**WEB-17** | MUST | Пустые списки — `EmptyState` или через `QueryState` с `emptyTitle`.
*Проверка:* review.
**WEB-18** | SHOULD | Повторяемая комбинация core (≥2 раза) — вынести в `apps/web/src/components/`.
**WEB-19** | MUST | **После любого изменения `apps/web/**` или `packages/ui/**`** — перед завершением задачи агент **обязан**:
```powershell
pnpm --filter @evobgp/web run typecheck
pnpm --filter @evobgp/web run lint
pnpm --filter @evobgp/web run build
pnpm dlx shadcn-svelte@latest add <component> -y -o
```
Все три команды должны exit 0. Не сдавать PR/ответ, пока все три не пройдут.
*Rationale:* CI job `web` = typecheck + lint + build.
*Проверка:* CI job `web`.
**WEB-20** | MUST | Роутинг — TanStack Router (file-based `apps/web/src/routes/`); типобезопасные `createFileRoute`, `useSearch`, `Link`. Не `react-router-dom`.
*Проверка:* `tsr generate` в `build`/`typecheck` скриптах.
## Правила (кратко)
**WEB-21** | MUST | Data fetching — TanStack Query (`useQuery`, `useMutation`, `queryOptions`); query-key factories в `apps/web/src/queries/`. Mutations invalidate keys, не refetch вручную.
*Проверка:* review `queries/*.ts`.
- **WEB-01** Новый UI — сначала shadcn-svelte docs; не HTML+CSS с нуля
- **WEB-02** Примитивы только через CLI → `packages/ui`
- **WEB-05** Формы — Formsnap + superforms; `patterns/form/`
- **WEB-06** Таблицы — `AppDataTable` / `DataTableCard`
- **WEB-07** Toast — `notify` из `ui/app/toast.ts`
- **WEB-08** Иконки — `@lucide/svelte`
- **WEB-09** Цвета — CSS variables; не hex в routes
- **WEB-13** Svelte 5 runes
- **WEB-16** Удаление — `ConfirmDialog`
- **WEB-17** Пустые списки — `EmptyState`
- **WEB-19** После правок `apps/web/**`:
**WEB-22** | MUST | Legacy Svelte — в `web-legacy-svelte/` (archive). Не использовать импорты оттуда в новом коде; только как референс при миграции роутов.
---
## Documentation Sync (Web)
**DOC-SYNC-06** | MUST | UI — первично MCP + shadcn/ui docs (React) + ReUI docs (Base UI); при конфликте с блогами/Stack Overflow побеждает официальная страница.
**DOC-SYNC-07** | MUST | Перед `add` — сверить Installation/Theming с `apps/web/components.json`, `packages/ui/components.json` и `packages/ui/src/styles/globals.css`.
Tailwind v4 + base-nova: https://ui.shadcn.com/docs/migration/tailwind-v4
ReUI semantic tokens: https://reui.io/docs/styling
---
```powershell
pnpm --filter @evobgp/web check
pnpm --filter @evobgp/web lint
```
## Enforcement
**Обязательный финальный шаг агента при правках `apps/web/**` или `packages/ui/**`** (см. **WEB-19**):
```powershell
pnpm --filter @evobgp/web run typecheck
pnpm --filter @evobgp/web run lint
pnpm --filter @evobgp/web run build
```
**PR checklist `apps/web/**` / `packages/ui/**`:**
- [ ] `pnpm --filter @evobgp/web run typecheck` — exit 0
- [ ] `pnpm --filter @evobgp/web run lint` — exit 0
- [ ] `pnpm --filter @evobgp/web run build` — exit 0
- [ ] shadcn-примитивы в `packages/ui/src/components/`, ReUI в `apps/web/src/components/reui/`
- [ ] Новые примитивы через shadcn CLI (`@shadcn` или `@reui`)
- [ ] Импорты: `@evobgp/ui/components/*` для shadcn, `@/components/reui/*` для ReUI
- [ ] Ссылка на docs компонента (если новый паттерн)
**CI:** job `web` — `typecheck` + `lint` + `build`.
PR checklist: `check` + `lint` exit 0; примитивы в `@evobgp/ui`, не дубли в routes.
+1 -10
View File
@@ -1,19 +1,10 @@
{
"plugins": {
"shadcn": {
"enabled": true
},
"context7-plugin": {
"svelte": {
"enabled": true
},
"claude-plugins-official/gopls-lsp": {
"enabled": true
},
"claude-plugins-official/typescript-lsp": {
"enabled": true
},
"svelte": {
"enabled": false
}
}
}
+5 -8
View File
@@ -9,7 +9,7 @@ description: Context7 lookup для стека EvoBGP — использоват
## Workflow
1. Определи область: `internal/` (Go), `apps/web/` + `packages/ui/` (React + shadcn/ui + ReUI), `docs/openapi.yaml`, `birdfmt`/`pipeline` (BIRD), `deploy/compose` (Docker).
1. Определи область: `internal/` (Go), `web/` (Svelte), `docs/openapi.yaml`, `birdfmt`/`pipeline` (BIRD), `deploy/compose` (Docker).
2. Найди строку в таблице `context7-stack.mdc`.
3. Вызови `query-docs` с `libraryId` из таблицы и полным вопросом пользователя.
4. `resolve-library-id` — только если библиотеки нет в таблице или нужна другая major-версия.
@@ -18,17 +18,14 @@ description: Context7 lookup для стека EvoBGP — использоват
| Задача | libraryId |
|--------|-----------|
| React 19 hooks | `/facebook/react` |
| TanStack Router | `/tanstack/router` |
| TanStack Query | `/tanstack/query` |
| shadcn/ui (React) | MCP `plugin-shadcn-shadcn` + https://ui.shadcn.com/docs |
| ReUI Base UI | https://reui.io/llms.txt + MCP с `registries: ["@reui"]` |
| Svelte 5 runes | `/websites/svelte_dev` |
| SvelteKit load/forms | `/sveltejs/kit` |
| shadcn-svelte компонент | `/websites/shadcn-svelte` |
| pgx pool/query | `/websites/pkg_go_dev_github_com_jackc_pgx_v5` |
| Go net/http | `/golang/go/go1_24_6` |
| OpenAPI lint | `/redocly/redocly-cli` |
| BIRD config | `/llmstxt/bird_xmsl_dev_llms_txt` |
| Tailwind v4 | `/tailwindlabs/tailwindcss.com` |
| Zod schema | `/websites/zod_dev_v4` |
| recharts | `/recharts/recharts` |
| Zod 4 schema | `/websites/zod_dev_v4` |
Полный список и версии — в `context7-stack.mdc`.
+8 -8
View File
@@ -98,9 +98,9 @@ jobs:
openapi=true
go=true
;;
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
apps/web/README.md|apps/web/components.json)
;;
apps/web/*|packages/ui/*|packages/shared/*)
apps/web/*|packages/ui/*|packages/shared/*|pnpm-workspace.yaml|pnpm-lock.yaml)
web=true
;;
deploy/bird/*)
@@ -170,15 +170,15 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Enable pnpm via corepack
run: corepack enable
- name: pnpm install, typecheck, lint, build
- uses: pnpm/action-setup@v4
with:
version: 10.33.2
- name: pnpm install, check, lint
run: |
set -euxo pipefail
pnpm install --frozen-lockfile
pnpm --filter @evobgp/web run typecheck
pnpm --filter @evobgp/web run lint
pnpm --filter @evobgp/web run build
pnpm --filter @evobgp/web check
pnpm --filter @evobgp/web lint
# ---------------------------------------------------------------------------
go:
+1 -8
View File
@@ -1,13 +1,6 @@
# Root npm/pnpm (semantic-release, commitlint, workspaces) — never commit deps
# Root npm (semantic-release, commitlint) — npm ci in CI, never commit deps
node_modules/
# Vite / TS build output
apps/web/dist/
packages/*/dist/
# TanStack Router auto-generated route tree
apps/web/src/routeTree.gen.ts
# Generated by deploy/docker/write-bake-override.sh (CI/local bake)
deploy/docker/docker-bake.override.hcl
-1
View File
@@ -1 +0,0 @@
22
+2 -2
View File
@@ -13,7 +13,7 @@ repos:
hooks:
- id: prettier-web
name: prettier (web)
entry: bash -c 'cd web && npx prettier --check .'
entry: bash -c 'pnpm --filter @evobgp/web lint'
language: system
files: ^web/
files: ^apps/web/
pass_filenames: false
+41 -67
View File
@@ -1,74 +1,48 @@
# Руководство для ИИ-агентов (экономия контекста)
# EvoBGP — руководство для ИИ-агентов
Краткие ориентиры по репозиторию **EvoBGP**, чтобы не тратить токены на полное сканирование дерева и повторное чтение одних и тех же файлов.
Краткая карта репозитория. Полная архитектура: [docs/architecture.md](docs/architecture.md). HTTP: [docs/openapi.yaml](docs/openapi.yaml).
## С чего начать (минимум чтения)
## Monorepo (фронтенд)
0. **Инженерные правила** — при изменении кода следовать [.cursor/rules/engineering.mdc](.cursor/rules/engineering.mdc); для `apps/web/` + `packages/ui/` — [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc); для `birdfmt` / `pipeline` / BIRD — [.cursor/rules/networking-bird.mdc](.cursor/rules/networking-bird.mdc). **Context7 (документация библиотек)** — закреплённые ID стека: [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc); скилл [.cursor/skills/context7-evobgp/SKILL.md](.cursor/skills/context7-evobgp/SKILL.md).
1. **[docs/README.md](docs/README.md)** — оглавление и роли читателя.
2. **[docs/architecture.md](docs/architecture.md)** — компоненты `cmd/`, карта `internal/`, потоки данных (одного этого файла обычно достаточно для ориентации).
3. Задача-специфично: [docs/api.md](docs/api.md), [docs/access.md](docs/access.md) — только если меняете API или доступ.
Источник правды по HTTP-контракту: **[docs/openapi.yaml](docs/openapi.yaml)**. Не дублируйте длинные фрагменты спецификации в ответах — ссылайтесь на путь и тег/операцию.
## Карта кода (куда смотреть)
| Область | Где искать |
|---------|------------|
| REST, auth, CORS | `internal/httpapi/` |
| Бизнес-слой и абстракция хранилища | `internal/store/` |
| PostgreSQL | `internal/repository/`, `internal/db/`, `migrations/` |
| Фоновые задачи | `internal/jobs/` |
| Цепочка refresh модуля (ingest+render, BIRD preview) | `internal/pipeline/` |
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
| Точки входа процессов | `cmd/*/` |
| Веб (SvelteKit) | `web/` |
| Compose, деплой | `deploy/compose/` |
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
## Как не раздувать контекст
- **Сначала узкий поиск:** `grep`/поиск по символу или короткий семантический запрос по одной папке (`internal/httpapi/`, `internal/pipeline/`, …), а не чтение всех `.go` подряд.
- **Читайте файлы целиком только при необходимости:** большие файлы — с `offset`/`limit` или по найденным строкам.
- **Не подтягивайте в контекст:** `web/node_modules/`, сгенерированные артефакты сборки, бинарники, полный `openapi.html`, если достаточно `openapi.yaml`.
- **Повторное использование:** если [docs/architecture.md](docs/architecture.md) уже описывает поток — не пересказывайте его длинно; укажите документ и конкретный подпункт задачи.
- **Длинные планы:** `.cursor/plans/*.plan.md` — для истории решений; для навигации пользователю достаточно `docs/`; не читайте план целиком без причины.
## Коммиты (Conventional Commits)
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit**, **`/commit-message`** или это следует из плана — **сразу**:
1. Shell: `powershell -NoProfile -File scripts/commit/staged-context.ps1` (первый вызов, до текста коммита).
2. Скилл [.cursor/skills/commit-message/SKILL.md](.cursor/skills/commit-message/SKILL.md) и правило [.cursor/rules/conventional-commits.mdc](.cursor/rules/conventional-commits.mdc).
Без вывода скрипта (exit 0) **не** придумывать сообщение коммита. Заголовок — EN, тело — RU; несвязанные области — auto-split (скилл).
**Кнопка ✨ Generate commit message в Source Control** skill/rule **не** использует. Для сообщений по правилам EvoBGP — Agent Chat → **`/commit-message`** (см. [.cursor/commands/commit-message.md](.cursor/commands/commit-message.md)).
## Команды и среда
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
- Быстрый старт и переменные: [docs/quickstart.md](docs/quickstart.md), [README.md](README.md).
- **Go:** после правок — `gofmt -w`, `go vet ./...`, `scripts/lint-go.ps1` (как CI golangci-lint).
## Язык документации проекта
Пользовательская документация в `docs/` — преимущественно на русском. Комментарии и имена в коде — в существующем стиле репозитория.
## Frontend (React + shadcn/ui + ReUI)
При правках `apps/web/**` или `packages/ui/**` следуйте [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc) (**WEB-19**): перед завершением задачи **обязательно**:
```powershell
pnpm --filter @evobgp/web run typecheck
pnpm --filter @evobgp/web run lint
pnpm --filter @evobgp/web run build
```
EvoBGP/
├── cmd/, internal/ # Go API /v1
├── apps/web/ # SvelteKit SPA (@evobgp/web)
├── packages/ui/ # @evobgp/ui — shadcn-svelte primitives
├── packages/shared/ # @evobgp/shared — types, Zod contracts
└── pnpm-workspace.yaml
```
Все три команды должны exit 0. CI job `web` не пропускает без этого.
| Слой | Импорт |
|------|--------|
| Примитивы | `@evobgp/ui/components/*` |
| CSS | `@evobgp/ui/styles/globals.css` |
| Типы/контракты | `@evobgp/shared/types/*`, `@evobgp/shared/contracts/*` |
| Паттерны UI | `$lib/components/*`, `$lib/components/patterns/*` |
| Queries | `$lib/queries/*` (`@tanstack/svelte-query`) |
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
## С чего начать
UI-задачи начинаются с MCP `plugin-shadcn-shadcn` (search → examples → add command), затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
1. **Правила:** [engineering.mdc](.cursor/rules/engineering.mdc), [web-shadcn.mdc](.cursor/rules/web-shadcn.mdc), [frontend-monorepo.mdc](.cursor/rules/frontend-monorepo.mdc)
2. **Фронт:** [apps/web/README.md](apps/web/README.md)
3. **Go:** `internal/httpapi/`, `internal/store/`, `internal/pipeline/`
## Команды
```powershell
pnpm install
pnpm --filter @evobgp/web dev
pnpm --filter @evobgp/web build
pnpm --filter @evobgp/web check
pnpm --filter @evobgp/web lint
```
После правок `apps/web/**`**обязательно** `check` и `lint` (WEB-19).
## UX-эталон
vps-tracker: `PageShell`, `SectionCards`, `QueryState`, `DataTableCard`, sidebar-07 layout. ReUI не используется — Data Table на shadcn-svelte.
## Коммиты
При запросе коммита: [conventional-commits.mdc](.cursor/rules/conventional-commits.mdc), скилл `.cursor/skills/commit-message/SKILL.md`.
+1 -1
View File
@@ -1,6 +1,6 @@
# EvoBGP
Control plane для управления префиксами, модулями ingest, ревизиями конфигурации BIRD и выкладкой на BGP-спикеры. Репозиторий включает HTTP API на Go, веб-интерфейс (`web/`), CLI для реплик (`evobgp-node`), агент и Docker Compose для локального и эталонного развёртывания.
Control plane для управления префиксами, модулями ingest, ревизиями конфигурации BIRD и выкладкой на BGP-спикеры. Репозиторий включает HTTP API на Go, веб-интерфейс (`apps/web/`), CLI для реплик (`evobgp-node`), агент и Docker Compose для локального и эталонного развёртывания.
## Документация
View File
+40
View File
@@ -0,0 +1,40 @@
# EvoBGP WebUI (`@evobgp/web`)
SvelteKit-панель управления EvoBGP. Monorepo: `apps/web` + `packages/ui` + `packages/shared`.
Правила: [.cursor/rules/web-shadcn.mdc](../../.cursor/rules/web-shadcn.mdc), [frontend-monorepo.mdc](../../.cursor/rules/frontend-monorepo.mdc).
## Структура
| Путь | Назначение |
| ------------------------------ | ------------------------------------------- |
| `packages/ui/src/components/` | shadcn-svelte примитивы (`@evobgp/ui`) |
| `src/lib/components/` | PageShell, QueryState, SectionCards, domain |
| `src/lib/components/patterns/` | AppDataTable, FormField, ConfirmDialog |
| `src/lib/ui/app/` | PageHeader, nav, toast |
| `src/lib/queries/` | TanStack Query factories |
| `packages/shared/` | API types + Zod contracts |
## Разработка
Из корня репозитория:
```powershell
pnpm install
pnpm --filter @evobgp/web dev
pnpm --filter @evobgp/web check
pnpm --filter @evobgp/web lint
pnpm --filter @evobgp/web build
```
Добавление shadcn-svelte (из `apps/web/`):
```powershell
pnpm dlx shadcn-svelte@latest add <component> -y -o
```
Тема: `packages/ui/src/styles/globals.css` → импорт в `src/routes/layout.css`.
## Проверка перед PR
`pnpm --filter @evobgp/web check` и `lint` — exit 0 (WEB-19).
+14 -21
View File
@@ -1,23 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"registries": {
"@reui": "https://reui.io/r/{style}/{name}.json"
},
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "@evobgp/ui/lib/utils",
"ui": "@evobgp/ui/components"
}
"$schema": "https://www.shadcn-svelte.com/schema.json",
"tailwind": {
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "@evobgp/ui/components",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"iconLibrary": "lucide"
}
-36
View File
@@ -1,36 +0,0 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'
export default tseslint.config(
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'prefer-const': 'off',
},
},
{
files: ['src/components/reui/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'react-hooks/exhaustive-deps': 'off',
},
},
)
-23
View File
@@ -1,23 +0,0 @@
<!doctype html>
<html lang="ru" class="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>EvoBGP</title>
<script>
// Anti-FOUC: apply persisted theme before paint (matches next-themes attribute="class")
try {
var t = localStorage.getItem('evobgp-theme');
var m = window.matchMedia('(prefers-color-scheme: dark)').matches;
var dark = t === 'dark' || (!t || t === 'system') && m;
if (dark) document.documentElement.classList.add('dark');
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+46 -60
View File
@@ -1,62 +1,48 @@
{
"name": "@evobgp/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsr generate && tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsr generate && tsc --noEmit",
"lint": "eslint .",
"test": "vitest run"
},
"dependencies": {
"@base-ui/react": "^1.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@evobgp/ui": "workspace:*",
"@hookform/resolvers": "^3.10.0",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-router": "^1.130.2",
"@tanstack/react-router-devtools": "^1.130.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.4",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.0",
"react-hook-form": "^7.60.0",
"recharts": "3.8.0",
"sonner": "^1.7.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@tanstack/router-plugin": "^1.130.0",
"@tanstack/router-cli": "^1.130.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.0.0",
"@eslint/js": "^9.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.0",
"globals": "^15.0.0",
"typescript-eslint": "^8.0.0",
"happy-dom": "^18.0.0",
"tailwindcss": "^4.1.0",
"tw-animate-css": "^1.0.0",
"typescript": "^5.9.2",
"vite": "^7.3.1",
"vitest": "^3.0.0"
}
"name": "@evobgp/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check .",
"format": "prettier --write ."
},
"devDependencies": {
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^22.15.0",
"formsnap": "^2.0.1",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"sveltekit-superforms": "^2.30.1",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@evobgp/shared": "workspace:*",
"@evobgp/ui": "workspace:*",
"@tanstack/svelte-query": "^5.90.2",
"@tanstack/table-core": "^8.21.3",
"bits-ui": "^2.17.2",
"clsx": "^2.1.1",
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
}
}
-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0f172a"/>
<text x="16" y="22" font-family="ui-sans-serif,system-ui,sans-serif" font-size="18" font-weight="700" fill="#f8fafc" text-anchor="middle">B</text>
</svg>

Before

Width:  |  Height:  |  Size: 272 B

@@ -1,103 +0,0 @@
import { useState } from 'react'
import { Plus, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
import { DataGridCard } from '@/components/data-grid-shell'
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKey, ApiKeyCreated } from '@/types/api'
interface AccessApiKeysCardProps {
items: ApiKey[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function AccessApiKeysCard({
items,
isLoading,
isError,
error,
onRetry,
}: AccessApiKeysCardProps) {
const [createOpen, setCreateOpen] = useState(false)
const [tokenDialogOpen, setTokenDialogOpen] = useState(false)
const [revealedToken, setRevealedToken] = useState('')
const revoke = useRevokeApiKeyMutation()
const rotate = useRotateApiKeyMutation()
function showToken(created: ApiKeyCreated) {
setRevealedToken(created.token)
setTokenDialogOpen(true)
}
function handleRotated(id: string) {
rotate.mutate(id, {
onSuccess: (created) => showToken(created),
})
}
return (
<>
<DataGridCard
title="API-ключи"
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
actions={
<>
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
Обновить
</Button>
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
<Plus />
Создать
</Button>
</>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет ключей"
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
skeleton={<TableSkeleton rows={4} cols={6} />}
onRetry={onRetry}
>
{(data) => (
<AccessApiKeysGrid
items={data}
isLoading={isLoading}
onRotate={handleRotated}
onRevoke={(id) => revoke.mutate(id)}
rotatePending={rotate.isPending}
revokePending={revoke.isPending}
/>
)}
</QueryState>
</DataGridCard>
<ApiKeyCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={showToken}
/>
<ApiKeyTokenDialog
open={tokenDialogOpen}
token={revealedToken}
onOpenChange={setTokenDialogOpen}
/>
</>
)
}
@@ -1,157 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { RefreshCw, Trash2 } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ApiKey } from '@/types/api'
export function AccessApiKeysGrid({
items,
isLoading = false,
onRotate,
onRevoke,
rotatePending = false,
revokePending = false,
}: {
items: ApiKey[]
isLoading?: boolean
onRotate: (id: string) => void
onRevoke: (id: string) => void
rotatePending?: boolean
revokePending?: boolean
}) {
const columns = useMemo<ColumnDef<ApiKey>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => (
<CategoryBadge className="font-mono text-xs">{row.original.role}</CategoryBadge>
),
meta: { headerTitle: 'Роль' },
},
{
accessorKey: 'prefix',
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}</span>
),
meta: { headerTitle: 'Префикс' },
},
{
id: 'status',
enableSorting: false,
header: 'Статус',
cell: ({ row }) =>
row.original.revoked_at ? (
<StatusBadge status="error" label="отозван" />
) : (
<StatusBadge status="active" label="активен" />
),
meta: { headerTitle: 'Статус' },
},
{
id: 'expires_at',
accessorFn: (row) => row.expires_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Истекает" />,
cell: ({ row }) => (
<DataGridMutedCell>{formatApiKeyDate(row.original.expires_at)}</DataGridMutedCell>
),
meta: { headerTitle: 'Истекает' },
},
{
id: 'last_used_at',
accessorFn: (row) => row.last_used_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
cell: ({ row }) => (
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
),
meta: { headerTitle: 'Последнее использование' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const k = row.original
return (
<div className="flex gap-1">
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
type="button"
title="Ротировать"
disabled={!!k.revoked_at || rotatePending}
>
<RefreshCw className="size-3.5" />
</Button>
}
title="Ротировать ключ?"
description="Старый токен перестанет работать сразу."
confirmLabel="Ротировать"
onConfirm={() => onRotate(k.id)}
/>
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
type="button"
className="text-destructive"
disabled={!!k.revoked_at || revokePending}
title="Отозвать"
>
<Trash2 className="size-3.5" />
</Button>
}
title="Отозвать API-ключ?"
description={`${k.name} (${k.prefix}…)`}
confirmLabel="Отозвать"
destructive
onConfirm={() => onRevoke(k.id)}
/>
</div>
)
},
},
],
[onRevoke, onRotate, revokePending, rotatePending],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ключей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск API-ключей…"
/>
)
}
@@ -1,109 +0,0 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
import { useCreateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
interface ApiKeyCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (created: ApiKeyCreated) => void
}
export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCreateDialogProps) {
const createMutation = useCreateApiKeyMutation()
const [name, setName] = useState('')
const [role, setRole] = useState<ApiKeyRole>('editor')
const [expiresLocal, setExpiresLocal] = useState('')
useEffect(() => {
if (!open) return
setName('')
setRole('editor')
setExpiresLocal('')
}, [open])
function handleOpenChange(next: boolean) {
onOpenChange(next)
}
async function save() {
if (!name.trim()) {
toast.error('Укажите имя')
return
}
const body: ApiKeyCreate = {
name: name.trim(),
role,
}
if (expiresLocal.trim()) {
const d = new Date(expiresLocal)
if (Number.isNaN(d.getTime())) {
toast.error('Некорректная дата истечения')
return
}
body.expires_at = d.toISOString()
}
try {
const created = await createMutation.mutateAsync(body)
onOpenChange(false)
onCreated(created)
} catch {
// toast handled in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={handleOpenChange}
title="Новый API-ключ"
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Отмена
</Button>
<LoadingButton onClick={save} loading={createMutation.isPending}>
Создать
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="key-name">Имя</Label>
<Input
id="key-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="CI / оператор UI"
/>
</div>
<SelectField
id="key-role"
label="Роль"
items={[...API_KEY_ROLE_ITEMS]}
value={role}
placeholder="Выберите роль"
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="key-expires">Истекает (опционально)</Label>
<Input
id="key-expires"
type="datetime-local"
value={expiresLocal}
onChange={(e) => setExpiresLocal(e.target.value)}
/>
</div>
</FormDrawer>
)
}
@@ -1,51 +0,0 @@
import { Copy } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
interface ApiKeyTokenDialogProps {
open: boolean
token: string
onOpenChange: (open: boolean) => void
}
export function ApiKeyTokenDialog({ open, token, onOpenChange }: ApiKeyTokenDialogProps) {
async function copyToken() {
if (!token) return
try {
await navigator.clipboard.writeText(token)
toast.success('Скопировано')
} catch {
toast.error('Не удалось скопировать')
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Сохраните токен</DialogTitle>
<DialogDescription>
Он больше не будет показан. Скопируйте в безопасное хранилище.
</DialogDescription>
</DialogHeader>
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">{token}</div>
<DialogFooter>
<Button variant="outline" onClick={copyToken}>
<Copy />
Копировать
</Button>
<Button onClick={() => onOpenChange(false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,54 +0,0 @@
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
import { StatusBadge } from '@/components/status-badge'
import type { PlatformActivityItem } from '@/lib/metrics'
const KIND_ICON = {
job: Info,
revision: CheckCircle,
network: AlertTriangle,
} as const
const KIND_ICON_CLASS = {
job: 'text-info',
revision: 'text-success',
network: 'text-warning',
} as const
export function AnalyticsActivityList({
items,
className,
}: {
items: PlatformActivityItem[]
className?: string
}) {
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">Нет недавних событий</p>
}
return (
<ul className={cn('space-y-3', className)}>
{items.map((item) => {
const Icon = KIND_ICON[item.kind]
return (
<li key={item.id} className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<span
className={cn(
'mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted/60',
KIND_ICON_CLASS[item.kind],
)}
>
<Icon className="size-3.5" />
</span>
<p className="text-sm leading-snug">{item.message}</p>
</div>
<StatusBadge status={item.status} label={item.statusLabel} />
</li>
)
})}
</ul>
)
}
@@ -1,62 +0,0 @@
import type { ReactNode } from 'react'
import { Info } from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@evobgp/ui/components/tooltip'
import { PanelCard } from '@/components/panel-card'
export function AnalyticsCardShell({
title,
description,
info,
actions,
footer,
className,
children,
}: {
title: string
description?: string
info?: string
actions?: ReactNode
footer?: ReactNode
className?: string
children: ReactNode
}) {
const titleNode = (
<span className="flex items-center gap-2">
{title}
{info ? (
<Tooltip>
<TooltipTrigger
className="inline-flex text-muted-foreground transition-colors hover:text-foreground"
aria-label="Подробнее"
>
<Info className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs">
{info}
</TooltipContent>
</Tooltip>
) : null}
</span>
)
return (
<PanelCard
title={titleNode}
description={description}
actions={actions}
footer={footer}
className={cn('overflow-hidden', className)}
contentClassName="flex flex-col gap-4 py-4"
footerClassName={footer ? 'gap-2 p-3' : undefined}
>
{children}
</PanelCard>
)
}
@@ -1,50 +0,0 @@
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
export type AnalyticsKpiItem = {
label: string
value: string
delta?: {
direction: 'up' | 'down' | 'neutral'
label: string
tone?: 'success' | 'warning' | 'destructive' | 'muted'
}
}
const TONE_CLASS = {
success: 'text-success',
warning: 'text-warning',
destructive: 'text-destructive',
muted: 'text-muted-foreground',
} as const
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
if (direction === 'up') return <TrendingUp className="size-3" />
if (direction === 'down') return <TrendingDown className="size-3" />
return <Minus className="size-3" />
}
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
return (
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
{items.map((item) => (
<div key={item.label} className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
{item.delta ? (
<p
className={cn(
'flex items-center gap-1 text-xs',
TONE_CLASS[item.delta.tone ?? 'muted'],
)}
>
<DeltaIcon direction={item.delta.direction} />
{item.delta.label}
</p>
) : null}
</div>
))}
</div>
)
}
@@ -1,34 +0,0 @@
import {
Progress,
ProgressIndicator,
ProgressTrack,
} from '@evobgp/ui/components/progress'
import { cn } from '@evobgp/ui/lib/utils'
export function AnalyticsProgress({
label,
hint,
value,
className,
}: {
label: string
hint?: string
value: number
className?: string
}) {
const clamped = Math.max(0, Math.min(100, value))
return (
<div className={cn('space-y-2', className)}>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{clamped}%</span>
</div>
{hint ? <p className="text-xs leading-snug text-muted-foreground">{hint}</p> : null}
<Progress value={clamped} className="w-full gap-0">
<ProgressTrack className="h-2">
<ProgressIndicator />
</ProgressTrack>
</Progress>
</div>
)
}
@@ -1,35 +0,0 @@
import { Button } from '@evobgp/ui/components/button'
import { ButtonGroup } from '@evobgp/ui/components/button-group'
import { cn } from '@evobgp/ui/lib/utils'
export function AnalyticsSegmentControl<T extends string>({
value,
onChange,
options,
className,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string }[]
className?: string
}) {
return (
<ButtonGroup className={cn('rounded-lg bg-muted/50 p-0.5', className)}>
{options.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={value === option.value ? 'secondary' : 'ghost'}
className={cn(
'h-7 rounded-md px-2.5 text-xs',
value === option.value && 'bg-background shadow-sm',
)}
onClick={() => onChange(option.value)}
>
{option.label}
</Button>
))}
</ButtonGroup>
)
}
@@ -1,41 +0,0 @@
import { Bar, BarChart, XAxis } from 'recharts'
import { ChartContainer, type ChartConfig } from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils'
import type { CapacityBar } from '@/lib/metrics'
const chartConfig = {
value: { label: 'Загрузка', color: 'var(--color-chart-2)' },
} satisfies ChartConfig
export function ChartBarStrip({
bars,
className,
}: {
bars: CapacityBar[]
className?: string
}) {
if (bars.length === 0) {
return (
<div className={cn('flex h-36 items-center justify-center text-sm text-muted-foreground', className)}>
Нет данных для графика
</div>
)
}
const data = bars.map((bar, index) => ({
...bar,
slot: index + 1,
fill: bar.value >= 80 ? 'var(--color-chart-2)' : 'var(--color-chart-3)',
}))
return (
<ChartContainer config={chartConfig} className={cn('aspect-auto h-36 w-full', className)}>
<BarChart data={data} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
<XAxis dataKey="slot" hide />
<Bar dataKey="value" radius={[3, 3, 0, 0]} maxBarSize={10} />
</BarChart>
</ChartContainer>
)
}
@@ -1,100 +0,0 @@
import { Cell, Label, Pie, PieChart } from 'recharts'
import {
ChartContainer,
type ChartConfig,
} from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils'
import type { BreakdownSlice } from '@/lib/metrics'
export function ChartDonutMetric({
slices,
centerLabel,
centerValue,
className,
}: {
slices: BreakdownSlice[]
centerLabel: string
centerValue: string | number
className?: string
}) {
const chartConfig = slices.reduce<ChartConfig>((acc, slice) => {
acc[slice.key] = { label: slice.label, color: slice.color }
return acc
}, {})
const data = slices.map((slice) => ({
...slice,
fill: slice.color,
}))
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
if (total === 0) {
return (
<div className={cn('flex h-48 items-center justify-center text-sm text-muted-foreground', className)}>
Нет данных
</div>
)
}
return (
<div className={cn('flex items-center gap-6', className)}>
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
<PieChart>
<Pie
data={data}
dataKey="count"
nameKey="label"
innerRadius={52}
outerRadius={72}
strokeWidth={2}
stroke="var(--color-card)"
>
{data.map((entry) => (
<Cell key={entry.key} fill={entry.fill} />
))}
<Label
content={({ viewBox }) => {
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
const { cx, cy } = viewBox
return (
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={cx} y={(cy ?? 0) - 6} className="fill-muted-foreground text-xs">
{centerLabel}
</tspan>
<tspan x={cx} y={(cy ?? 0) + 14} className="fill-foreground text-xl font-semibold">
{centerValue}
</tspan>
</text>
)
}}
/>
</Pie>
</PieChart>
</ChartContainer>
<ul className="min-w-0 flex-1 space-y-3">
{slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
<div className="flex min-w-0 items-center gap-2">
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: slice.color }}
/>
<span className="truncate text-muted-foreground">{slice.label}</span>
</div>
<div className="shrink-0 text-right tabular-nums">
<span className="font-semibold">{slice.count}</span>
<span className="ml-2 text-muted-foreground">{pct}%</span>
</div>
</li>
)
})}
</ul>
</div>
)
}
@@ -1,107 +0,0 @@
import { useMemo, useState } from 'react'
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
} from '@evobgp/ui/components/avatar'
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { ChartBarStrip } from '@/components/analytics/chart-bar-strip'
import {
capacityUtilization,
peerCapacityBars,
speakerCapacityBars,
} from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview'
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
type CapacityMode = 'peers' | 'speakers'
function speakerInitials(speaker: SpeakerRow): string {
const label = speaker.live?.label ?? speaker.agent_domain ?? speaker.endpoint ?? speaker.id
const parts = label.split(/[.\-_@/]/).filter(Boolean)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return label.slice(0, 2).toUpperCase()
}
export function DashboardNetworkCapacityCard({
peers,
speakers,
jobs,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}) {
const [mode, setMode] = useState<CapacityMode>('peers')
const bars = useMemo(
() => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)),
[mode, peers, speakers],
)
const utilization = capacityUtilization(bars)
const queued = runningJobCount(jobs)
const previewSpeakers = speakers.slice(0, 3)
const deltaLabel =
mode === 'peers'
? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} установлено`
: `${speakers.filter((s) => s.live?.agent_ok).length} в сети`
return (
<AnalyticsCardShell
title="Загрузка BGP"
description="Текущая утилизация сессий по пирам и спикерам"
info="Каждый столбец — включённый пир или спикер. Высота отражает установленную сессию или доступность."
actions={
<AnalyticsSegmentControl
value={mode}
onChange={setMode}
options={[
{ value: 'peers', label: 'Пиры' },
{ value: 'speakers', label: 'Спикеры' },
]}
/>
}
>
<div className="space-y-1">
<p className="text-3xl font-semibold tracking-tight tabular-nums">
{loading ? '—' : `${utilization}%`}
</p>
<p className="text-sm text-success">{loading ? '…' : `${deltaLabel} · снимок live`}</p>
</div>
{loading ? (
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartBarStrip bars={bars} />
)}
<div className="flex items-center justify-between gap-3 text-sm">
<p className="text-muted-foreground">
Активных задач: <span className="font-medium text-foreground">{loading ? '—' : queued}</span>
</p>
<div className="flex items-center gap-2">
<AvatarGroup>
{previewSpeakers.map((speaker) => (
<Avatar key={speaker.id} size="sm">
<AvatarFallback>{speakerInitials(speaker)}</AvatarFallback>
</Avatar>
))}
{speakers.length > 3 ? (
<AvatarGroupCount>+{speakers.length - 3}</AvatarGroupCount>
) : null}
</AvatarGroup>
<span className="text-muted-foreground">{speakers.length} спикеров</span>
</div>
</div>
</AnalyticsCardShell>
)
}
@@ -1,59 +0,0 @@
import { useMemo, useState } from 'react'
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
import type { JobRow, ModuleRow } from '@/types/api'
type FlowMode = 'jobs' | 'modules'
export function DashboardOperationsFlowCard({
jobs,
modules,
loading,
}: {
jobs: JobRow[]
modules: ModuleRow[]
loading?: boolean
}) {
const [mode, setMode] = useState<FlowMode>('jobs')
const slices = useMemo(
() => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)),
[mode, jobs, modules],
)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
return (
<AnalyticsCardShell
title="Поток операций"
description="Распределение фоновых задач и типов модулей"
info="Donut строится по текущей выборке API (до 100 последних задач)."
actions={
<AnalyticsSegmentControl
value={mode}
onChange={setMode}
options={[
{ value: 'jobs', label: 'Задачи' },
{ value: 'modules', label: 'Модули' },
]}
/>
}
>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric
slices={slices}
centerLabel={centerLabel}
centerValue={total}
/>
)}
</AnalyticsCardShell>
)
}
@@ -1,145 +0,0 @@
import { useNavigate } from '@tanstack/react-router'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { AnalyticsActivityList } from '@/components/analytics/analytics-activity-list'
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { AnalyticsProgress } from '@/components/analytics/analytics-progress'
import {
deploymentProgress,
deploymentProgressMeta,
recentPlatformActivity,
} from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview'
import type { JobRow, ModuleRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
export function DashboardPlatformCard({
modules,
peers,
speakers,
jobs,
revisions,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
revisions: RevisionRow[]
loading?: boolean
}) {
const navigate = useNavigate()
const enabledModules = modules.filter((m) => m.enabled !== false).length
const peersEnabled = peers.filter((p) => p.enabled !== false).length
const peersEstablished = peers.filter(
(p) => p.enabled !== false && p.session_state === 'Established',
).length
const peersMismatch = peers.filter((p) => p.session_mismatch).length
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const running = runningJobCount(jobs)
const riskCount = peersMismatch + failedJobs + Math.max(0, speakers.length - speakersOnline)
const bgpPct =
peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null
const deploy = useMemo(() => deploymentProgress(speakers), [speakers])
const deployMeta = useMemo(() => deploymentProgressMeta(deploy), [deploy])
const activity = useMemo(
() => recentPlatformActivity(jobs, revisions, peers, speakers),
[jobs, revisions, peers, speakers],
)
const kpis = [
{
label: 'Модули активны',
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
delta: {
direction: 'neutral' as const,
label: `${modules.length} всего`,
tone: 'muted' as const,
},
},
{
label: 'BGP готовность',
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
delta: {
direction: (bgpPct !== null && bgpPct >= 90 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as
| 'up'
| 'down'
| 'neutral',
label:
bgpPct === null
? 'нет включённых пиров'
: `${peersEstablished} установлено`,
tone: (bgpPct !== null && bgpPct >= 90
? 'success'
: bgpPct !== null && bgpPct < 70
? 'warning'
: 'muted') as 'success' | 'warning' | 'muted',
},
},
{
label: 'Риски',
value: loading ? '—' : String(riskCount),
delta: {
direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down',
label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} расхождений` : 'в норме',
tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success',
},
},
]
const progressLabel = deployMeta.label
const progressHint = deployMeta.hint
return (
<AnalyticsCardShell
title="Состояние платформы"
description="Сводка модулей, BGP и фоновых задач"
info="Актуальный снимок без исторических трендов. Обновите данные кнопкой «Обновить» на странице."
footer={
<>
<Button
variant="outline"
className="flex-1"
onClick={() => navigate({ to: '/schedule' })}
>
Расписание
</Button>
<Button
className="flex-1"
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
>
Мониторинг
</Button>
</>
}
>
<AnalyticsKpiRow items={kpis} />
<AnalyticsProgress
label={progressLabel}
hint={progressHint}
value={loading ? 0 : deploy.percent}
/>
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Недавняя активность</span>
{!loading ? (
<span className="text-xs text-muted-foreground">{running} активных задач</span>
) : null}
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Загрузка</p>
) : (
<AnalyticsActivityList items={activity} />
)}
</div>
</AnalyticsCardShell>
)
}
@@ -1,13 +0,0 @@
export { AnalyticsActivityList } from './analytics-activity-list'
export { AnalyticsCardShell } from './analytics-card-shell'
export { AnalyticsKpiRow, type AnalyticsKpiItem } from './analytics-kpi-row'
export { AnalyticsProgress } from './analytics-progress'
export { AnalyticsSegmentControl } from './analytics-segment-control'
export { ChartBarStrip } from './chart-bar-strip'
export { ChartDonutMetric } from './chart-donut-metric'
export { DashboardNetworkCapacityCard } from './dashboard-network-capacity-card'
export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
export { DashboardPlatformCard } from './dashboard-platform-card'
export { MonitoringHealthCard } from './monitoring-health-card'
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
export { OperationsAnalyticsCard } from './operations-analytics-card'
@@ -1,33 +0,0 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { readinessBreakdown } from '@/lib/metrics'
import type { ReadyStatus } from '@/queries/monitoring'
export function MonitoringHealthCard({
healthOk,
ready,
loading,
}: {
healthOk: boolean
ready: ReadyStatus | null | undefined
loading?: boolean
}) {
const slices = readinessBreakdown(ready, healthOk)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Доступность системы"
description="Проверки живучести и готовности"
info="Диаграмма отражает результат GET /v1/health и проверок из GET /v1/ready."
>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Проверки" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
@@ -1,66 +0,0 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { peerSessionBreakdown } from '@/lib/metrics'
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { PeerRow, SpeakerRow } from '@/types/api'
export function NetworkOverviewAnalyticsCard({
peers,
speakers,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
loading?: boolean
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const slices = peerSessionBreakdown(peers)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Сводка BGP"
description="Установленные сессии, доступность спикеров и расхождения по live-данным"
info="Снимок текущего состояния пиров и спикеров."
>
<AnalyticsKpiRow
items={[
{
label: 'Пиры с установленной сессией',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
delta: {
direction: net.peersMismatch > 0 ? 'down' : 'up',
label: net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'сессии в норме',
tone: net.peersMismatch > 0 ? 'warning' : 'success',
},
},
{
label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
delta: {
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
label:
net.speakersOnline < net.speakersTotal
? `${net.speakersTotal - net.speakersOnline} не в сети`
: 'все в сети',
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
},
},
{
label: 'Пиры всего',
value: loading ? '—' : String(net.peersTotal),
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
},
]}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
@@ -1,65 +0,0 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { jobStatusBreakdown } from '@/lib/metrics'
import type { JobRow, RevisionRow } from '@/types/api'
export function OperationsAnalyticsCard({
jobs,
revisions,
loading,
}: {
jobs: JobRow[]
revisions: RevisionRow[]
loading?: boolean
}) {
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const slices = jobStatusBreakdown(jobs)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Операции и задачи"
description="Статистика ревизий и фоновых jobs"
info="Данные из GET /v1/jobs и /v1/revisions."
>
<AnalyticsKpiRow
items={[
{
label: 'Ревизий',
value: loading ? '—' : String(revisions.length),
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
},
{
label: 'Активных задач',
value: loading ? '—' : String(running),
delta: {
direction: running > 0 ? 'up' : 'neutral',
label: running > 0 ? 'выполняются' : 'очередь пуста',
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
},
},
{
label: 'С ошибкой',
value: loading ? '—' : String(failed),
delta: {
direction: failed > 0 ? 'down' : 'up',
label: failed > 0 ? 'требуют внимания' : 'в норме',
tone: failed > 0 ? 'destructive' : 'success',
},
},
]}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
-69
View File
@@ -1,69 +0,0 @@
import type { ComponentProps, ReactNode } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
export type BadgeTabItem = {
value: string
label: string
count?: number
badgeVariant?: ComponentProps<typeof Badge>['variant']
icon?: ReactNode
}
interface BadgeTabsProps {
items: BadgeTabItem[]
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
children: ReactNode
className?: string
listClassName?: string
contentClassName?: string
}
/** Underline tabs with optional badge counts (ReUI c-tabs-7 pattern). */
export function BadgeTabs({
items,
value,
defaultValue,
onValueChange,
children,
className,
listClassName,
contentClassName,
}: BadgeTabsProps) {
return (
<Tabs
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
className={cn('w-full', className)}
>
<TabsList
variant="line"
className={cn(
'mb-4 w-full justify-start gap-6',
listClassName,
)}
>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2">
{item.icon}
{item.label}
{item.count !== undefined ? (
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
{item.count}
</Badge>
) : null}
</TabsTrigger>
))}
</TabsList>
<div className={cn('w-full min-w-0', contentClassName)}>{children}</div>
</Tabs>
)
}
export { TabsContent }
@@ -1,50 +0,0 @@
import type { ComponentProps, ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const TONE_VARIANT: Record<string, BadgeVariant> = {
neutral: 'outline',
info: 'info-light',
warning: 'warning-light',
success: 'success-light',
}
export function ModeBadge({
enabled,
onLabel = 'включён',
offLabel = 'выключен',
className,
}: {
enabled: boolean
onLabel?: string
offLabel?: string
className?: string
}) {
return enabled ? (
<Badge variant="success-light" size="sm" radius="full" className={className}>
{onLabel}
</Badge>
) : (
<Badge variant="secondary" size="sm" radius="full" className={className}>
{offLabel}
</Badge>
)
}
export function CategoryBadge({
children,
tone = 'neutral',
className,
}: {
children: ReactNode
tone?: keyof typeof TONE_VARIANT
className?: string
}) {
return (
<Badge variant={TONE_VARIANT[tone]} size="sm" radius="full" className={className}>
{children}
</Badge>
)
}
-130
View File
@@ -1,130 +0,0 @@
import { Button } from '@evobgp/ui/components/button'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@evobgp/ui/components/drawer'
import type { ReactElement, ReactNode } from 'react'
import {
confirmDrawerContentClassName,
DrawerActionsFooter,
} from '@/components/drawer-layout'
type ConfirmDialogBaseProps = {
title: string
description?: ReactNode
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
onConfirm: () => void
confirmDisabled?: boolean
confirmLoading?: boolean
confirmLoadingLabel?: string
}
type ConfirmDialogWithTrigger = ConfirmDialogBaseProps & {
trigger: ReactElement
open?: never
onOpenChange?: never
}
type ConfirmDialogControlled = ConfirmDialogBaseProps & {
trigger?: never
open: boolean
onOpenChange: (open: boolean) => void
}
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
function ConfirmDrawerBody({
title,
description,
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
destructive,
onConfirm,
confirmDisabled,
confirmLoading,
confirmLoadingLabel,
controlled,
}: ConfirmDialogBaseProps & { controlled?: boolean }) {
const confirmText =
confirmLoading && confirmLoadingLabel
? confirmLoadingLabel
: confirmLoading
? `${confirmLabel}`
: confirmLabel
return (
<>
<DrawerHeader className="shrink-0 border-b border-border pb-4">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<DrawerActionsFooter>
<DrawerClose render={<Button variant="outline" disabled={confirmLoading} />}>
{cancelLabel}
</DrawerClose>
{controlled ? (
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
onClick={onConfirm}
>
{confirmText}
</Button>
) : (
<DrawerClose
render={
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
/>
}
onClick={onConfirm}
>
{confirmText}
</DrawerClose>
)}
</DrawerActionsFooter>
</>
)
}
export function ConfirmDialog(props: ConfirmDialogProps) {
const bodyProps: ConfirmDialogBaseProps = {
title: props.title,
description: props.description,
confirmLabel: props.confirmLabel,
cancelLabel: props.cancelLabel,
destructive: props.destructive,
onConfirm: props.onConfirm,
confirmDisabled: props.confirmDisabled,
confirmLoading: props.confirmLoading,
confirmLoadingLabel: props.confirmLoadingLabel,
}
if (props.trigger) {
return (
<Drawer swipeDirection="right">
<DrawerTrigger render={props.trigger} />
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} />
</DrawerContent>
</Drawer>
)
}
return (
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} controlled />
</DrawerContent>
</Drawer>
)
}
@@ -1,38 +0,0 @@
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { PeerRow, SpeakerRow } from '@/types/api'
function Row({ label, value, variant = 'default' }: { label: string; value: string; variant?: 'default' | 'warning' }) {
return (
<div className="flex items-center justify-between gap-2 px-1 text-sm">
<span className="text-muted-foreground">{label}</span>
<span
className={
variant === 'warning'
? 'font-medium text-warning-foreground tabular-nums'
: 'font-medium tabular-nums'
}
>
{value}
</span>
</div>
)
}
export function DashboardNetworkPanel({
peers,
speakers,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
}) {
const m = aggregateNetworkMetrics(peers, speakers)
return (
<div className="flex flex-col gap-2 p-3">
<Row label="Пиры с установленной сессией" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
<Row label="Спикеры в сети" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
{m.peersMismatch > 0 ? (
<Row label="Расхождения сессий" value={String(m.peersMismatch)} variant="warning" />
) : null}
</div>
)
}
@@ -1,40 +0,0 @@
import { Link } from '@tanstack/react-router'
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
export function DashboardQuickActions() {
return (
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
<Plus className="size-4" />
Создать модуль
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
<Tags className="size-4" />
Добавить BGP-сообщество
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'overview' }} />}>
<Network className="size-4" />
Сеть
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'peers' }} />}>
<Share2 className="size-4" />
Добавить пира
</Button>
<Button
variant="outline"
size="sm"
type="button"
render={<Link to="/operations" search={{ tab: 'revisions' }} />}
>
<Play className="size-4" />
Деплой
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}>
<Gauge className="size-4" />
Мониторинг
</Button>
</div>
)
}
@@ -1,78 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { jobKindRu } from '@/lib/ui-labels'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { JobRow } from '@/types/api'
export function DashboardRecentJobsGrid({
jobs,
nameById,
isLoading = false,
}: {
jobs: JobRow[]
nameById: Map<string, string>
isLoading?: boolean
}) {
const data = useMemo(() => jobs.slice(0, 8), [jobs])
const columns = useMemo<ColumnDef<JobRow>[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={jobKindRu(row.original.kind)}
accent="mono"
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
: undefined
}
/>
),
meta: { headerTitle: 'Вид' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
],
[nameById],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => {
const moduleName = row.meta?.module_id
? (nameById.get(String(row.meta.module_id)) ?? '')
: ''
return `${jobKindRu(row.kind)} ${row.status} ${moduleName}`
},
getRowId: (row) => row.job_id,
pageSize: 8,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет задач"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/>
)
}
@@ -1,66 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { RevisionRow } from '@/types/api'
export function DashboardRecentRevisionsGrid({
revisions,
isLoading = false,
}: {
revisions: RevisionRow[]
isLoading?: boolean
}) {
const data = useMemo(() => revisions.slice(0, 8), [revisions])
const columns = useMemo<ColumnDef<RevisionRow>[]>(
() => [
{
id: 'id',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={`${row.original.id.slice(0, 10)}`} accent="mono" />
),
meta: { headerTitle: 'ID' },
},
{
accessorKey: 'created_at',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<DataGridMutedCell>
{new Date(row.original.created_at).toLocaleString('ru-RU')}
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => row.id,
getRowId: (row) => row.id,
pageSize: 8,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ревизий"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/>
)
}
@@ -1,44 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
const ACCENT_CLASS = {
primary: 'font-medium text-primary',
default: 'font-medium text-foreground',
mono: 'font-mono text-sm text-primary',
} as const
export function DataGridPrimaryCell({
title,
subtitle,
accent = 'default',
className,
}: {
title: ReactNode
subtitle?: ReactNode
accent?: keyof typeof ACCENT_CLASS
className?: string
}) {
return (
<div className={cn('flex min-w-0 flex-col gap-0.5', className)}>
<span className={cn('truncate', ACCENT_CLASS[accent])}>{title}</span>
{subtitle ? (
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
) : null}
</div>
)
}
export function DataGridMutedCell({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<span className={cn('whitespace-nowrap text-xs text-muted-foreground', className)}>
{children}
</span>
)
}
-114
View File
@@ -1,114 +0,0 @@
import type { ReactNode } from 'react'
import type { Table } from '@tanstack/react-table'
import { cn } from '@evobgp/ui/lib/utils'
import { DataGridToolbar } from '@/components/data-grid-toolbar'
import { PanelCard, panelCardContentFlushClassName, panelCardFooterClassName } from '@/components/panel-card'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
DATA_GRID_MESSAGES_RU,
DATA_GRID_PAGINATION_RU,
DATA_GRID_TABLE_LAYOUT,
} from '@/lib/data-grid-defaults'
interface DataGridShellProps<TData extends object> {
table: Table<TData>
recordCount: number
isLoading?: boolean
emptyMessage?: ReactNode
showPagination?: boolean
tableLayout?: typeof DATA_GRID_TABLE_LAYOUT
className?: string
onRowClick?: (row: TData) => void
}
export function DataGridShell<TData extends object>({
table,
recordCount,
isLoading = false,
emptyMessage,
showPagination = true,
tableLayout = DATA_GRID_TABLE_LAYOUT,
className,
onRowClick,
}: DataGridShellProps<TData>) {
return (
<DataGrid
table={table}
recordCount={recordCount}
isLoading={isLoading}
emptyMessage={emptyMessage ?? DATA_GRID_MESSAGES_RU.emptyMessage}
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
tableLayout={tableLayout}
className={className}
onRowClick={onRowClick}
>
<DataGridContainer>
<DataGridTable />
</DataGridContainer>
{showPagination ? (
<div className={cn(panelCardFooterClassName, 'px-3 py-2')}>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
</div>
) : null}
</DataGrid>
)
}
interface DataGridCardProps {
title?: string
description?: string
actions?: ReactNode
children: ReactNode
className?: string
}
export function DataGridCard({ title, description, actions, children, className }: DataGridCardProps) {
return (
<PanelCard
title={title}
description={description}
actions={actions}
className={className}
contentClassName={panelCardContentFlushClassName}
>
{children}
</PanelCard>
)
}
interface DataGridSectionProps<TData extends object> extends DataGridShellProps<TData> {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
toolbarFilters?: ReactNode
toolbarActions?: ReactNode
beforeGrid?: ReactNode
}
export function DataGridSection<TData extends object>({
searchValue,
onSearchChange,
searchPlaceholder,
toolbarFilters,
toolbarActions,
beforeGrid,
...shellProps
}: DataGridSectionProps<TData>) {
return (
<>
<DataGridToolbar
searchValue={searchValue}
onSearchChange={onSearchChange}
searchPlaceholder={searchPlaceholder}
filters={toolbarFilters}
actions={toolbarActions}
/>
{beforeGrid}
<DataGridShell {...shellProps} />
</>
)
}
@@ -1,73 +0,0 @@
import type { ReactNode } from 'react'
import { Field } from '@evobgp/ui/components/field'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { ListFilterIcon, SearchIcon, XIcon } from 'lucide-react'
interface DataGridToolbarProps {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
filters?: ReactNode
actions?: ReactNode
className?: string
}
/** Search row for data grids (ReUI c-input-group-37 pattern). */
export function DataGridToolbar({
searchValue,
onSearchChange,
searchPlaceholder = 'Поиск…',
filters,
actions,
className,
}: DataGridToolbarProps) {
return (
<div className={`flex flex-wrap items-center gap-2 border-b px-3 py-2 ${className ?? ''}`}>
<Field className="min-w-[200px] flex-1">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder={searchPlaceholder}
value={searchValue}
onChange={(event) => onSearchChange(event.target.value)}
aria-label={searchPlaceholder}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchValue.length > 0 ? (
<InputGroupButton
aria-label="Очистить поиск"
size="icon-xs"
variant="ghost"
onClick={() => onSearchChange('')}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
{filters ? (
filters
) : (
<InputGroupButton
variant="ghost"
size="xs"
className="pointer-events-none gap-1.5 opacity-0"
aria-hidden="true"
tabIndex={-1}
>
<ListFilterIcon className="size-3.5" />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</Field>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
)
}
@@ -1,63 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { BgpCommunity } from '@/types/api'
export function DirectoriesCommunitiesGrid({
items,
isLoading = false,
}: {
items: BgpCommunity[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<BgpCommunity>[]>(
() => [
{
accessorKey: 'title',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.title} accent="primary" />,
meta: { headerTitle: 'Название' },
},
{
accessorKey: 'community',
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.community} accent="mono" />
),
meta: { headerTitle: 'Значение' },
},
{
id: 'type',
enableSorting: false,
header: 'Тип',
cell: () => <CategoryBadge>community</CategoryBadge>,
meta: { headerTitle: 'Тип' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.title} ${row.community}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет сообществ"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск сообществ…"
/>
)
}
@@ -1,68 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { DohProfile } from '@/types/api'
export function DirectoriesDohGrid({
items,
isLoading = false,
}: {
items: DohProfile[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<DohProfile>[]>(
() => [
{
id: 'name',
accessorFn: (row) => row.name ?? row.url,
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.url}
subtitle={row.original.name ? row.original.url : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Название' },
},
{
accessorKey: 'url',
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.url} accent="mono" />,
meta: { headerTitle: 'URL' },
},
{
id: 'default',
enableSorting: false,
header: 'По умолчанию',
cell: () => <CategoryBadge></CategoryBadge>,
meta: { headerTitle: 'По умолчанию' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.name ?? ''} ${row.url}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет DoH профилей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск DoH профилей…"
/>
)
}
-31
View File
@@ -1,31 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import { DrawerFooter } from '@evobgp/ui/components/drawer'
/** Shared footer layout for right-side form and confirm drawers. */
export function DrawerActionsFooter({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<DrawerFooter
className={cn(
'mt-0 shrink-0 border-t border-border bg-muted/50 p-4',
className,
)}
>
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{children}
</div>
</DrawerFooter>
)
}
export const formDrawerContentClassName =
'flex h-full max-h-dvh flex-col sm:max-w-lg'
export const confirmDrawerContentClassName = 'flex h-auto max-h-dvh flex-col sm:max-w-sm'
-28
View File
@@ -1,28 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
interface EmptyStateProps {
title: string
description?: string
icon?: ReactNode
action?: ReactNode
className?: string
}
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
className,
)}
>
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}
@@ -1,107 +0,0 @@
"use client"
import { useState } from "react"
import { Checkbox } from "@evobgp/ui/components/checkbox"
import { Field } from "@evobgp/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { Label } from "@evobgp/ui/components/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
const statuses = ["Pending", "Shipped", "Cancelled"] as const
type Status = (typeof statuses)[number]
function toggleStatus(values: Status[], value: Status) {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value]
}
export function Pattern() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
return (
<Field className="max-w-sm">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search orders..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchQuery.length > 0 ? (
<InputGroupButton
aria-label="Clear search"
size="icon-xs"
variant="ghost"
onClick={() => setSearchQuery("")}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
<Popover>
<PopoverTrigger
render={
<InputGroupButton
variant="ghost"
size="xs"
className="gap-1.5"
aria-label="Filter order status"
/>
}
>
<ListFilterIcon className="size-3.5" aria-hidden="true" />
Status
{selectedStatuses.length > 0 ? (
<span className="text-muted-foreground tabular-nums">
{selectedStatuses.length}
</span>
) : null}
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-3">
<div className="flex flex-col gap-2">
{statuses.map((status) => (
<div key={status} className="flex items-center gap-2.5">
<Checkbox
id={`order-status-${status}`}
checked={selectedStatuses.includes(status)}
onCheckedChange={() =>
setSelectedStatuses((previous) =>
toggleStatus(previous, status)
)
}
/>
<Label
htmlFor={`order-status-${status}`}
className="text-sm font-normal"
>
{status}
</Label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</InputGroupAddon>
</InputGroup>
</Field>
)
}
@@ -1,19 +0,0 @@
import { Field } from '@evobgp/ui/components/field'
import { SelectMenu } from '@/components/select-field'
const items = [
{ label: 'Select an item', value: 'placeholder' as const },
...Array.from({ length: 100 }).map((_, i) => ({
label: `Item ${i}`,
value: `item-${i}` as const,
})),
]
export function Pattern() {
return (
<Field className="max-w-xs">
<SelectMenu items={items} placeholder="Select an item" />
</Field>
)
}
@@ -1,46 +0,0 @@
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="overview">
<TabsList className="w-full">
<TabsTrigger value="overview">
<LayoutDashboardIcon className="size-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics">
<BarChart3Icon className="size-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="settings">
<SettingsIcon className="size-4" />
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Card>
<CardContent>Overview dashboard content goes here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics">
<Card>
<CardContent>Analytics charts and metrics.</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardContent>Application settings and preferences.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,61 +0,0 @@
import { Badge } from "@/components/reui/badge"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="inbox">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="inbox" className="gap-2">
Inbox
<Badge variant="primary-light" size="sm">
12
</Badge>
</TabsTrigger>
<TabsTrigger value="drafts" className="gap-2">
Drafts
<Badge variant="info-light" size="sm">
3
</Badge>
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
Sent
</TabsTrigger>
<TabsTrigger value="spam" className="gap-2">
Spam
<Badge variant="destructive-light" size="sm">
24
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="inbox">
<Card>
<CardContent>12 unread messages in your inbox.</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts">
<Card>
<CardContent>3 drafts waiting to be sent.</CardContent>
</Card>
</TabsContent>
<TabsContent value="sent">
<Card>
<CardContent>All sent messages appear here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="spam">
<Card>
<CardContent>24 spam messages detected.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,195 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { FirewallClient } from '@/types/api'
function formatPacketCount(value?: number | null): string | null {
if (value == null || value <= 0) return null
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
return String(value)
}
export interface FirewallClientsGridProps {
clients: FirewallClient[]
isLoading?: boolean
onApprove: (id: string) => void
onReject: (id: string) => void
approvePending?: boolean
rejectPending?: boolean
emptyTitle?: string
}
export function FirewallClientsGrid({
clients,
isLoading = false,
onApprove,
onReject,
approvePending = false,
rejectPending = false,
emptyTitle = 'Нет клиентов',
}: FirewallClientsGridProps) {
const columns = useMemo<ColumnDef<FirewallClient>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name}
subtitle={row.original.hostname || row.original.token_prefix}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
{
id: 'last_seen_at',
accessorFn: (row) => row.last_seen_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последняя активность" />,
cell: ({ row }) => (
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
),
sortingFn: (a, b) => {
const av = a.original.last_seen_at ?? ''
const bv = b.original.last_seen_at ?? ''
return av.localeCompare(bv)
},
meta: { headerTitle: 'Последняя активность' },
},
{
id: 'apply',
enableSorting: false,
header: 'Применение',
cell: ({ row }) => {
const c = row.original
return (
<span className="text-xs">
{c.last_apply_status ?? '—'}
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</span>
)
},
meta: { headerTitle: 'Применение' },
},
{
id: 'packets',
enableSorting: false,
header: 'Пакеты',
cell: ({ row }) => {
const dropped = formatPacketCount(row.original.last_apply_packets_dropped)
const accepted = formatPacketCount(row.original.last_apply_packets_accepted)
if (!dropped && !accepted) {
return <span className="text-muted-foreground text-xs"></span>
}
return (
<span className="text-muted-foreground text-xs">
{dropped ? <span className="text-destructive">{dropped}</span> : null}
{dropped && accepted ? ' · ' : null}
{accepted ? <span className="text-success">{accepted}</span> : null}
</span>
)
},
meta: { headerTitle: 'Пакеты' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const c = row.original
return (
<div className="flex justify-end gap-2">
{c.status === 'pending' ? (
<>
<Button
size="sm"
variant="outline"
type="button"
disabled={approvePending}
onClick={() => onApprove(c.id)}
>
Одобрить
</Button>
<ConfirmDialog
trigger={
<Button
size="sm"
variant="outline"
type="button"
className="text-destructive"
disabled={rejectPending}
>
Отклонить
</Button>
}
title="Отклонить запрос?"
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
confirmLabel="Отклонить"
destructive
onConfirm={() => onReject(c.id)}
/>
</>
) : null}
{c.status === 'approved' ? (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
type="button"
className="text-destructive"
disabled={rejectPending}
>
Удалить
</Button>
}
title="Удалить клиент?"
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
confirmLabel="Удалить"
destructive
onConfirm={() => onReject(c.id)}
/>
) : null}
</div>
)
},
},
],
[approvePending, onApprove, onReject, rejectPending],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: clients,
columns,
getSearchText: (row) =>
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск клиентов…"
/>
)
}
@@ -1,101 +0,0 @@
import { useEffect, useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import { useCreateFirewallRule } from '@/queries/firewall'
import type { BgpCommunity } from '@/types/api'
const FIREWALL_ACTION_ITEMS = [
{ value: 'block', label: 'block' },
{ value: 'accept', label: 'accept' },
] as const
interface FirewallRuleCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
communities: BgpCommunity[]
}
export function FirewallRuleCreateDialog({
open,
onOpenChange,
communities,
}: FirewallRuleCreateDialogProps) {
const createMutation = useCreateFirewallRule()
const [action, setAction] = useState<'block' | 'accept'>('block')
const [communityId, setCommunityId] = useState<string | null>(null)
const [comment, setComment] = useState('')
useEffect(() => {
if (!open) return
setAction('block')
setCommunityId(null)
setComment('')
}, [open])
async function save() {
try {
await createMutation.mutateAsync({
scope: 'tenant',
action,
community_id: communityId,
comment: comment.trim(),
})
onOpenChange(false)
} catch {
// toast handled in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новое правило"
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" onClick={save} loading={createMutation.isPending}>
Добавить
</LoadingButton>
</>
}
>
<SelectField
id="fw-rule-action"
label="Действие"
items={[...FIREWALL_ACTION_ITEMS]}
value={action}
placeholder="Выберите действие"
onValueChange={(v) => v && setAction(v as 'block' | 'accept')}
/>
<CommunitySelect
id="fw-rule-community"
label="Community"
value={communityId}
onValueChange={setCommunityId}
communities={communities}
nullable
placeholder="Все communities"
/>
<div className="flex flex-col gap-2">
<Label htmlFor="fw-rule-comment">Комментарий</Label>
<Input
id="fw-rule-comment"
placeholder="Комментарий"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
</FormDrawer>
)
}
@@ -1,122 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { communityLabel } from '@/lib/modules/helpers'
import type { BgpCommunity, FirewallRule } from '@/types/api'
export interface FirewallRulesGridProps {
rules: FirewallRule[]
communities: BgpCommunity[]
isLoading?: boolean
onDelete: (id: string) => void
deletePending?: boolean
emptyTitle?: string
}
export function FirewallRulesGrid({
rules,
communities,
isLoading = false,
onDelete,
deletePending = false,
emptyTitle = 'Нет правил — blocklist пуст (default accept).',
}: FirewallRulesGridProps) {
const columns = useMemo<ColumnDef<FirewallRule>[]>(
() => [
{
accessorKey: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
cell: ({ row }) => row.original.priority,
meta: { headerTitle: '#' },
},
{
accessorKey: 'action',
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
cell: ({ row }) => (
<StatusBadge status={row.original.action} label={row.original.action} />
),
meta: { headerTitle: 'Действие' },
},
{
id: 'community',
enableSorting: false,
header: 'Community',
cell: ({ row }) => (
<span className="text-sm">
{row.original.community_id
? communityLabel(row.original.community_id, communities)
: 'Все'}
</span>
),
meta: { headerTitle: 'Community' },
},
{
accessorKey: 'comment',
enableSorting: false,
header: 'Комментарий',
cell: ({ row }) => row.original.comment || '—',
meta: { headerTitle: 'Комментарий' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const r = row.original
return (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
type="button"
className="text-destructive"
disabled={deletePending}
>
Удалить
</Button>
}
title="Удалить правило?"
description={
r.comment
? `Правило #${r.priority} (${r.action}): ${r.comment}`
: `Правило #${r.priority} (${r.action}) будет удалено.`
}
confirmLabel="Удалить"
destructive
onConfirm={() => onDelete(r.id)}
/>
)
},
},
],
[communities, deletePending, onDelete],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: rules,
columns,
getSearchText: (row) =>
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск правил…"
/>
)
}
-51
View File
@@ -1,51 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@evobgp/ui/components/drawer'
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
import {
DrawerActionsFooter,
formDrawerContentClassName,
} from '@/components/drawer-layout'
interface FormDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
children: ReactNode
footer: ReactNode
className?: string
}
export function FormDrawer({
open,
onOpenChange,
title,
description,
children,
footer,
className,
}: FormDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
<DrawerContent className={cn(formDrawerContentClassName, className)}>
<DrawerHeader className="shrink-0 border-b border-border pb-4">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 px-4 py-4">{children}</div>
</ScrollArea>
<DrawerActionsFooter>{footer}</DrawerActionsFooter>
</DrawerContent>
</Drawer>
)
}
@@ -1,174 +0,0 @@
import {
LayoutDashboard,
Boxes,
Network,
Cog,
ListChecks,
Activity,
Settings,
BookText,
KeyRound,
ServerCog,
Shield,
} from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@evobgp/ui/components/sidebar'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@evobgp/ui/components/breadcrumb'
import { Separator } from '@evobgp/ui/components/separator'
import { Link, useRouterState } from '@tanstack/react-router'
import type { ComponentType, ReactNode } from 'react'
import { ModeToggle } from '@/components/mode-toggle'
interface NavItem {
to: string
label: string
icon: ComponentType<{ className?: string }>
}
interface NavGroup {
label: string
items: NavItem[]
}
const NAV_GROUPS: NavGroup[] = [
{
label: 'Обзор',
items: [{ to: '/dashboard', label: 'Панель', icon: LayoutDashboard }],
},
{
label: 'Маршрутизация',
items: [
{ to: '/modules', label: 'Модули', icon: Boxes },
{ to: '/network', label: 'Сеть', icon: Network },
{ to: '/directories', label: 'Справочники', icon: BookText },
],
},
{
label: 'Операции',
items: [
{ to: '/operations', label: 'Операции', icon: Cog },
{ to: '/firewall', label: 'Файрвол', icon: Shield },
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
],
},
{
label: 'Система',
items: [
{ to: '/access', label: 'Доступ', icon: KeyRound },
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog },
{ to: '/settings', label: 'Настройки UI', icon: Settings },
],
},
]
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
)
const PARENT_ROUTE: Record<string, string> = {}
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const activeItem =
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
ALL_NAV_ITEMS[0]
const parentTo = PARENT_ROUTE[activeItem.to]
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
return (
<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarHeader>
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
B
</div>
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">EvoBGP</span>
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
</div>
</div>
</SidebarHeader>
<SidebarContent>
{NAV_GROUPS.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter />
</Sidebar>
<SidebarInset>
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
{parentLabel && parentTo ? (
<>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
</>
) : null}
<BreadcrumbItem>
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? activeItem.label}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<ModeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
</SidebarInset>
</SidebarProvider>
)
}
@@ -1,19 +0,0 @@
import { Button } from '@evobgp/ui/components/button'
import { Loader2Icon } from 'lucide-react'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
loading?: boolean
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
children: ReactNode
}
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
return (
<Button disabled={disabled || loading} {...props}>
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
{children}
</Button>
)
}
-29
View File
@@ -1,29 +0,0 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Сменить тему</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>Светлая</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Тёмная</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>Системная</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -1,69 +0,0 @@
import { useMemo } from 'react'
import { Field, FieldLabel } from '@evobgp/ui/components/field'
import { SelectMenu } from '@/components/select-field'
import {
NONE_OPTION,
communityOptionLabel,
fromNullableSelect,
nullableSelectValue,
} from '@/lib/modules/helpers'
import type { BgpCommunity } from '@/types/api'
interface CommunitySelectProps {
id?: string
label?: string
value: string | null
onValueChange: (value: string | null) => void
communities: BgpCommunity[]
nullable?: boolean
placeholder?: string
}
export function CommunitySelect({
id,
label,
value,
onValueChange,
communities,
nullable = false,
placeholder = 'Выберите community',
}: CommunitySelectProps) {
const items = useMemo(() => {
const communityItems = communities.map((c) => ({
value: c.id,
label: communityOptionLabel(c),
}))
if (nullable) {
return [{ value: NONE_OPTION, label: 'Не выбрано' }, ...communityItems]
}
return communityItems
}, [communities, nullable])
const selectValue = nullable ? nullableSelectValue(value) : (value ?? '')
const select = (
<SelectMenu
id={id}
items={items}
value={selectValue}
placeholder={placeholder}
onValueChange={(v) => {
if (!v) return
onValueChange(nullable ? fromNullableSelect(v) : v)
}}
/>
)
if (!label) {
return select
}
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
{select}
</Field>
)
}
@@ -1,107 +0,0 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '@/types/api'
interface ModuleAsEntryDialogProps {
open: boolean
moduleId: string
edit: AsEntry | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
export function ModuleAsEntryDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleAsEntryDialogProps) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<AsEntryCreate>({ asn: 0, community_id: null })
useEffect(() => {
if (!open) return
setForm(
edit
? { asn: edit.asn, community_id: edit.community_id }
: { asn: 0, community_id: null },
)
}, [open, edit])
async function save() {
const asn = Number(form.asn)
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
toast.error('Укажите корректный ASN (14294967295)')
return
}
setSaving(true)
try {
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id }
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body)
toast.success('Запись обновлена')
} else {
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate)
toast.success('Запись добавлена')
}
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={edit ? 'Редактировать запись' : 'Новая AS-запись'}
description="Номер автономной системы и community для политики анонса."
className="sm:max-w-sm"
footer={
<>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
value={form.asn || ''}
min={1}
max={4294967295}
onChange={(e) => setForm((s) => ({ ...s, asn: Number(e.target.value) }))}
/>
</div>
<CommunitySelect
id="as-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</FormDrawer>
)
}
@@ -1,253 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import { ApiError, apiMutate } from '@/lib/api-client'
import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
const CDN_KIND_ITEMS = [
{ value: 'plaintext', label: 'plaintext' },
{ value: 'json', label: 'json' },
] as const
interface ModuleCdnSourceDialogProps {
open: boolean
moduleId: string
edit: CdnSource | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
type CdnForm = CdnSourceCreate & { refresh_interval_sec?: number | null }
export function ModuleCdnSourceDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleCdnSourceDialogProps) {
const [saving, setSaving] = useState(false)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewItems, setPreviewItems] = useState<string[]>([])
const [previewTotal, setPreviewTotal] = useState(0)
const [previewTruncated, setPreviewTruncated] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [previewOk, setPreviewOk] = useState(false)
const [form, setForm] = useState<CdnForm>({
url: '',
source_kind: 'plaintext',
prefix_path: '',
community_id: null,
})
const kindItems = useMemo(() => [...CDN_KIND_ITEMS], [])
function clearPreview() {
setPreviewLoading(false)
setPreviewItems([])
setPreviewTotal(0)
setPreviewTruncated(false)
setPreviewError(null)
setPreviewOk(false)
}
useEffect(() => {
if (!open) {
clearPreview()
return
}
setForm(
edit
? {
url: edit.url,
source_kind: normalizeCdnSourceKind(edit.source_kind),
prefix_path: edit.prefix_path ?? '',
community_id: edit.community_id,
refresh_interval_sec: edit.refresh_interval_sec,
}
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null },
)
clearPreview()
}, [open, edit])
async function previewCdn() {
const urlTrim = form.url.trim()
if (!urlTrim) {
toast.error('Укажите URL')
return
}
setPreviewLoading(true)
setPreviewError(null)
setPreviewOk(false)
try {
const res = await apiMutate<CdnPreviewResponse>(
`/v1/modules/${moduleId}/cdn-sources/preview`,
'POST',
{
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? '',
},
)
setPreviewItems(res.items)
setPreviewTotal(res.total)
setPreviewTruncated(res.truncated)
setPreviewOk(true)
} catch (e) {
setPreviewError(e instanceof ApiError ? e.message : String(e))
setPreviewItems([])
setPreviewTotal(0)
setPreviewTruncated(false)
setPreviewOk(false)
} finally {
setPreviewLoading(false)
}
}
async function save() {
const urlTrim = form.url.trim()
if (!urlTrim) {
toast.error('Укажите URL')
return
}
setSaving(true)
try {
const body = {
...form,
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? '',
}
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body)
toast.success('Источник обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body)
toast.success('Источник добавлен')
}
clearPreview()
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={edit ? 'Редактировать источник' : 'Новый CDN-источник'}
className="sm:max-w-lg"
footer={
<>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-url">URL</Label>
<Input
id="cdn-url"
placeholder="https://example.com/list.txt"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
/>
</div>
<SelectField
id="cdn-kind"
label="Тип источника"
items={kindItems}
value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
value={form.prefix_path ?? ''}
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
/>
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
<p className="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
) : null}
</div>
<CommunitySelect
id="cdn-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
value={form.refresh_interval_sec ?? ''}
onChange={(e) =>
setForm((s) => ({
...s,
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
}))
}
/>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void previewCdn()}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{previewError ? (
<span className="text-sm text-destructive">{previewError}</span>
) : previewOk ? (
<span className="text-sm text-muted-foreground">
Всего: {previewTotal}
{previewTruncated ? (
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
) : null}
</span>
) : null}
</div>
{previewItems.length > 0 ? (
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{previewItems.map((item, i) => (
<li key={`${i}-${item}`} className="py-0.5">
{item}
</li>
))}
</ul>
) : null}
</div>
</FormDrawer>
)
}
@@ -1,102 +0,0 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '@/types/api'
interface ModuleDomainEntryDialogProps {
open: boolean
moduleId: string
edit: DomainEntry | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
export function ModuleDomainEntryDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleDomainEntryDialogProps) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<DomainEntryCreate>({ fqdn: '', community_id: null })
useEffect(() => {
if (!open) return
setForm(
edit
? { fqdn: edit.fqdn, community_id: edit.community_id }
: { fqdn: '', community_id: null },
)
}, [open, edit])
async function save() {
if (!form.fqdn.trim()) {
toast.error('Укажите FQDN')
return
}
setSaving(true)
try {
const body = { fqdn: form.fqdn.trim(), community_id: form.community_id }
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', body)
toast.success('Домен обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', body)
toast.success('Домен добавлен')
}
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={edit ? 'Редактировать домен' : 'Новый домен'}
className="sm:max-w-sm"
footer={
<>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-fqdn">FQDN</Label>
<Input
id="dom-fqdn"
placeholder="example.com"
value={form.fqdn}
onChange={(e) => setForm((s) => ({ ...s, fqdn: e.target.value }))}
/>
</div>
<CommunitySelect
id="dom-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</FormDrawer>
)
}
@@ -1,259 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { Pencil, Trash2 } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatDateTime } from '@/lib/modules/display'
import { communityLabel } from '@/lib/modules/helpers'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type {
AsEntry,
BgpCommunity,
CdnSource,
DomainEntry,
IpRangeEntry,
ModuleRow,
} from '@/types/api'
type DeleteTarget =
| { kind: 'domain'; entry: DomainEntry }
| { kind: 'ip-range'; entry: IpRangeEntry }
| { kind: 'cdn'; entry: CdnSource }
| { kind: 'as'; entry: AsEntry }
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
return (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" type="button" onClick={onEdit} aria-label="Редактировать">
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
type="button"
className="text-destructive"
onClick={onDelete}
aria-label="Удалить"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
export function ModuleEntriesGrid({
mod,
rows,
communities,
onEdit,
onDelete,
isLoading = false,
}: {
mod: ModuleRow
rows: Record<string, unknown>[]
communities: BgpCommunity[]
onEdit: (target: DeleteTarget) => void
onDelete: (target: DeleteTarget) => void
isLoading?: boolean
}) {
const columns = useMemo(() => {
if (mod.type === 'DOMAINS') {
return [
{
accessorKey: 'fqdn',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="FQDN" />
),
cell: ({ row }: { row: { original: DomainEntry } }) => (
<DataGridPrimaryCell title={row.original.fqdn} accent="mono" />
),
},
{
id: 'community',
header: 'Community',
cell: ({ row }: { row: { original: DomainEntry } }) => (
<span className="text-sm text-muted-foreground">
{communityLabel(row.original.community_id, communities)}
</span>
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }: { row: { original: DomainEntry } }) => (
<RowActions
onEdit={() => onEdit({ kind: 'domain', entry: row.original })}
onDelete={() => onDelete({ kind: 'domain', entry: row.original })}
/>
),
},
] as ColumnDef<DomainEntry>[]
}
if (mod.type === 'IP_RANGES') {
return [
{
accessorKey: 'prefix',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="Префикс (CIDR)" />
),
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
<DataGridPrimaryCell title={row.original.prefix} accent="mono" />
),
},
{
id: 'community',
header: 'Community',
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
<span className="text-sm text-muted-foreground">
{communityLabel(row.original.community_id, communities)}
</span>
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
<RowActions
onEdit={() => onEdit({ kind: 'ip-range', entry: row.original })}
onDelete={() => onDelete({ kind: 'ip-range', entry: row.original })}
/>
),
},
] as ColumnDef<IpRangeEntry>[]
}
if (mod.type === 'CDN_CIDRS') {
return [
{
accessorKey: 'url',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="URL" />
),
cell: ({ row }: { row: { original: CdnSource } }) => (
<DataGridPrimaryCell title={row.original.url} accent="mono" className="max-w-xs" />
),
},
{
accessorKey: 'source_kind',
header: 'Тип',
cell: ({ row }: { row: { original: CdnSource } }) => (
<CategoryBadge>{row.original.source_kind}</CategoryBadge>
),
},
{
id: 'community',
header: 'Community',
cell: ({ row }: { row: { original: CdnSource } }) => (
<span className="text-sm text-muted-foreground">
{communityLabel(row.original.community_id, communities)}
</span>
),
},
{
id: 'last_refreshed_at',
accessorFn: (row: CdnSource) => row.last_refreshed_at ?? '',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="Обновлено" />
),
cell: ({ row }: { row: { original: CdnSource } }) => (
<DataGridMutedCell>{formatDateTime(row.original.last_refreshed_at)}</DataGridMutedCell>
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }: { row: { original: CdnSource } }) => (
<RowActions
onEdit={() => onEdit({ kind: 'cdn', entry: row.original })}
onDelete={() => onDelete({ kind: 'cdn', entry: row.original })}
/>
),
},
] as ColumnDef<CdnSource>[]
}
return [
{
accessorKey: 'asn',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="ASN" />
),
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="font-mono text-sm">{row.original.asn}</span>
),
},
{
accessorKey: 'asn_name',
header: 'Имя',
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="text-sm">{row.original.asn_name ?? '—'}</span>
),
},
{
accessorKey: 'prefix_count',
header: 'Префиксов',
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="font-mono text-sm">{row.original.prefix_count ?? '—'}</span>
),
},
{
id: 'community',
header: 'Community',
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="text-sm text-muted-foreground">
{communityLabel(row.original.community_id, communities)}
</span>
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }: { row: { original: AsEntry } }) => (
<RowActions
onEdit={() => onEdit({ kind: 'as', entry: row.original })}
onDelete={() => onDelete({ kind: 'as', entry: row.original })}
/>
),
},
] as ColumnDef<AsEntry>[]
}, [communities, mod.type, onDelete, onEdit])
type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry
const data = rows as unknown as RowType[]
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns: columns as ColumnDef<RowType>[],
getSearchText: (row) => {
const r = row as Record<string, unknown>
return Object.values(r)
.filter((v) => typeof v === 'string' || typeof v === 'number')
.join(' ')
},
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет записей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск записей…"
/>
)
}
export type { DeleteTarget as ModuleEntryDeleteTarget }
@@ -1,239 +0,0 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridCard } from '@/components/data-grid-shell'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
import { ModuleDomainEntryDialog } from '@/components/modules/module-domain-entry-dialog'
import { ModuleIpRangeEntryDialog } from '@/components/modules/module-ip-range-entry-dialog'
import { ApiError, apiMutate } from '@/lib/api-client'
import type {
AsEntry,
BgpCommunity,
CdnSource,
DomainEntry,
IpRangeEntry,
ModuleRow,
} from '@/types/api'
interface ModuleEntriesSectionProps {
moduleId: string
mod: ModuleRow
items: Record<string, unknown>[]
communities: BgpCommunity[]
isLoading: boolean
isError: boolean
error: Error | null
onRetry: () => void
onChanged: () => void | Promise<void>
}
type DeleteTarget = ModuleEntryDeleteTarget
const CARD_META: Record<
ModuleRow['type'],
{ title: string; description: string; emptyTitle: string; emptyDescription: string }
> = {
DOMAINS: {
title: 'Домены',
description: 'FQDN для резолвинга через DoH.',
emptyTitle: 'Нет доменов',
emptyDescription: 'Добавьте FQDN для резолвинга.',
},
IP_RANGES: {
title: 'IP-диапазоны',
description: 'Статические CIDR для анонса.',
emptyTitle: 'Нет диапазонов',
emptyDescription: 'Добавьте CIDR.',
},
CDN_CIDRS: {
title: 'CDN-источники',
description: 'URL источников для скачивания списков CIDR.',
emptyTitle: 'Нет источников',
emptyDescription: 'Добавьте CDN-источник.',
},
AS_PREFIXES: {
title: 'AS-записи',
description: 'ASN для получения префиксов через RIPEstat.',
emptyTitle: 'Нет записей',
emptyDescription: 'Добавьте ASN.',
},
}
export function ModuleEntriesSection({
moduleId,
mod,
items,
communities,
isLoading,
isError,
error,
onRetry,
onChanged,
}: ModuleEntriesSectionProps) {
const meta = CARD_META[mod.type]
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null)
const [deleting, setDeleting] = useState(false)
const [editDomain, setEditDomain] = useState<DomainEntry | null>(null)
const [editIpRange, setEditIpRange] = useState<IpRangeEntry | null>(null)
const [editCdn, setEditCdn] = useState<CdnSource | null>(null)
const [editAs, setEditAs] = useState<AsEntry | null>(null)
function openCreate() {
setEditDomain(null)
setEditIpRange(null)
setEditCdn(null)
setEditAs(null)
setDialogOpen(true)
}
function closeDialog() {
setDialogOpen(false)
setEditDomain(null)
setEditIpRange(null)
setEditCdn(null)
setEditAs(null)
}
async function confirmDelete() {
if (!deleteTarget) return
setDeleting(true)
try {
const { kind, entry } = deleteTarget
const pathByKind = {
domain: `/v1/modules/${moduleId}/domain-entries/${entry.id}`,
'ip-range': `/v1/modules/${moduleId}/ip-range-entries/${entry.id}`,
cdn: `/v1/modules/${moduleId}/cdn-sources/${entry.id}`,
as: `/v1/modules/${moduleId}/as-entries/${entry.id}`,
} as const
await apiMutate(pathByKind[kind], 'DELETE', undefined, { idempotent: false })
toast.success('Удалено')
setDeleteTarget(null)
await onChanged()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setDeleting(false)
}
}
return (
<>
<DataGridCard
title={meta.title}
description={meta.description}
actions={
<Button size="sm" type="button" onClick={openCreate}>
<Plus />
Добавить
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle={meta.emptyTitle}
emptyDescription={meta.emptyDescription}
skeleton={<TableSkeleton rows={5} cols={3} />}
onRetry={onRetry}
>
{(rows) => (
<ModuleEntriesGrid
mod={mod}
rows={rows}
communities={communities}
isLoading={isLoading}
onEdit={(target) => {
if (target.kind === 'domain') setEditDomain(target.entry)
if (target.kind === 'ip-range') setEditIpRange(target.entry)
if (target.kind === 'cdn') setEditCdn(target.entry)
if (target.kind === 'as') setEditAs(target.entry)
setDialogOpen(true)
}}
onDelete={setDeleteTarget}
/>
)}
</QueryState>
</DataGridCard>
{mod.type === 'DOMAINS' ? (
<ModuleDomainEntryDialog
open={dialogOpen}
moduleId={moduleId}
edit={editDomain}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
{mod.type === 'IP_RANGES' ? (
<ModuleIpRangeEntryDialog
open={dialogOpen}
moduleId={moduleId}
edit={editIpRange}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
{mod.type === 'CDN_CIDRS' ? (
<ModuleCdnSourceDialog
open={dialogOpen}
moduleId={moduleId}
edit={editCdn}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
{mod.type === 'AS_PREFIXES' ? (
<ModuleAsEntryDialog
open={dialogOpen}
moduleId={moduleId}
edit={editAs}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
title="Удалить запись?"
description={deleteDescription(deleteTarget)}
confirmLabel="Удалить"
confirmLoadingLabel="Удаление…"
destructive
confirmLoading={deleting}
onConfirm={() => void confirmDelete()}
/>
</>
)
}
function deleteDescription(target: DeleteTarget | null): string {
if (!target) return ''
switch (target.kind) {
case 'domain':
return target.entry.fqdn
case 'ip-range':
return target.entry.prefix
case 'cdn':
return target.entry.url
case 'as':
return `AS${target.entry.asn}`
}
}
@@ -1,102 +0,0 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '@/types/api'
interface ModuleIpRangeEntryDialogProps {
open: boolean
moduleId: string
edit: IpRangeEntry | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
export function ModuleIpRangeEntryDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleIpRangeEntryDialogProps) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<IpRangeEntryCreate>({ prefix: '', community_id: '' })
useEffect(() => {
if (!open) return
setForm(
edit
? { prefix: edit.prefix, community_id: edit.community_id }
: { prefix: '', community_id: '' },
)
}, [open, edit])
async function save() {
if (!form.prefix.trim() || !form.community_id) {
toast.error('Укажите префикс и community')
return
}
setSaving(true)
try {
const body = { prefix: form.prefix.trim(), community_id: form.community_id }
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', body)
toast.success('Диапазон обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', body)
toast.success('Диапазон добавлен')
}
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}
className="sm:max-w-sm"
footer={
<>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
<Input
id="ip-prefix"
placeholder="203.0.113.0/24"
value={form.prefix}
onChange={(e) => setForm((s) => ({ ...s, prefix: e.target.value }))}
/>
</div>
<CommunitySelect
id="ip-comm"
label="Community (обязательно)"
value={form.community_id || null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v ?? '' }))}
communities={communities}
placeholder="Выберите community"
/>
</FormDrawer>
)
}
@@ -1,84 +0,0 @@
import {
ArrowDownUp,
Network,
RefreshCw,
ShieldCheck,
Timer,
} from 'lucide-react'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
import {
communityLabel,
dohProfileLabel,
moduleDohProfileIds,
} from '@/lib/modules/helpers'
import { dohPolicyRu } from '@/lib/ui-labels'
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
interface ModuleKpiCardsProps {
mod: ModuleRow | null
communities: BgpCommunity[]
dohProfiles: DohProfile[]
asEntries: AsEntry[]
loading?: boolean
}
export function ModuleKpiCards({
mod,
communities,
dohProfiles,
asEntries,
loading = false,
}: ModuleKpiCardsProps) {
if (loading || !mod) {
return <SectionCardsSkeleton count={5} />
}
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
const dohIds = moduleDohProfileIds(mod)
const items: SectionCardItem[] = [
{
label: 'Приоритет',
value: String(mod.priority ?? 0),
hint: 'порядок в сборке ревизии',
icon: <ArrowDownUp className="size-3.5" />,
},
{
label: 'Интервал',
value: moduleIntervalLabel(mod),
hint: 'refresh_interval_sec / cron',
icon: <Timer className="size-3.5" />,
},
{
label: 'DoH',
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
hint:
mod.type === 'DOMAINS'
? dohIds.length
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
: 'Системный DNS'
: 'не применимо',
icon: <Network className="size-3.5" />,
},
{
label: 'Community по умолч.',
value: communityLabel(mod.default_community_id, communities),
hint: 'для записей без своего community',
icon: <ShieldCheck className="size-3.5" />,
},
{
label: 'Последнее обновление',
value: formatDateTime(mod.last_refreshed_at),
hint:
mod.type === 'AS_PREFIXES'
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
: 'время последнего refresh',
icon: <RefreshCw className="size-3.5" />,
},
]
return <SectionCards items={items} />
}
@@ -1,98 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useNavigate } from '@tanstack/react-router'
import { Boxes } from 'lucide-react'
import { useMemo } from 'react'
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api'
export function ModulesListGrid({
items,
isLoading = false,
}: {
items: ModuleRow[]
isLoading?: boolean
}) {
const navigate = useNavigate()
const columns = useMemo<ColumnDef<ModuleRow>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Boxes className="size-4 shrink-0 text-muted-foreground" />
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[280px]" />
</div>
),
meta: { headerTitle: 'Название' },
},
{
accessorKey: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
meta: { headerTitle: 'Тип' },
},
{
accessorKey: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
),
meta: { headerTitle: 'Приоритет' },
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled ? 'enabled' : 'disabled'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => <ModeBadge enabled={row.original.enabled} />,
meta: { headerTitle: 'Состояние' },
},
{
id: 'last_refreshed_at',
accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
sortingFn: (a, b) => {
const av = a.original.last_refreshed_at ?? ''
const bv = b.original.last_refreshed_at ?? ''
return av.localeCompare(bv)
},
meta: { headerTitle: 'Обновлено' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'включён' : 'выключен'}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет модулей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск модулей…"
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
/>
)
}
@@ -1,120 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { jobStatusRu, readyCheckRu } from '@/lib/ui-labels'
import type { ReadyStatus } from '@/queries/monitoring'
const READY_CHECK_ICONS: Record<string, typeof Database> = {
postgres: Database,
store: HardDrive,
jobs: ListTodo,
}
interface ReadyCheckRow {
id: string
label: string
subtitle?: string
icon: typeof Database
status: string
statusLabel: string
}
export function MonitoringReadyGrid({
health,
ready,
}: {
health?: { ok?: boolean; status?: string; error?: string } | null
ready: ReadyStatus
}) {
const data = useMemo<ReadyCheckRow[]>(() => {
const checks = ready.checks ?? {}
const rows: ReadyCheckRow[] = [
{
id: 'liveness',
label: 'Живучесть',
subtitle: '/v1/health',
icon: HeartPulse,
status: health?.ok ? 'ok' : 'error',
statusLabel: health?.ok ? 'В норме' : 'Ошибка',
},
{
id: 'readiness',
label: 'Готовность',
subtitle: '/v1/ready',
icon: ShieldCheck,
status: ready.status === 'ok' ? 'ok' : 'warning',
statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'),
},
]
for (const key of Object.keys(checks)) {
const value = checks[key]
const ok = typeof value === 'boolean' ? value : value?.ok !== false
rows.push({
id: key,
label: readyCheckRu(key),
icon: READY_CHECK_ICONS[key] ?? ListTodo,
status: ok ? 'ok' : 'error',
statusLabel: ok ? 'В норме' : 'Ошибка',
})
}
return rows
}, [health?.ok, ready.checks, ready.status])
const columns = useMemo<ColumnDef<ReadyCheckRow>[]>(
() => [
{
accessorKey: 'label',
header: ({ column }) => <DataGridColumnHeader column={column} title="Проверка" />,
cell: ({ row }) => {
const Icon = row.original.icon
return (
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<DataGridPrimaryCell
title={row.original.label}
subtitle={row.original.subtitle}
/>
</div>
)
},
meta: { headerTitle: 'Проверка' },
},
{
id: 'status',
enableSorting: false,
header: 'Статус',
cell: ({ row }) => (
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
),
meta: { headerTitle: 'Статус' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => `${row.label} ${row.subtitle ?? ''} ${row.statusLabel}`,
getRowId: (row) => row.id,
pageSize: 20,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
showPagination={false}
emptyMessage="Нет проверок"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск проверок…"
/>
)
}
@@ -1,68 +0,0 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import type { PeerRow, SpeakerRow } from '@/types/api'
interface NetworkPeersCardProps {
items: PeerRow[]
speakers: SpeakerRow[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function NetworkPeersCard({
items,
speakers,
isLoading,
isError,
error,
onRetry,
}: NetworkPeersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<>
<DataGridCard
title="Пиры"
description="BGP-соседи и привязка к спикерам"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет пиров"
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
<PeerFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
speakers={speakers}
/>
</>
)
}
@@ -1,90 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { bgpSessionStateRu } from '@/lib/ui-labels'
import type { PeerRow } from '@/types/api'
export function NetworkPeersGrid({
items,
isLoading = false,
}: {
items: PeerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<PeerRow>[]>(
() => [
{
id: 'name',
accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.neighbor}
subtitle={row.original.name ? row.original.neighbor : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
),
meta: { headerTitle: 'Адрес соседа' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
),
meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'session_state',
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<StatusBadge
status={row.original.session_state ?? '—'}
label={bgpSessionStateRu(row.original.session_state)}
/>
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null}
</div>
),
meta: { headerTitle: 'Состояние' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет пиров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск пиров…"
/>
)
}
@@ -1,62 +0,0 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import type { SpeakerRow } from '@/types/api'
interface NetworkSpeakersCardProps {
items: SpeakerRow[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function NetworkSpeakersCard({
items,
isLoading,
isError,
error,
onRetry,
}: NetworkSpeakersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<>
<DataGridCard
title="Спикеры"
description="BIRD-агенты на нодах tenant"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте первого BIRD-агента на ноде."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
)
}
@@ -1,87 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { speakerOnlineLabel } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api'
export function NetworkSpeakersGrid({
items,
isLoading = false,
}: {
items: SpeakerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
() => [
{
accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
),
meta: { headerTitle: 'Конечная точка' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
meta: { headerTitle: 'Роль' },
},
{
id: 'agent',
enableSorting: false,
header: 'Агент',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
return <Badge variant="outline" size="sm" radius="full"></Badge>
},
meta: { headerTitle: 'Агент' },
},
{
id: 'bgp',
enableSorting: false,
header: 'BGP',
cell: ({ row }) => {
const live = row.original.live
if (!live) return '—'
return (
<span className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</span>
)
},
meta: { headerTitle: 'BGP' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет спикеров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск спикеров…"
/>
)
}
@@ -1,168 +0,0 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { useCreatePeerMutation, useUpdatePeerMutation } from '@/queries/network'
import type { BgpPeerCreate, PeerRow, SpeakerRow } from '@/types/api'
interface PeerFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
speakers: SpeakerRow[]
editTarget?: PeerRow | null
}
function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)'
}
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
}
export function PeerFormDialog({
open,
onOpenChange,
speakers,
editTarget = null,
}: PeerFormDialogProps) {
const createMutation = useCreatePeerMutation()
const updateMutation = useUpdatePeerMutation()
const saving = createMutation.isPending || updateMutation.isPending
const [name, setName] = useState('')
const [neighbor, setNeighbor] = useState('')
const [remoteAsn, setRemoteAsn] = useState('')
const [bgpSpeakerId, setBgpSpeakerId] = useState<string | null>(null)
const [enabled, setEnabled] = useState(true)
useEffect(() => {
if (!open) return
if (editTarget) {
setName(editTarget.name ?? '')
setNeighbor(editTarget.neighbor)
setRemoteAsn(String(editTarget.remote_asn ?? ''))
setBgpSpeakerId(editTarget.bgp_speaker_id ?? null)
setEnabled(editTarget.enabled !== false)
} else {
setName('')
setNeighbor('')
setRemoteAsn('')
setBgpSpeakerId(null)
setEnabled(true)
}
}, [editTarget, open])
const speakerItems = [
{ value: '', label: 'Все спикеры' },
...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })),
]
async function save() {
if (!neighbor.trim()) {
toast.error('Укажите адрес соседа')
return
}
const asn = Number(remoteAsn)
if (!asn || asn <= 0) {
toast.error('Remote ASN должен быть больше 0')
return
}
const body: BgpPeerCreate = {
name: name.trim() || undefined,
neighbor: neighbor.trim(),
remote_asn: asn,
bgp_speaker_id: bgpSpeakerId || null,
enabled,
}
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body })
} else {
await createMutation.mutateAsync(body)
}
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={editTarget ? 'Редактировать пира' : 'Новый пир'}
description="BGP-сосед для установки сессии"
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={save}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-name">Имя пира (опционально)</Label>
<Input
id="peer-name"
placeholder="Core-RTR-1"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-neighbor">Адрес соседа</Label>
<Input
id="peer-neighbor"
placeholder="192.0.2.1"
value={neighbor}
onChange={(e) => setNeighbor(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-asn">Remote ASN</Label>
<Input
id="peer-asn"
type="number"
placeholder="65000"
value={remoteAsn}
onChange={(e) => setRemoteAsn(e.target.value)}
required
/>
</div>
<SelectField
id="peer-speaker"
label="Спикер (опционально)"
items={speakerItems}
value={bgpSpeakerId ?? ''}
onValueChange={(v) => setBgpSpeakerId(v || null)}
placeholder="Все спикеры"
/>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="peer-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
</p>
</div>
<Checkbox
id="peer-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
</FormDrawer>
)
}
@@ -1,162 +0,0 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { useCreateSpeakerMutation } from '@/queries/network'
import type { BgpSpeakerCreate } from '@/types/api'
interface SpeakerFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
function parseIpv4FromEndpoint(ep: string): string {
try {
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`)
const host = u.hostname
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host
} catch {
/* ignore */
}
return ''
}
function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string): string {
const meta: Record<string, string> = {}
if (agentDomain.trim()) meta.agent_domain = agentDomain.trim()
if (nodeIpv4.trim()) meta.node_ipv4 = nodeIpv4.trim()
if (bgpSource.trim()) meta.bird_bgp_source_ipv4 = bgpSource.trim()
return JSON.stringify(meta)
}
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
const createMutation = useCreateSpeakerMutation()
const [endpoint, setEndpoint] = useState('')
const [role, setRole] = useState('replica')
const [agentDomain, setAgentDomain] = useState('')
const [nodeIpv4, setNodeIpv4] = useState('')
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
const [bgpSourceManual, setBgpSourceManual] = useState(false)
useEffect(() => {
if (!open) return
setEndpoint('')
setRole('replica')
setAgentDomain('')
setNodeIpv4('')
setBgpSourceIpv4('')
setBgpSourceManual(false)
}, [open])
function handleEndpointChange(value: string) {
setEndpoint(value)
const ip = parseIpv4FromEndpoint(value)
if (ip && !nodeIpv4) {
handleNodeIpv4Change(ip)
}
}
function handleNodeIpv4Change(value: string) {
setNodeIpv4(value)
if (!bgpSourceManual) {
setBgpSourceIpv4(value)
}
}
async function save() {
const ep =
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
if (!ep) {
toast.error('Укажите endpoint или agent domain')
return
}
const body: BgpSpeakerCreate = {
endpoint: ep,
role: role.trim() || 'replica',
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
}
try {
await createMutation.mutateAsync(body)
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новый спикер"
description="BIRD-агент на ноде реплики или control plane"
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
Создать
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
<Input
id="speaker-endpoint"
placeholder="https://node.example.com:8443"
value={endpoint}
onChange={(e) => handleEndpointChange(e.target.value)}
/>
</div>
<SelectField
id="speaker-role"
label="Роль"
items={[
{ value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер (CP)' },
]}
value={role}
onValueChange={(v) => setRole(v ?? 'replica')}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
<Input
id="speaker-agent-domain"
placeholder="bird-agent.example.com"
value={agentDomain}
onChange={(e) => setAgentDomain(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
<Input
id="speaker-node-ipv4"
placeholder="203.0.113.10"
value={nodeIpv4}
onChange={(e) => handleNodeIpv4Change(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
<Input
id="speaker-bgp-source"
placeholder="203.0.113.10"
value={bgpSourceIpv4}
onChange={(e) => {
setBgpSourceManual(true)
setBgpSourceIpv4(e.target.value)
}}
/>
</div>
</FormDrawer>
)
}
@@ -1,139 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMutation } from '@tanstack/react-query'
import { useMemo } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { apiMutate } from '@/lib/api-client'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
export function OperationsJobsGrid({
items,
nameById,
qc,
isLoading = false,
}: {
items: JobRow[]
nameById: Map<string, string>
qc: QueryClient
isLoading?: boolean
}) {
const cancelMutation = useMutation({
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
onSuccess: () => {
toast.success('Задача отменена')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
})
const columns = useMemo<ColumnDef<JobRow>[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={jobKindRu(row.original.kind)}
accent="mono"
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ??
String(row.original.meta.module_id))
: undefined
}
/>
),
meta: { headerTitle: 'Вид' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => {
const finished = row.original.finished_at
const hint = finished
? new Date(finished).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
: undefined
return <StatusBadge status={row.original.status} hint={hint} />
},
meta: { headerTitle: 'Статус' },
},
{
id: 'created_at',
accessorFn: (row) => row.created_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.created_at
? new Date(row.original.created_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
},
{
id: 'finished_at',
accessorFn: (row) => row.finished_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.finished_at
? new Date(row.original.finished_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
meta: { headerTitle: 'Завершена' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) =>
row.original.status === 'running' || row.original.status === 'queued' ? (
<Button
variant="ghost"
size="icon-sm"
type="button"
className="text-destructive"
onClick={() => cancelMutation.mutate(row.original.job_id)}
>
</Button>
) : null,
},
],
[cancelMutation, nameById],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => {
const moduleName = row.meta?.module_id
? (nameById.get(String(row.meta.module_id)) ?? String(row.meta.module_id))
: ''
return `${jobKindRu(row.kind)} ${row.status} ${row.job_id} ${moduleName}`
},
getRowId: (row) => row.job_id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет задач"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/>
)
}
@@ -1,109 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMutation } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { useMemo } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { apiMutate } from '@/lib/api-client'
import type { RevisionRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
export function OperationsRevisionsGrid({
items,
qc,
isLoading = false,
}: {
items: RevisionRow[]
qc: QueryClient
isLoading?: boolean
}) {
const rollbackMutation = useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
onSuccess: () => {
toast.success('Откат выполнен')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
})
const columns = useMemo<ColumnDef<RevisionRow>[]>(
() => [
{
id: 'id',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={`${row.original.id.slice(0, 12)}`} accent="mono" />
),
meta: { headerTitle: 'ID' },
},
{
accessorKey: 'created_at',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<DataGridMutedCell>
{new Date(row.original.created_at).toLocaleString('ru-RU')}
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
},
{
accessorKey: 'materialized_prefix_count',
header: ({ column }) => <DataGridColumnHeader column={column} title="Префиксов" />,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">
{row.original.materialized_prefix_count}
</span>
),
meta: { headerTitle: 'Префиксов' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => (
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" type="button" className="text-destructive">
<RefreshCw className="size-3.5" />
</Button>
}
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
confirmLabel="Откатить"
destructive
onConfirm={() => rollbackMutation.mutate(row.original.id)}
/>
),
},
],
[rollbackMutation],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.id} ${row.materialized_prefix_count}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ревизий"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/>
)
}
-19
View File
@@ -1,19 +0,0 @@
import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
)
}
-6
View File
@@ -1,6 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
}
-64
View File
@@ -1,64 +0,0 @@
import type { ReactNode } from 'react'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
import { cn } from '@evobgp/ui/lib/utils'
/** REUI c-data-grid-19: flush card shell with compact header spacing. */
export const panelCardClassName = 'gap-0 py-0'
export const panelCardHeaderClassName = 'border-b [.border-b]:pb-3'
export const panelCardContentFlushClassName = 'px-0'
export const panelCardFooterClassName = 'border-t bg-transparent py-0'
interface PanelCardProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
footer?: ReactNode
children?: ReactNode
className?: string
headerClassName?: string
contentClassName?: string
footerClassName?: string
size?: 'default' | 'sm'
}
export function PanelCard({
title,
description,
actions,
footer,
children,
className,
headerClassName,
contentClassName,
footerClassName,
size = 'sm',
}: PanelCardProps) {
const hasHeader = Boolean(title || description || actions)
return (
<Card size={size} className={cn(panelCardClassName, className)}>
{hasHeader ? (
<CardHeader className={cn(panelCardHeaderClassName, headerClassName)}>
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <CardDescription>{description}</CardDescription> : null}
{actions ? <CardAction>{actions}</CardAction> : null}
</CardHeader>
) : null}
{children != null && children !== false ? (
<CardContent className={contentClassName}>{children}</CardContent>
) : null}
{footer ? (
<CardFooter className={cn(panelCardFooterClassName, footerClassName)}>{footer}</CardFooter>
) : null}
</Card>
)
}
-67
View File
@@ -1,67 +0,0 @@
import type { ReactNode } from 'react'
import { AlertCircle, RefreshCwIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { EmptyState } from './empty-state'
interface QueryStateProps<T> {
data: T | undefined
isLoading: boolean
isError: boolean
error?: unknown
empty?: boolean
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
onRetry?: () => void
skeleton?: ReactNode
children: (data: T) => ReactNode
}
export function QueryState<T>({
data,
isLoading,
isError,
error,
empty,
emptyTitle = 'Нет данных',
emptyDescription,
emptyAction,
onRetry,
skeleton,
children,
}: QueryStateProps<T>) {
if (isLoading) {
return <>{skeleton ?? <DefaultSkeleton />}</>
}
if (isError) {
return (
<EmptyState
icon={<AlertCircle className="size-8" />}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
)
}
if (empty || data == null) {
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
}
return <>{children(data)}</>
}
function DefaultSkeleton() {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" />
</div>
)
}
@@ -1,343 +0,0 @@
"use client"
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
const inputVariants = cva(
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
{
variants: {
size: {
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
default:
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
},
},
defaultVariants: {
size: "default",
},
}
)
const Autocomplete = AutocompletePrimitive.Root
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
return (
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
)
}
function AutocompleteInput({
className,
size = "default",
showClear = false,
showTrigger = false,
...props
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
VariantProps<typeof inputVariants> & {
showClear?: boolean
showTrigger?: boolean
}) {
return (
<div className="relative w-full">
<AutocompletePrimitive.Input
data-slot="autocomplete-input"
data-size={size}
className={cn(inputVariants({ size }), className)}
{...props}
/>
{showTrigger && <AutocompleteTrigger />}
{showClear && <AutocompleteClear />}
</div>
)
}
function AutocompleteStatus({
className,
...props
}: AutocompletePrimitive.Status.Props) {
return (
<AutocompletePrimitive.Status
data-slot="autocomplete-status"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
return (
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
)
}
function AutocompleteBackdrop({
...props
}: AutocompletePrimitive.Backdrop.Props) {
return (
<AutocompletePrimitive.Backdrop
data-slot="autocomplete-backdrop"
{...props}
/>
)
}
function AutocompletePositioner({
className,
...props
}: AutocompletePrimitive.Positioner.Props) {
return (
<AutocompletePrimitive.Positioner
data-slot="autocomplete-positioner"
className={cn("z-50 outline-none", className)}
{...props}
/>
)
}
function AutocompleteList({
className,
scrollAreaClassName,
...props
}: AutocompletePrimitive.List.Props & {
scrollAreaClassName?: string
scrollFade?: boolean
scrollbarGutter?: boolean
}) {
return (
<ScrollArea
className={cn(
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
scrollAreaClassName
)}
>
<AutocompletePrimitive.List
data-slot="autocomplete-list"
className={cn(
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
className
)}
{...props}
/>
</ScrollArea>
)
}
function AutocompleteCollection({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
return (
<AutocompletePrimitive.Collection
data-slot="autocomplete-collection"
{...props}
/>
)
}
function AutocompleteRow({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
return (
<AutocompletePrimitive.Row
data-slot="autocomplete-row"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function AutocompleteItem({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
return (
<AutocompletePrimitive.Item
data-slot="autocomplete-item"
className={cn(
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
className
)}
{...props}
/>
)
}
export interface AutocompleteContentProps extends React.ComponentProps<
typeof AutocompletePrimitive.Popup
> {
align?: AutocompletePrimitive.Positioner.Props["align"]
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
side?: AutocompletePrimitive.Positioner.Props["side"]
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
showBackdrop?: boolean
}
function AutocompleteContent({
className,
children,
showBackdrop = false,
align = "start",
sideOffset = 4,
alignOffset = 0,
side = "bottom",
anchor,
...props
}: AutocompleteContentProps) {
return (
<AutocompletePortal>
{showBackdrop && <AutocompleteBackdrop />}
<AutocompletePositioner
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
side={side}
anchor={anchor}
>
<div className="relative flex max-h-full">
<AutocompletePrimitive.Popup
data-slot="autocomplete-popup"
className={cn(
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
className
)}
{...props}
>
{children}
</AutocompletePrimitive.Popup>
</div>
</AutocompletePositioner>
</AutocompletePortal>
)
}
function AutocompleteGroup({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
return (
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
)
}
function AutocompleteGroupLabel({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
return (
<AutocompletePrimitive.GroupLabel
data-slot="autocomplete-group-label"
className={cn(
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
className
)}
{...props}
/>
)
}
function AutocompleteEmpty({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
return (
<AutocompletePrimitive.Empty
data-slot="autocomplete-empty"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompleteClear({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
return (
<AutocompletePrimitive.Clear
data-slot="autocomplete-clear"
className={cn(
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
className
)}
{...props}
>
<XIcon className="size-4" />
</AutocompletePrimitive.Clear>
)
}
function AutocompleteTrigger({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
return (
<AutocompletePrimitive.Trigger
data-slot="autocomplete-trigger"
className={cn(
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
className
)}
{...props}
>
<ChevronsUpDownIcon className="size-4 opacity-70" />
</AutocompletePrimitive.Trigger>
)
}
function AutocompleteArrow({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
return (
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
)
}
function AutocompleteSeparator({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
return (
<AutocompletePrimitive.Separator
data-slot="autocomplete-separator"
className={cn(
"bg-border my-1.5 h-px",
className
)}
{...props}
/>
)
}
export {
Autocomplete,
AutocompleteValue,
AutocompleteTrigger,
AutocompleteInput,
AutocompleteStatus,
AutocompletePortal,
AutocompleteBackdrop,
AutocompletePositioner,
AutocompleteContent,
AutocompleteList,
AutocompleteCollection,
AutocompleteRow,
AutocompleteItem,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteEmpty,
AutocompleteClear,
AutocompleteArrow,
AutocompleteSeparator,
}

Some files were not shown because too many files have changed in this diff Show More