Compare commits

..
19 Commits
Author SHA1 Message Date
Denozordec 7b3f002e5f feat(firewall): enhance firewall management with community selection and script handling
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m16s
Added a community selection feature to the firewall rules management UI, allowing users to specify BGP communities for block/accept policies. Updated the backend to support reading firewall scripts from a specified directory, improving script management. Enhanced documentation to clarify the new community functionality and its implications for firewall rules. Additionally, introduced tests for the firewall script endpoints to ensure proper functionality.
2026-07-08 17:50:38 +07:00
Denozordec fa2abc81f3 feat(firewall): add install context query and API endpoint for firewall client setup
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 1m1s
CI / go (push) Successful in 1m16s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m4s
Introduced a new API endpoint for retrieving the install context of the firewall client, which includes the bundle seed, configuration status, and suggested control plane URL. Updated the frontend to utilize this new endpoint, enhancing the user experience by dynamically displaying relevant information. Additionally, added type definitions for the install context and integrated it into the existing firewall management flow.
2026-07-08 17:17:34 +07:00
Denozordec 7a3eae98b1 feat(firewall): implement firewall blocklist feature with client management and policy rules
CI / changes (push) Successful in 12s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 46s
CI / go (push) Successful in 1m15s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 3m59s
Introduced a comprehensive firewall blocklist feature, allowing for the management of firewall clients and their associated rules. This includes endpoints for enrolling clients, listing clients and rules, and reporting apply statuses. Enhanced the API to support firewall operations, including the ability to handle block/accept policies. Updated the documentation to reflect these changes and added necessary components in the web UI for better user interaction.

Additionally, modified the agent server to support firewall failover and integrated firewall functionality into the existing architecture.
2026-07-08 16:37:27 +07:00
Denozordec 276194a9d0 fix(daemon): update PID and timestamps in .codegraph/daemon.pid for synchronization 2026-07-07 17:09:55 +07:00
Denozordec 53b3c49612 refactor(settings): simplify settings query options and remove tenant dependency
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 1m0s
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m5s
Updated the settings query options to eliminate the tenant ID parameter, streamlining the settings retrieval process. Adjusted the TenantSettingsComponent to reflect this change, ensuring it now queries settings without relying on tenant-specific data. This refactor enhances code clarity and reduces complexity in the settings management flow.
2026-07-06 23:28:14 +07:00
Denozordec db79820df0 feat(auth): introduce demo token support and enhance API token handling
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m57s
Added a local demo token for development purposes and improved the API token management by normalizing input tokens. Updated the authentication flow to utilize the new token handling, allowing for better session management and user experience. Enhanced the settings component to support the demo token and provide clear instructions for its use in local development.
2026-07-06 22:55:30 +07:00
Denozordec a902a4270d fix(api-keys): enhance API key management with new mutations and UI updates
CI / changes (push) Successful in 14s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 59s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m47s
Added new mutations for creating, revoking, and rotating API keys in the api-keys query file. Updated the Access component to utilize these mutations, improving the user interface with better feedback and session management. Introduced a new AccessApiKeysCard for displaying API key information and enhanced the overall layout and user experience in the access route.
2026-07-06 20:32:17 +07:00
Denozordec 3a3e6db018 fix(tabs): update Tabs component for orientation handling and improve class names
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 45s
CI / go (push) Successful in 57s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m15s
Enhanced the Tabs component to support orientation prop, ensuring proper layout for both horizontal and vertical orientations. Updated class names for better clarity and consistency in styling. Additionally, modified the daemon PID and timestamps in .codegraph/daemon.pid for synchronization.
2026-07-06 18:45:44 +07:00
Denozordec 144d342c16 fix(modules): enhance ModuleDetailComponent with additional queries and UI updates
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m3s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m4s
Updated the ModuleDetailComponent to include new queries for communities and DoH profiles, improving data handling. Added a module type alert function for better user guidance and refined the UI to display module type in a more user-friendly manner. Removed unused components and streamlined the refresh functionality for better performance.
2026-07-03 16:50:21 +07:00
Denozordec 0af37d55c4 fix(pagination): enhance DataGridPagination to dynamically populate Select items
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m0s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m37s
Updated the DataGridPagination component to map available sizes into Select items for better user experience. This change allows for dynamic selection of page sizes based on the provided props.
2026-07-03 14:43:46 +07:00
Denozordec 78f2ecc246 fix(auth): update Select components to include SelectValue for better placeholder handling
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m5s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m0s
Modified the Select components in operations.tsx, settings.tsx, and tenant-settings.tsx to incorporate SelectValue for improved placeholder functionality. Additionally, updated the daemon PID and timestamps in .codegraph/daemon.pid for consistency.
2026-07-03 14:10:23 +07:00
DenozordecandCursor 8c97445f7e fix(jobs): make deploy coalescing deterministic via inflight counter
CI / changes (push) Successful in 12s
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m27s
TestParallelModuleRefresh_CoalescesDeployApply падал на CI под -race
(want exactly one deploy_apply job, got 2). Локально тест проходил
стабильно (100/500 итераций с -cpu), но узкая гонка проявлялась при
замедлении под race-детектором.

Корень: коалесцирование решало «делать ли deploy» через
CountOtherActiveRefresh, который опрашивал статусы job-ов (queued/
running). Статусы меняются асинхронно относительно tenantRefreshMu,
поэтому в редких таймингах оба параллельных refresh могли решить,
что другой уже не активен, и каждый породил свой deploy_apply.

Решение — детерминированный inflight-счётчик refresh-kind job-ов в
Registry (inflightRefresh map[string]int), управляемый под r.mu:
- инкремент в Enqueue при создании нового refresh-kind job-а;
- декремент + проверка «последний ли я» в finishModuleRefreshSuccess
  через новый метод finalizeRefreshCoalesce (под tenantRefreshMu).

Последний refresh (счётчик <= 1) делает render + deploy_apply; все
остальные defer-ят. Решение больше не зависит от опроса статусов и
таймингов ingest.

Чтобы счётчик не утёк на error/cancel путях (где refresh не доходит
до finishModuleRefreshSuccess), обработка module_refresh и
tenant_refresh вынесена в runModuleRefresh / runTenantRefresh с
defer-обёрткой, которая гарантированно освобождает слот, если
finishModuleRefreshSuccess не отработал.

CountOtherActiveRefresh / CountOtherActiveModuleRefresh оставлены
как публичные методы (могут использоваться в мониторинге); из
продакшн-логики коалесцирования убраны.

Проверки: go build, go vet, go test ./internal/... -count=1 — exit 0.
Стресс-тест коалесцирования: 200 итераций с -cpu=4 — стабильно.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 01:31:32 +07:00
DenozordecandCursor 0ea5b3b738 fix(bird2): switch download host from bird.network.cz to bird.nic.cz
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Failing after 41s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
Docker-сборка birdc падала с HTTP 403 при скачивании исходников BIRD
2.14 с bird.network.cz. Старый домен bird.network.cz больше не отдаёт
файлы (403 для всех путей и User-Agent), официальный сайт BIRD переехал
на bird.nic.cz.

URL bird.nic.cz/download/bird-2.14.tar.gz проверен: 200 OK, ~1.4 МБ,
совпадает с размером на старом домене. Страница old-releases на новом
домене подтверждает тот же путь /download/bird-2.14.tar.gz.

Изменены все 5 вхождений старого домена в репозитории:
- deploy/docker/bird/bird-from-source.sh — критичный curl в сборке;
- .cursor/rules/networking-bird.mdc — BIRD2 docs и DOC-SYNC-05;
- .cursor/rules/context7-stack.mdc — приоритет источников;
- .cursor/rules/engineering.mdc — таблица Documentation Sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 23:30:56 +07:00
DenozordecandCursor 66b785f7cb fix(ci): replace pnpm/action-setup with corepack for web job
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 29s
CI / web (push) Successful in 1m3s
CI / go (push) Successful in 1m10s
CI / bird2 (push) Successful in 19s
CI / release (push) Failing after 3m33s
pnpm/action-setup@v4 на Gitea Actions продолжает падать на
Running self-installer даже после перестановки шагов
(setup-node перед action-setup). Action использует GitHub-specific
механику скачивания pnpm, которая ненадёжна на Gitea runner.

Вместо pnpm/action-setup используем corepack (встроен в Node 22):
corepack enable активирует pnpm@10.33.2 из поля packageManager в
package.json — тот же механизм, что работает в Dockerfile. Это
устраняет зависимость от внешнего action и его self-installer.

Кэш pnpm-store добавим позже через actions/cache или setup-node
cache: pnpm (теперь pnpm доступен к моменту настройки кэша).
Сейчас приоритет — сделать step рабочим.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 23:17:26 +07:00
DenozordecandCursor cb14194a5f fix(ci): order setup-node before pnpm action and cache pnpm store
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Failing after 9s
CI / go (push) Successful in 58s
CI / bird2 (push) Successful in 15s
CI / release (push) Has been skipped
Job web падал на шаге pnpm/action-setup@v4 (Running self-installer)
из-за неверного порядка: pnpm/action-setup вызывался до
actions/setup-node, поэтому Node.js ещё не был настроен к моменту
запуска self-installer pnpm. Добавление packageManager в package.json
усугубило ситуацию — action начал honour'ить его через node-зависимый
инсталлер, которому не хватило Node.

Изменения в job web:
- actions/setup-node@v4 (node 22) перенесён ВЫШЕ pnpm/action-setup@v4;
- кэш pnpm-store включён через cache: true в pnpm/action-setup
  (вместо cache: pnpm в setup-node, который требует pnpm установленным
  раньше и спотыкается о тот же порядок).

Версия pnpm задаётся полем packageManager в package.json (pnpm@10.33.2)
и дублируется в Dockerfile через corepack prepare, поэтому version: 10
в action оставлен как совместимый fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 23:09:44 +07:00
Denozordec 9c38e1bc57 fix(ci): update daemon PID and timestamps in configuration files
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Failing after 9s
CI / go (push) Successful in 52s
CI / bird2 (push) Successful in 15s
CI / release (push) Has been skipped
Updated the daemon PID from 44608 to 52448 and adjusted the startedAt timestamp in the .codegraph/daemon.pid file. Additionally, modified the CI workflow configuration in .gitea/workflows/ci.yaml to ensure consistent pnpm version usage during the setup process.
2026-07-02 19:10:18 +07:00
DenozordecandCursor 4ac99e43ae fix(web): pin pnpm version via packageManager and corepack prepare
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 24s
CI / go (push) Successful in 53s
CI / bird2 (push) Successful in 16s
CI / web (push) Failing after 7s
CI / release (push) Has been skipped
Предыдущий фикс (onlyBuiltDependencies в pnpm-workspace.yaml) оказался
недостаточным: Docker-сборка evobgp-web продолжала падать с
ERR_PNPM_IGNORED_BUILDS для esbuild. Причина — в корневом package.json
отсутствовало поле packageManager, а Dockerfile вызывал corepack enable
без пина версии. Corepack активировал устаревшую pnpm, зашитую в образ
node:22-alpine, которая не поддерживает чтение onlyBuiltDependencies из
pnpm-workspace.yaml (эта возможность появилась в pnpm 10.4+).

Двойная защита от рассинхрона версий pnpm между локальной средой,
CI и Docker:

- package.json: packageManager = pnpm@10.33.2 (стандарт corepack).
- Dockerfile: corepack prepare pnpm@10.33.2 --activate — явно ставит
  нужную версию даже если package.json ещё не скопирован на момент
  первого вызова pnpm.

Локально: typecheck и build @evobgp/web — exit 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:03:58 +07:00
DenozordecandCursor f26c15401c fix(web): allow esbuild postinstall for pnpm 10 frozen install
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 46s
CI / go (push) Successful in 59s
CI / bird2 (push) Successful in 14s
CI / release (push) Failing after 1m58s
В pnpm 10 postinstall-скрипты unreviewed-зависимостей по умолчанию
блокируются (strictDepBuilds=true), из-за чего pnpm install
--frozen-lockfile в Docker падал с ERR_PNPM_IGNORED_BUILDS для
esbuild@0.28.1. Vite требует нативный бинарник esbuild для билда
apps/web, поэтому скрипт нужно выполнять.

onlyBuiltDependencies в pnpm-workspace.yaml явно одобряет сборку
esbuild. Поведение проверено локально в условиях CI (чистый store,
CI=true, --frozen-lockfile): postinstall выполняется, install и
build проходят с exit 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 18:30:37 +07:00
DenozordecandCursor c144b49acf feat!(web): migrate UI from SvelteKit to React + shadcn/ui + ReUI
CI / changes (push) Successful in 17s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 46s
CI / go (push) Successful in 1m1s
CI / bird2 (push) Successful in 17s
CI / release (push) Failing after 2m22s
Web UI полностью переведён с SvelteKit на новый стек: React 19,
TanStack Router/Query/Table/Virtual, shadcn/ui (base-nova) и ReUI
enterprise-компоненты (data-grid, filters, autocomplete). Новый код
разложен по слоям: packages/ui (shadcn-примитивы), apps/web
(роуты, shared-обёртки, ReUI-адаптации).

BREAKING CHANGE: меняется структура и инструментинг фронтенда.

- apps/web/ — новый Vite + React-проект (@evobgp/web), file-based
  роуты TanStack Router; экраны dashboard, modules, monitoring,
  network, operations, schedule, settings, tenant-settings, access,
  directories.
- packages/ui/ — shadcn/ui-примитивы (@evobgp/ui) с общими стилями
  globals.css и cn-утилитой; CLI shadcn запускается из apps/web.
- apps/web/src/components/reui/ — enterprise-паттерны ReUI.
- pnpm workspace (pnpm-workspace.yaml, pnpm-lock.yaml, tsconfig.base.json)
  заменяет npm-проект в web/.
- web/ переименован в web-legacy-svelte/ (архив-референс для миграции);
  импорты оттуда запрещены правилом WEB-22.
- CI (.gitea/workflows/ci.yaml): job web переведён на Node 22 + pnpm 10
  (typecheck/lint/build через pnpm --filter @evobgp/web); пути триггеров
  обновлены под apps/web|packages/ui.
- deploy/docker/evobgp-web/Dockerfile: сборка из корня репозитория,
  pnpm install --frozen-lockfile, выход dist из apps/web/dist.
- .cursor/rules/web-shadcn.mdc, context7-stack.mdc, engineering.mdc,
  AGENTS.md — обновлены под React-стек (WEB-01..WEB-22, DOC-SYNC-06/07).

Проверки WEB-19 локально: typecheck, lint, build — exit 0.

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

After

Width:  |  Height:  |  Size: 272 B

@@ -0,0 +1,190 @@
import { useState } from 'react'
import { Plus, RefreshCw, Trash2 } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
import { Badge } from '@/components/reui/badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state'
import { StatusBadge } from '@/components/status-badge'
import { TableSkeleton } from '@/components/skeletons'
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
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 (
<>
<Card>
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">API-ключи</CardTitle>
<CardDescription>
Управление ключами tenant. Полный токен показывается только при создании и ротации.
</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
Обновить
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus />
Создать
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<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) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Префикс</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Истекает</TableHead>
<TableHead>Последнее использование</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((k) => (
<TableRow key={k.id}>
<TableCell className="font-medium">{k.name}</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{k.role}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{k.prefix}
</TableCell>
<TableCell>
{k.revoked_at ? (
<StatusBadge status="error" label="отозван" />
) : (
<StatusBadge status="active" label="активен" />
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatApiKeyDate(k.expires_at)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatApiKeyDate(k.last_used_at)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
title="Ротировать"
disabled={!!k.revoked_at || rotate.isPending}
>
<RefreshCw className="size-3.5" />
</Button>
}
title="Ротировать ключ?"
description="Старый токен перестанет работать сразу."
confirmLabel="Ротировать"
onConfirm={() => handleRotated(k.id)}
/>
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
disabled={!!k.revoked_at || revoke.isPending}
title="Отозвать"
>
<Trash2 className="size-3.5" />
</Button>
}
title="Отозвать API-ключ?"
description={`${k.name} (${k.prefix}…)`}
confirmLabel="Отозвать"
destructive
onConfirm={() => revoke.mutate(k.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
<ApiKeyCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={showToken}
/>
<ApiKeyTokenDialog
open={tokenDialogOpen}
token={revealedToken}
onOpenChange={setTokenDialogOpen}
/>
</>
)
}
@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { LoadingButton } from '@/components/loading-button'
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
import { useCreateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
interface ApiKeyCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (created: ApiKeyCreated) => void
}
export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCreateDialogProps) {
const createMutation = useCreateApiKeyMutation()
const [name, setName] = useState('')
const [role, setRole] = useState<ApiKeyRole>('editor')
const [expiresLocal, setExpiresLocal] = useState('')
useEffect(() => {
if (!open) return
setName('')
setRole('editor')
setExpiresLocal('')
}, [open])
function handleOpenChange(next: boolean) {
onOpenChange(next)
}
async function save() {
if (!name.trim()) {
toast.error('Укажите имя')
return
}
const body: ApiKeyCreate = {
name: name.trim(),
role,
}
if (expiresLocal.trim()) {
const d = new Date(expiresLocal)
if (Number.isNaN(d.getTime())) {
toast.error('Некорректная дата истечения')
return
}
body.expires_at = d.toISOString()
}
try {
const created = await createMutation.mutateAsync(body)
onOpenChange(false)
onCreated(created)
} catch {
// toast handled in mutation
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Новый API-ключ</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label htmlFor="key-name">Имя</Label>
<Input
id="key-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="CI / оператор UI"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key-role">Роль</Label>
<Select
items={[...API_KEY_ROLE_ITEMS]}
value={role}
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
>
<SelectTrigger id="key-role" className="w-full">
<SelectValue placeholder="Выберите роль" />
</SelectTrigger>
<SelectContent>
{API_KEY_ROLE_ITEMS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key-expires">Истекает (опционально)</Label>
<Input
id="key-expires"
type="datetime-local"
value={expiresLocal}
onChange={(e) => setExpiresLocal(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Отмена
</Button>
<LoadingButton onClick={save} loading={createMutation.isPending}>
Создать
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,51 @@
import { Copy } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
interface ApiKeyTokenDialogProps {
open: boolean
token: string
onOpenChange: (open: boolean) => void
}
export function ApiKeyTokenDialog({ open, token, onOpenChange }: ApiKeyTokenDialogProps) {
async function copyToken() {
if (!token) return
try {
await navigator.clipboard.writeText(token)
toast.success('Скопировано')
} catch {
toast.error('Не удалось скопировать')
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Сохраните токен</DialogTitle>
<DialogDescription>
Он больше не будет показан. Скопируйте в безопасное хранилище.
</DialogDescription>
</DialogHeader>
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">{token}</div>
<DialogFooter>
<Button variant="outline" onClick={copyToken}>
<Copy />
Копировать
</Button>
<Button onClick={() => onOpenChange(false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,53 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@evobgp/ui/components/alert-dialog'
import type { ReactElement, ReactNode } from 'react'
interface ConfirmDialogProps {
trigger: ReactElement
title: string
description?: ReactNode
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
onConfirm: () => void
}
export function ConfirmDialog({
trigger,
title,
description,
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
destructive,
onConfirm,
}: ConfirmDialogProps) {
return (
<AlertDialog>
<AlertDialogTrigger render={trigger} />
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? 'destructive' : 'default'}
onClick={onConfirm}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
interface EmptyStateProps {
title: string
description?: string
icon?: ReactNode
action?: ReactNode
className?: string
}
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
className,
)}
>
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}
@@ -0,0 +1,174 @@
import {
LayoutDashboard,
Boxes,
Network,
Cog,
ListChecks,
Activity,
Settings,
BookText,
KeyRound,
ServerCog,
Shield,
} from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@evobgp/ui/components/sidebar'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@evobgp/ui/components/breadcrumb'
import { Separator } from '@evobgp/ui/components/separator'
import { Link, useRouterState } from '@tanstack/react-router'
import type { ComponentType, ReactNode } from 'react'
import { ModeToggle } from '@/components/mode-toggle'
interface NavItem {
to: string
label: string
icon: ComponentType<{ className?: string }>
}
interface NavGroup {
label: string
items: NavItem[]
}
const NAV_GROUPS: NavGroup[] = [
{
label: 'Обзор',
items: [{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }],
},
{
label: 'Маршрутизация',
items: [
{ to: '/modules', label: 'Модули', icon: Boxes },
{ to: '/network', label: 'Сеть', icon: Network },
{ to: '/directories', label: 'Справочники', icon: BookText },
],
},
{
label: 'Операции',
items: [
{ to: '/operations', label: 'Операции', icon: Cog },
{ to: '/firewall', label: 'Firewall', icon: Shield },
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
],
},
{
label: 'Система',
items: [
{ to: '/access', label: 'Доступ', icon: KeyRound },
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog },
{ to: '/settings', label: 'Настройки UI', icon: Settings },
],
},
]
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
)
const PARENT_ROUTE: Record<string, string> = {}
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const activeItem =
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
ALL_NAV_ITEMS[0]
const parentTo = PARENT_ROUTE[activeItem.to]
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
return (
<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarHeader>
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
B
</div>
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">EvoBGP</span>
<span className="truncate text-xs text-muted-foreground">Control Plane</span>
</div>
</div>
</SidebarHeader>
<SidebarContent>
{NAV_GROUPS.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter />
</Sidebar>
<SidebarInset>
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
{parentLabel && parentTo ? (
<>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
</>
) : null}
<BreadcrumbItem>
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? activeItem.label}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<ModeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
</SidebarInset>
</SidebarProvider>
)
}
@@ -0,0 +1,19 @@
import { Button } from '@evobgp/ui/components/button'
import { Loader2Icon } from 'lucide-react'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
loading?: boolean
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
children: ReactNode
}
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
return (
<Button disabled={disabled || loading} {...props}>
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
{children}
</Button>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Сменить тему</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>Светлая</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Тёмная</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>Системная</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,76 @@
import { useMemo } from 'react'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
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 ?? '')
return (
<div className="flex flex-col gap-1.5">
{label ? <Label htmlFor={id}>{label}</Label> : null}
<Select
items={items}
value={selectValue}
onValueChange={(v) => {
if (!v) return
onValueChange(nullable ? fromNullableSelect(v) : v)
}}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '@/types/api'
interface ModuleAsEntryDialogProps {
open: boolean
moduleId: string
edit: AsEntry | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
export function ModuleAsEntryDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleAsEntryDialogProps) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<AsEntryCreate>({ asn: 0, community_id: null })
useEffect(() => {
if (!open) return
setForm(
edit
? { asn: edit.asn, community_id: edit.community_id }
: { asn: 0, community_id: null },
)
}, [open, edit])
async function save() {
const asn = Number(form.asn)
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
toast.error('Укажите корректный ASN (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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
<DialogDescription>
Номер автономной системы и community для политики анонса.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
value={form.asn || ''}
min={1}
max={4294967295}
onChange={(e) => setForm((s) => ({ ...s, asn: Number(e.target.value) }))}
/>
</div>
<CommunitySelect
id="as-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,277 @@
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Button } from '@evobgp/ui/components/button'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
const CDN_KIND_ITEMS = [
{ value: 'plaintext', label: 'plaintext' },
{ value: 'json', label: 'json' },
] as const
interface ModuleCdnSourceDialogProps {
open: boolean
moduleId: string
edit: CdnSource | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
type CdnForm = CdnSourceCreate & { refresh_interval_sec?: number | null }
export function ModuleCdnSourceDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleCdnSourceDialogProps) {
const [saving, setSaving] = useState(false)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewItems, setPreviewItems] = useState<string[]>([])
const [previewTotal, setPreviewTotal] = useState(0)
const [previewTruncated, setPreviewTruncated] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [previewOk, setPreviewOk] = useState(false)
const [form, setForm] = useState<CdnForm>({
url: '',
source_kind: 'plaintext',
prefix_path: '',
community_id: null,
})
const kindItems = useMemo(() => [...CDN_KIND_ITEMS], [])
function clearPreview() {
setPreviewLoading(false)
setPreviewItems([])
setPreviewTotal(0)
setPreviewTruncated(false)
setPreviewError(null)
setPreviewOk(false)
}
useEffect(() => {
if (!open) {
clearPreview()
return
}
setForm(
edit
? {
url: edit.url,
source_kind: normalizeCdnSourceKind(edit.source_kind),
prefix_path: edit.prefix_path ?? '',
community_id: edit.community_id,
refresh_interval_sec: edit.refresh_interval_sec,
}
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null },
)
clearPreview()
}, [open, edit])
async function previewCdn() {
const urlTrim = form.url.trim()
if (!urlTrim) {
toast.error('Укажите URL')
return
}
setPreviewLoading(true)
setPreviewError(null)
setPreviewOk(false)
try {
const res = await apiMutate<CdnPreviewResponse>(
`/v1/modules/${moduleId}/cdn-sources/preview`,
'POST',
{
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? '',
},
)
setPreviewItems(res.items)
setPreviewTotal(res.total)
setPreviewTruncated(res.truncated)
setPreviewOk(true)
} catch (e) {
setPreviewError(e instanceof ApiError ? e.message : String(e))
setPreviewItems([])
setPreviewTotal(0)
setPreviewTruncated(false)
setPreviewOk(false)
} finally {
setPreviewLoading(false)
}
}
async function save() {
const urlTrim = form.url.trim()
if (!urlTrim) {
toast.error('Укажите URL')
return
}
setSaving(true)
try {
const body = {
...form,
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? '',
}
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body)
toast.success('Источник обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body)
toast.success('Источник добавлен')
}
clearPreview()
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-url">URL</Label>
<Input
id="cdn-url"
placeholder="https://example.com/list.txt"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-kind">Тип источника</Label>
<Select
items={kindItems}
value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
>
<SelectTrigger id="cdn-kind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{kindItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
value={form.prefix_path ?? ''}
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
/>
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
<p className="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
) : null}
</div>
<CommunitySelect
id="cdn-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
value={form.refresh_interval_sec ?? ''}
onChange={(e) =>
setForm((s) => ({
...s,
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
}))
}
/>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void previewCdn()}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{previewError ? (
<span className="text-sm text-destructive">{previewError}</span>
) : previewOk ? (
<span className="text-sm text-muted-foreground">
Всего: {previewTotal}
{previewTruncated ? (
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
) : null}
</span>
) : null}
</div>
{previewItems.length > 0 ? (
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{previewItems.map((item, i) => (
<li key={`${i}-${item}`} className="py-0.5">
{item}
</li>
))}
</ul>
) : null}
</div>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '@/types/api'
interface ModuleDomainEntryDialogProps {
open: boolean
moduleId: string
edit: DomainEntry | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
export function ModuleDomainEntryDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleDomainEntryDialogProps) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<DomainEntryCreate>({ fqdn: '', community_id: null })
useEffect(() => {
if (!open) return
setForm(
edit
? { fqdn: edit.fqdn, community_id: edit.community_id }
: { fqdn: '', community_id: null },
)
}, [open, edit])
async function save() {
if (!form.fqdn.trim()) {
toast.error('Укажите FQDN')
return
}
setSaving(true)
try {
const body = { fqdn: form.fqdn.trim(), community_id: form.community_id }
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', body)
toast.success('Домен обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', body)
toast.success('Домен добавлен')
}
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-fqdn">FQDN</Label>
<Input
id="dom-fqdn"
placeholder="example.com"
value={form.fqdn}
onChange={(e) => setForm((s) => ({ ...s, fqdn: e.target.value }))}
/>
</div>
<CommunitySelect
id="dom-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,437 @@
import { useState } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@evobgp/ui/components/alert-dialog'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
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 { communityLabel } from '@/lib/modules/helpers'
import { formatDateTime } from '@/lib/modules/display'
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 =
| { kind: 'domain'; entry: DomainEntry }
| { kind: 'ip-range'; entry: IpRangeEntry }
| { kind: 'cdn'; entry: CdnSource }
| { kind: 'as'; entry: AsEntry }
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 (
<>
<Card>
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">{meta.title}</CardTitle>
<CardDescription>{meta.description}</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button size="sm" onClick={openCreate}>
<Plus />
Добавить
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<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) => (
<EntriesTable
mod={mod}
rows={rows}
communities={communities}
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>
</CardContent>
</Card>
{mod.type === 'DOMAINS' ? (
<ModuleDomainEntryDialog
open={dialogOpen}
moduleId={moduleId}
edit={editDomain}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
{mod.type === 'IP_RANGES' ? (
<ModuleIpRangeEntryDialog
open={dialogOpen}
moduleId={moduleId}
edit={editIpRange}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
{mod.type === 'CDN_CIDRS' ? (
<ModuleCdnSourceDialog
open={dialogOpen}
moduleId={moduleId}
edit={editCdn}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
{mod.type === 'AS_PREFIXES' ? (
<ModuleAsEntryDialog
open={dialogOpen}
moduleId={moduleId}
edit={editAs}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить запись?</AlertDialogTitle>
<AlertDialogDescription>{deleteDescription(deleteTarget)}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={() => void confirmDelete()}
>
{deleting ? 'Удаление…' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
function deleteDescription(target: DeleteTarget | null): string {
if (!target) return ''
switch (target.kind) {
case 'domain':
return target.entry.fqdn
case 'ip-range':
return target.entry.prefix
case 'cdn':
return target.entry.url
case 'as':
return `AS${target.entry.asn}`
}
}
function EntriesTable({
mod,
rows,
communities,
onEdit,
onDelete,
}: {
mod: ModuleRow
rows: Record<string, unknown>[]
communities: BgpCommunity[]
onEdit: (target: DeleteTarget) => void
onDelete: (target: DeleteTarget) => void
}) {
if (mod.type === 'DOMAINS') {
const entries = rows as unknown as DomainEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>FQDN</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.fqdn}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'domain', entry })}
onDelete={() => onDelete({ kind: 'domain', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
if (mod.type === 'IP_RANGES') {
const entries = rows as unknown as IpRangeEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Префикс (CIDR)</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.prefix}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'ip-range', entry })}
onDelete={() => onDelete({ kind: 'ip-range', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
if (mod.type === 'CDN_CIDRS') {
const entries = rows as unknown as CdnSource[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>URL</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Community</TableHead>
<TableHead>Обновлено</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="max-w-xs truncate font-mono text-xs">{entry.url}</TableCell>
<TableCell className="text-sm">{entry.source_kind}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(entry.last_refreshed_at)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'cdn', entry })}
onDelete={() => onDelete({ kind: 'cdn', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
const entries = rows as unknown as AsEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>ASN</TableHead>
<TableHead>Имя</TableHead>
<TableHead>Префиксов</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.asn}</TableCell>
<TableCell className="text-sm">{entry.asn_name ?? '—'}</TableCell>
<TableCell className="font-mono text-sm">{entry.prefix_count ?? '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'as', entry })}
onDelete={() => onDelete({ kind: 'as', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
return (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={onEdit} aria-label="Редактировать">
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
onClick={onDelete}
aria-label="Удалить"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '@/types/api'
interface ModuleIpRangeEntryDialogProps {
open: boolean
moduleId: string
edit: IpRangeEntry | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
export function ModuleIpRangeEntryDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleIpRangeEntryDialogProps) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<IpRangeEntryCreate>({ prefix: '', community_id: '' })
useEffect(() => {
if (!open) return
setForm(
edit
? { prefix: edit.prefix, community_id: edit.community_id }
: { prefix: '', community_id: '' },
)
}, [open, edit])
async function save() {
if (!form.prefix.trim() || !form.community_id) {
toast.error('Укажите префикс и community')
return
}
setSaving(true)
try {
const body = { prefix: form.prefix.trim(), community_id: form.community_id }
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', body)
toast.success('Диапазон обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', body)
toast.success('Диапазон добавлен')
}
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
<Input
id="ip-prefix"
placeholder="203.0.113.0/24"
value={form.prefix}
onChange={(e) => setForm((s) => ({ ...s, prefix: e.target.value }))}
/>
</div>
<CommunitySelect
id="ip-comm"
label="Community (обязательно)"
value={form.community_id || null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v ?? '' }))}
communities={communities}
placeholder="Выберите community"
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,84 @@
import {
ArrowDownUp,
Network,
RefreshCw,
ShieldCheck,
Timer,
} from 'lucide-react'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
import {
communityLabel,
dohProfileLabel,
moduleDohProfileIds,
} from '@/lib/modules/helpers'
import { dohPolicyRu } from '@/lib/ui-labels'
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
interface ModuleKpiCardsProps {
mod: ModuleRow | null
communities: BgpCommunity[]
dohProfiles: DohProfile[]
asEntries: AsEntry[]
loading?: boolean
}
export function ModuleKpiCards({
mod,
communities,
dohProfiles,
asEntries,
loading = false,
}: ModuleKpiCardsProps) {
if (loading || !mod) {
return <SectionCardsSkeleton count={5} />
}
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
const dohIds = moduleDohProfileIds(mod)
const items: SectionCardItem[] = [
{
label: 'Приоритет',
value: String(mod.priority ?? 0),
hint: 'порядок в сборке ревизии',
icon: <ArrowDownUp className="size-3.5" />,
},
{
label: 'Интервал',
value: moduleIntervalLabel(mod),
hint: 'refresh_interval_sec / cron',
icon: <Timer className="size-3.5" />,
},
{
label: 'DoH',
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
hint:
mod.type === 'DOMAINS'
? dohIds.length
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
: 'Системный DNS'
: 'не применимо',
icon: <Network className="size-3.5" />,
},
{
label: 'Community по умолч.',
value: communityLabel(mod.default_community_id, communities),
hint: 'для записей без своего community',
icon: <ShieldCheck className="size-3.5" />,
},
{
label: 'Последнее обновление',
value: formatDateTime(mod.last_refreshed_at),
hint:
mod.type === 'AS_PREFIXES'
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
: 'время последнего refresh',
icon: <RefreshCw className="size-3.5" />,
},
]
return <SectionCards items={items} />
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
}
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from 'react'
import { AlertCircle, RefreshCwIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { EmptyState } from './empty-state'
interface QueryStateProps<T> {
data: T | undefined
isLoading: boolean
isError: boolean
error?: unknown
empty?: boolean
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
onRetry?: () => void
skeleton?: ReactNode
children: (data: T) => ReactNode
}
export function QueryState<T>({
data,
isLoading,
isError,
error,
empty,
emptyTitle = 'Нет данных',
emptyDescription,
emptyAction,
onRetry,
skeleton,
children,
}: QueryStateProps<T>) {
if (isLoading) {
return <>{skeleton ?? <DefaultSkeleton />}</>
}
if (isError) {
return (
<EmptyState
icon={<AlertCircle className="size-8" />}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
)
}
if (empty || data == null) {
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
}
return <>{children(data)}</>
}
function DefaultSkeleton() {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" />
</div>
)
}
@@ -0,0 +1,343 @@
"use client"
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
const inputVariants = cva(
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
{
variants: {
size: {
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
default:
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
},
},
defaultVariants: {
size: "default",
},
}
)
const Autocomplete = AutocompletePrimitive.Root
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
return (
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
)
}
function AutocompleteInput({
className,
size = "default",
showClear = false,
showTrigger = false,
...props
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
VariantProps<typeof inputVariants> & {
showClear?: boolean
showTrigger?: boolean
}) {
return (
<div className="relative w-full">
<AutocompletePrimitive.Input
data-slot="autocomplete-input"
data-size={size}
className={cn(inputVariants({ size }), className)}
{...props}
/>
{showTrigger && <AutocompleteTrigger />}
{showClear && <AutocompleteClear />}
</div>
)
}
function AutocompleteStatus({
className,
...props
}: AutocompletePrimitive.Status.Props) {
return (
<AutocompletePrimitive.Status
data-slot="autocomplete-status"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
return (
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
)
}
function AutocompleteBackdrop({
...props
}: AutocompletePrimitive.Backdrop.Props) {
return (
<AutocompletePrimitive.Backdrop
data-slot="autocomplete-backdrop"
{...props}
/>
)
}
function AutocompletePositioner({
className,
...props
}: AutocompletePrimitive.Positioner.Props) {
return (
<AutocompletePrimitive.Positioner
data-slot="autocomplete-positioner"
className={cn("z-50 outline-none", className)}
{...props}
/>
)
}
function AutocompleteList({
className,
scrollAreaClassName,
...props
}: AutocompletePrimitive.List.Props & {
scrollAreaClassName?: string
scrollFade?: boolean
scrollbarGutter?: boolean
}) {
return (
<ScrollArea
className={cn(
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
scrollAreaClassName
)}
>
<AutocompletePrimitive.List
data-slot="autocomplete-list"
className={cn(
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
className
)}
{...props}
/>
</ScrollArea>
)
}
function AutocompleteCollection({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
return (
<AutocompletePrimitive.Collection
data-slot="autocomplete-collection"
{...props}
/>
)
}
function AutocompleteRow({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
return (
<AutocompletePrimitive.Row
data-slot="autocomplete-row"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function AutocompleteItem({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
return (
<AutocompletePrimitive.Item
data-slot="autocomplete-item"
className={cn(
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
className
)}
{...props}
/>
)
}
export interface AutocompleteContentProps extends React.ComponentProps<
typeof AutocompletePrimitive.Popup
> {
align?: AutocompletePrimitive.Positioner.Props["align"]
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
side?: AutocompletePrimitive.Positioner.Props["side"]
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
showBackdrop?: boolean
}
function AutocompleteContent({
className,
children,
showBackdrop = false,
align = "start",
sideOffset = 4,
alignOffset = 0,
side = "bottom",
anchor,
...props
}: AutocompleteContentProps) {
return (
<AutocompletePortal>
{showBackdrop && <AutocompleteBackdrop />}
<AutocompletePositioner
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
side={side}
anchor={anchor}
>
<div className="relative flex max-h-full">
<AutocompletePrimitive.Popup
data-slot="autocomplete-popup"
className={cn(
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
className
)}
{...props}
>
{children}
</AutocompletePrimitive.Popup>
</div>
</AutocompletePositioner>
</AutocompletePortal>
)
}
function AutocompleteGroup({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
return (
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
)
}
function AutocompleteGroupLabel({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
return (
<AutocompletePrimitive.GroupLabel
data-slot="autocomplete-group-label"
className={cn(
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
className
)}
{...props}
/>
)
}
function AutocompleteEmpty({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
return (
<AutocompletePrimitive.Empty
data-slot="autocomplete-empty"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompleteClear({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
return (
<AutocompletePrimitive.Clear
data-slot="autocomplete-clear"
className={cn(
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
className
)}
{...props}
>
<XIcon className="size-4" />
</AutocompletePrimitive.Clear>
)
}
function AutocompleteTrigger({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
return (
<AutocompletePrimitive.Trigger
data-slot="autocomplete-trigger"
className={cn(
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
className
)}
{...props}
>
<ChevronsUpDownIcon className="size-4 opacity-70" />
</AutocompletePrimitive.Trigger>
)
}
function AutocompleteArrow({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
return (
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
)
}
function AutocompleteSeparator({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
return (
<AutocompletePrimitive.Separator
data-slot="autocomplete-separator"
className={cn(
"bg-border my-1.5 h-px",
className
)}
{...props}
/>
)
}
export {
Autocomplete,
AutocompleteValue,
AutocompleteTrigger,
AutocompleteInput,
AutocompleteStatus,
AutocompletePortal,
AutocompleteBackdrop,
AutocompletePositioner,
AutocompleteContent,
AutocompleteList,
AutocompleteCollection,
AutocompleteRow,
AutocompleteItem,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteEmpty,
AutocompleteClear,
AutocompleteArrow,
AutocompleteSeparator,
}
+98
View File
@@ -0,0 +1,98 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
const badgeVariants = cva(
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
outline: "border-border bg-transparent dark:bg-input/32",
secondary: "bg-secondary text-secondary-foreground",
info: "bg-info text-white",
success: "bg-success text-white",
warning: "bg-warning text-white",
destructive: "bg-destructive text-white",
focus: "bg-focus text-focus-foreground",
invert: "bg-invert text-invert-foreground",
"primary-light":
"bg-primary/10 border-none text-primary dark:bg-primary/20",
"warning-light":
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20",
"success-light":
"bg-success/10 border-none text-success-foreground dark:bg-success/20",
"info-light":
"bg-info/10 border-none text-info-foreground dark:bg-info/20",
"destructive-light":
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20",
"invert-light":
"bg-invert/10 border-none text-foreground dark:bg-invert/20",
"focus-light":
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20",
"primary-outline":
"bg-background border-border text-primary dark:bg-input/30",
"warning-outline":
"bg-background border-border text-warning-foreground dark:bg-input/30",
"success-outline":
"bg-background border-border text-success-foreground dark:bg-input/30",
"info-outline":
"bg-background border-border text-info-foreground dark:bg-input/30",
"destructive-outline":
"bg-background border-border text-destructive-foreground dark:bg-input/30",
"invert-outline":
"bg-background border-border text-invert-foreground dark:bg-input/30",
"focus-outline":
"bg-background border-border text-focus-foreground dark:bg-input/30",
},
size: {
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
},
/** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */
radius: {
default:
"rounded-sm",
full: "rounded-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
radius: "default",
},
}
)
interface BadgeProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof badgeVariants>["variant"]
size?: VariantProps<typeof badgeVariants>["size"]
radius?: VariantProps<typeof badgeVariants>["radius"]
}
function Badge({
className,
variant,
size,
radius,
render,
...props
}: BadgeProps) {
const defaultProps = {
"data-slot": "badge",
className: cn(badgeVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { Badge, badgeVariants, type BadgeProps }
@@ -0,0 +1,167 @@
"use client"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Column } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import { Input } from "@evobgp/ui/components/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { Separator } from "@evobgp/ui/components/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
function DataGridColumnFilter<TData, TValue>({
column,
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
if (!searchQuery) return options
return options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [options, searchQuery])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm">
<CirclePlusIcon className="size-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
}
/>
<PopoverContent className="w-[200px] p-0" align="start">
<div className="p-2">
<Input
placeholder={title}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
No results found.
</div>
) : (
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<div
key={option.value}
onClick={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</div>
)
})}
</div>
)}
{selectedValues.size > 0 && (
<>
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
onClick={() => column?.setFilterValue(undefined)}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
export { DataGridColumnFilter, type DataGridColumnFilterProps }
@@ -0,0 +1,343 @@
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import { Column } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData,
TValue,
> extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
}
function DataGridColumnHeaderInner<TData, TValue>({
column,
title,
icon,
className,
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const canSort = column.getCanSort()
const canPin = column.getCanPin()
const canResize = column.getCanResize()
const columnIndex = columnOrder.indexOf(column.id)
const canMoveLeft = columnIndex > 0
const canMoveRight = columnIndex < columnOrder.length - 1
const handleSort = () => {
if (isSorted === "asc") {
column.toggleSorting(true)
} else if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
const headerLabelClassName = cn(
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
className
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" />
))
const hasControls =
props.tableLayout?.columnsMovable ||
(props.tableLayout?.columnsVisibility && visibility) ||
(props.tableLayout?.columnsPinnable && canPin) ||
filter
const menuItems = useMemo(() => {
const items: ReactNode[] = []
let hasPreviousSection = false
// Filter section
if (filter) {
items.push(
<DropdownMenuGroup key="group-filter">
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
</DropdownMenuGroup>
)
hasPreviousSection = true
}
// Sort section
if (canSort) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-sort" />)
}
items.push(
<DropdownMenuItem
key="sort-asc"
onClick={() => {
if (isSorted === "asc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}}
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">Asc</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="sort-desc"
onClick={() => {
if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(true)
}
}}
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">Desc</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Pin section
if (props.tableLayout?.columnsPinnable && canPin) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-pin" />)
}
items.push(
<DropdownMenuItem
key="pin-left"
onClick={() => column.pin(isPinned === "left" ? false : "left")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span>
{isPinned === "left" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="pin-right"
onClick={() => column.pin(isPinned === "right" ? false : "right")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span>
{isPinned === "right" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Move section
if (props.tableLayout?.columnsMovable) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-move" />)
}
items.push(
<DropdownMenuItem
key="move-left"
onClick={() => {
if (columnIndex > 0) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex - 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Left</span>
</DropdownMenuItem>,
<DropdownMenuItem
key="move-right"
onClick={() => {
if (columnIndex < columnOrder.length - 1) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex + 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Right</span>
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Visibility section
if (props.tableLayout?.columnsVisibility && visibility) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-visibility" />)
}
items.push(
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>Columns</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
className="capitalize"
>
{getColumnHeaderLabel(col)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filter,
canSort,
isSorted,
column,
props.tableLayout?.columnsPinnable,
props.tableLayout?.columnsMovable,
props.tableLayout?.columnsVisibility,
canPin,
isPinned,
canMoveLeft,
canMoveRight,
visibility,
table,
columnIndex,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
if (hasControls) {
return (
<div className="flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
}
/>
<DropdownMenuContent className="w-40" align="start">
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
<Button
size="icon-sm"
variant="ghost"
className="-me-1 size-7 rounded-md"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
)}
</div>
)
}
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
onClick={handleSort}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
</div>
)
}
return (
<div className={headerLabelClassName}>
{icon && icon}
{resolvedTitle}
</div>
)
}
const DataGridColumnHeader = memo(
DataGridColumnHeaderInner
) as typeof DataGridColumnHeaderInner
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -0,0 +1,53 @@
"use client"
import { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { Table } from "@tanstack/react-table"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
function DataGridColumnVisibility<TData>({
table,
trigger,
}: {
table: Table<TData>
trigger: ReactElement<Record<string, unknown>>
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnHeaderLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataGridColumnVisibility }
@@ -0,0 +1,228 @@
import React, { ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Skeleton } from "@evobgp/ui/components/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
interface DataGridPaginationProps {
sizes?: number[]
sizesInfo?: string
sizesLabel?: string
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
className?: string
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesLabel: "Show",
sizesDescription: "per page",
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
more: false,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
ellipsisText: "...",
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "size-7 p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const from = pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
const paginationInfo = mergedProps?.info
? mergedProps.info
.replace("{from}", from.toString())
.replace("{to}", to.toString())
.replace("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
// Pagination limit logic
const paginationMoreLimit = mergedProps?.moreLimit || 5
// Determine the start and end of the pagination group
const currentGroupStart =
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
const currentGroupEnd = Math.min(
currentGroupStart + paginationMoreLimit,
pageCount
)
// Render page buttons based on the current group
const renderPageButtons = () => {
const buttons = []
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
buttons.push(
<Button
key={i}
size="icon-sm"
variant="ghost"
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
onClick={() => {
if (pageIndex !== i) {
table.setPageIndex(i)
}
}}
>
{i + 1}
</Button>
)
}
return buttons
}
// Render a "previous" ellipsis button if there are previous pages to show
const renderEllipsisPrevButton = () => {
if (currentGroupStart > 0) {
return (
<Button
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
// Render a "next" ellipsis button if there are more pages to show after the current group
const renderEllipsisNextButton = () => {
if (currentGroupEnd < pageCount) {
return (
<Button
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
return (
<div
data-slot="data-grid-pagination"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps?.className
)}
>
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps?.sizesSkeleton
) : (
<>
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<Select
items={mergedProps?.sizes?.map((size: number) => ({
value: `${size}`,
label: `${size}`,
}))}
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="w-14" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps?.infoSkeleton
) : (
<>
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1 sm:order-2">
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">
{mergedProps.previousPageLabel}
</span>
<ChevronLeftIcon className="size-4" />
</Button>
{renderEllipsisPrevButton()}
{renderPageButtons()}
{renderEllipsisNextButton()}
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{mergedProps.nextPageLabel}</span>
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
export { DataGridPagination, type DataGridPaginationProps }
@@ -0,0 +1,421 @@
"use client"
import {
PointerEvent,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@evobgp/ui/lib/utils"
const MIN_THUMB_SIZE = 24
const FALLBACK_SCROLLBAR_SIZE = 12
const INITIAL_METRICS = {
hasVerticalOverflow: false,
headerHeight: 0,
horizontalScrollbarSize: 0,
thumbHeight: 0,
thumbTop: 0,
trackHeight: 0,
} as const
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
hasVerticalOverflow: boolean
headerHeight: number
horizontalScrollbarSize: number
thumbHeight: number
thumbTop: number
trackHeight: number
}
type ObservedElements = {
header: HTMLElement | null
horizontalScrollbar: HTMLElement | null
table: HTMLElement | null
tableViewport: HTMLElement | null
}
type DataGridScrollAreaProps = Omit<
ScrollAreaPrimitive.Root.Props,
"children"
> & {
children: ReactNode
orientation?: DataGridScrollAreaOrientation
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
return (
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
next.headerHeight === prev.headerHeight &&
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
next.thumbHeight === prev.thumbHeight &&
next.thumbTop === prev.thumbTop &&
next.trackHeight === prev.trackHeight
)
}
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
element.style.setProperty(
"--data-grid-scrollbar-header-height",
`${metrics.headerHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-height",
`${metrics.thumbHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-top",
`${metrics.thumbTop}px`
)
element.style.setProperty(
"--data-grid-scrollbar-track-height",
`${metrics.trackHeight}px`
)
}
function DataGridScrollArea({
children,
className,
orientation = "both",
...props
}: DataGridScrollAreaProps) {
const { props: dataGridProps } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
startScrollTop: number
startY: number
} | null>(null)
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
const observedElementsRef = useRef<ObservedElements>({
header: null,
horizontalScrollbar: null,
table: null,
tableViewport: null,
})
const showHorizontal = orientation !== "vertical"
const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false)
const clearDragState = useCallback(() => {
dragRef.current = null
document.body.style.userSelect = ""
document.body.style.webkitUserSelect = ""
}, [])
const resetMetrics = useCallback(() => {
const container = containerRef.current
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
applyMetrics(container, INITIAL_METRICS)
metricsRef.current = INITIAL_METRICS
}
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
}, [])
const syncCustomVerticalScrollbar = useCallback(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport || !usesCustomVerticalScrollbar) {
resetMetrics()
return
}
const { header, horizontalScrollbar } = observedElementsRef.current
const headerHeight = header?.getBoundingClientRect().height ?? 0
const viewportHeight = viewport.clientHeight
const viewportWidth = viewport.clientWidth
const scrollHeight = viewport.scrollHeight
const scrollWidth = viewport.scrollWidth
const hasHorizontalOverflow =
showHorizontal && scrollWidth > viewportWidth + 0.5
const horizontalScrollbarSize = hasHorizontalOverflow
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
: 0
const trackHeight = Math.max(
0,
viewportHeight - headerHeight - horizontalScrollbarSize
)
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
let nextMetrics: ScrollbarMetrics
if (trackHeight === 0 || maxScroll === 0) {
nextMetrics = {
hasVerticalOverflow: false,
headerHeight,
horizontalScrollbarSize,
thumbHeight: trackHeight,
thumbTop: 0,
trackHeight,
}
} else {
const bodyContentHeight = Math.max(
trackHeight,
scrollHeight - headerHeight
)
const thumbHeight = clamp(
trackHeight * (trackHeight / bodyContentHeight),
MIN_THUMB_SIZE,
trackHeight
)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const thumbTop =
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
nextMetrics = {
hasVerticalOverflow: true,
headerHeight,
horizontalScrollbarSize,
thumbHeight,
thumbTop,
trackHeight,
}
}
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
applyMetrics(container, nextMetrics)
metricsRef.current = nextMetrics
}
setHasCustomVerticalOverflow((prev) =>
prev === nextMetrics.hasVerticalOverflow
? prev
: nextMetrics.hasVerticalOverflow
)
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
useEffect(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport) return
if (!usesCustomVerticalScrollbar) {
resetMetrics()
return
}
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
let frame = 0
const scheduleSync = () => {
cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
observer?.observe(viewport)
observedElementsRef.current.header &&
observer?.observe(observedElementsRef.current.header)
observedElementsRef.current.table &&
observer?.observe(observedElementsRef.current.table)
observedElementsRef.current.tableViewport &&
observer?.observe(observedElementsRef.current.tableViewport)
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
}, [
clearDragState,
resetMetrics,
syncCustomVerticalScrollbar,
usesCustomVerticalScrollbar,
])
const scrollToThumbOffset = (nextThumbTop: number) => {
const viewport = viewportRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport) return
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
if (maxScroll === 0 || maxThumbTop === 0) {
viewport.scrollTop = 0
return
}
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
viewport.scrollTop = ratio * maxScroll
}
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
if (!viewport) return
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = {
pointerId: event.pointerId,
startScrollTop: viewport.scrollTop,
startY: event.clientY,
}
document.body.style.userSelect = "none"
document.body.style.webkitUserSelect = "none"
}
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
const dragState = dragRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
if (maxThumbTop === 0 || maxScroll === 0) return
const deltaY = event.clientY - dragState.startY
const nextScrollTop =
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
}
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return
clearDragState()
}
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const { thumbHeight } = metricsRef.current
if (event.target !== event.currentTarget) return
event.preventDefault()
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
const offsetY = event.clientY - rect.top - thumbHeight / 2
scrollToThumbOffset(offsetY)
}
return (
<div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="size-full"
>
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{showHorizontal && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
)}
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
)}
</ScrollAreaPrimitive.Root>
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
className="pointer-events-auto relative h-full w-2 touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-2",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full"
)}
onLostPointerCapture={clearDragState}
onPointerCancel={handleThumbPointerUp}
onPointerDown={handleThumbPointerDown}
onPointerMove={handleThumbPointerMove}
onPointerUp={handleThumbPointerUp}
/>
</div>
</div>
)}
</div>
)
}
export { DataGridScrollArea }
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
@@ -0,0 +1,307 @@
import {
createContext,
CSSProperties,
ReactNode,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import { GripHorizontalIcon } from "lucide-react"
// Context to share sortable listeners from row to handle
type SortableContextValue = ReturnType<typeof useSortable>
const SortableRowContext = createContext<Pick<
SortableContextValue,
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
const context = useContext(SortableRowContext)
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
disabled
>
<GripHorizontalIcon
/>
</Button>
)
}
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon
/>
</Button>
)
}
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
const {
transform,
transition,
setNodeRef,
isDragging,
attributes,
listeners,
} = useSortable({
id: row.id,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
}
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow
row={row}
dndRef={setNodeRef}
dndStyle={style}
key={row.id}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
return (
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
</DataGridTableBodyRow>
</SortableRowContext.Provider>
)
}
function DataGridTableDndRows<TData>({
handleDragEnd,
dataIds,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingRow) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
}) => {
if (!tableContainerRef.current || !draggingNodeRect) {
return transform
}
const containerRect = tableContainerRef.current.getBoundingClientRect()
const { x, y } = transform
const minX = containerRect.left - draggingNodeRect.left
const maxX = containerRect.right - draggingNodeRect.right
const minY = containerRect.top - draggingNodeRect.top
const maxY = containerRect.bottom - draggingNodeRect.bottom
return {
...transform,
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
return [restrictToVerticalAxis, restrictToTableContainer]
}, [])
return (
<DndContext
id={useId()}
collisionDetection={closestCenter}
modifiers={modifiers}
onDragCancel={() => setIsDraggingRow(false)}
onDragEnd={(event) => {
setIsDraggingRow(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingRow(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, index) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
@@ -0,0 +1,314 @@
"use client"
import {
CSSProperties,
Fragment,
ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
Header,
HeaderGroup,
Row,
} from "@tanstack/react-table"
import { Button } from "@evobgp/ui/components/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData>({
header,
}: {
header: Header<TData, unknown>
}) {
const { props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? `calc(var(--header-${header.id}-size) * 1px)`
: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableHeadRowCell
header={header}
dndStyle={style}
dndRef={setNodeRef}
>
<div className="flex items-center justify-start gap-0.5">
{canOrder && (
<Button
size="icon-sm"
variant="ghost"
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<span className="grow truncate">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</span>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
function DataGridTableDnd<TData>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingColumn) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={[restrictToTableBounds]}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => {
return (
<SortableContext
key={cell.id}
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
<DataGridTableDndCell cell={cell} />
</SortableContext>
)
})}
</DataGridTableBodyRow>
{row.getIsExpanded() && (
<DataGridTableBodyRowExpandded row={row} />
)}
</Fragment>
)
})
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
@@ -0,0 +1,490 @@
import {
memo,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridTableRowSections,
} from "@/components/reui/data-grid/data-grid-table"
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
import {
useVirtualizer,
VirtualItem,
Virtualizer,
VirtualizerOptions,
} from "@tanstack/react-virtual"
import { cn } from "@evobgp/ui/lib/utils"
import { Spinner } from "@evobgp/ui/components/spinner"
type DataGridTableVirtualScrollElements = {
containerElement: HTMLDivElement | null
scrollElement: HTMLElement | null
}
type DataGridTableVirtualizerInstance = Virtualizer<
HTMLElement,
HTMLTableRowElement
>
type DataGridTableVirtualizerOptions<TData> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & {
estimateSize?: (index: number, row: Row<TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number
getScrollElement?: (
elements: DataGridTableVirtualScrollElements
) => HTMLElement | null
}
interface DataGridTableVirtualProps<TData> {
height?: number | string
estimateSize?: number
overscan?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
isFetchingMore?: boolean
hasMore?: boolean
fetchMoreOffset?: number
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
}
interface VirtualBodyProps<TData> {
table: Table<TData>
columnCount: number
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
virtualItems: VirtualItem[]
totalSize: number
isVirtualizationEnabled: boolean
isInfiniteMode: boolean
isFetchingMore: boolean
hasMore?: boolean
loadingMoreMessage: ReactNode
allRowsLoadedMessage: ReactNode
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualSpacer({
columnCount,
height,
}: {
columnCount: number
height: number
}) {
if (height <= 0) return null
return (
<tr aria-hidden="true">
<td colSpan={columnCount} style={{ height, padding: 0 }} />
</tr>
)
}
function DataGridTableVirtualStatusRow({
children,
className,
columnCount,
}: {
children: ReactNode
className?: string
columnCount: number
}) {
return (
<tr>
<td
colSpan={columnCount}
className={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</td>
</tr>
)
}
function DataGridTableVirtualBody<TData>({
table,
columnCount,
topRows,
centerRows,
bottomRows,
virtualItems,
totalSize,
isVirtualizationEnabled,
isInfiniteMode,
isFetchingMore,
hasMore,
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) return <DataGridTableEmpty />
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
const leadingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? (virtualItems[0]?.start ?? 0)
: 0
const trailingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? Math.max(
0,
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
)
: 0
const renderedRows: ReactNode[] = []
topRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
}
/>
)
})
if (isVirtualizationEnabled) {
if (leadingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-start"
columnCount={columnCount}
height={leadingSpacerHeight}
/>
)
}
virtualItems.forEach((virtualRow) => {
const row = centerRows[virtualRow.index]
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
/>
)
})
if (trailingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
columnCount={columnCount}
height={trailingSpacerHeight}
/>
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
})
}
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-loading"
columnCount={columnCount}
>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
if (showCompleteRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-complete"
columnCount={columnCount}
className="py-3 text-xs"
>
{allRowsLoadedMessage}
</DataGridTableVirtualStatusRow>
)
}
bottomRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === 0 && (topRows.length > 0 || hasMiddleSection)
? "bottom"
: undefined
}
/>
)
})
return <>{renderedRows}</>
}
/**
* Memoized virtual body: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({
height,
estimateSize = 48,
overscan = 10,
footerContent,
renderHeader = true,
onFetchMore,
isFetchingMore = false,
hasMore,
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
const columnCount =
table.getVisibleFlatColumns().length +
(props.tableLayout?.columnsResizable ? 1 : 0)
const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({
containerElement: null,
scrollElement: null,
})
const {
estimateSize: customEstimateSize,
getItemKey: customGetItemKey,
getScrollElement: customGetScrollElement,
measureElement: customMeasureElement,
overscan: customOverscan,
...virtualizerOptionsRest
} = virtualizerOptions ?? {}
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
containerElement: node,
scrollElement:
(node?.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null) ?? node,
})
}, [])
const usesExternalScrollArea =
viewportElements.scrollElement !== null &&
viewportElements.scrollElement !== viewportElements.containerElement
const resolveScrollElement = useCallback(() => {
if (customGetScrollElement) {
return customGetScrollElement(viewportElements)
}
return viewportElements.scrollElement
}, [customGetScrollElement, viewportElements])
const resolveItemKey = useCallback(
(index: number) => {
const row = centerRows[index]
if (!row) return index
return customGetItemKey?.(index, row) ?? row.id ?? index
},
[centerRows, customGetItemKey]
)
const resolveEstimateSize = useCallback(
(index: number) => {
const row = centerRows[index]
return row
? (customEstimateSize?.(index, row) ?? estimateSize)
: estimateSize
},
[centerRows, customEstimateSize, estimateSize]
)
const virtualizer = useVirtualizer({
count: centerRows.length,
getScrollElement: resolveScrollElement,
getItemKey: resolveItemKey,
estimateSize: resolveEstimateSize,
overscan: customOverscan ?? overscan,
measureElement: customMeasureElement,
...virtualizerOptionsRest,
}) as DataGridTableVirtualizerInstance
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
const measureRowRef =
isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = useMemo(
() => Math.max(0, fetchMoreOffset),
[fetchMoreOffset]
)
useEffect(() => {
if (
!isVirtualizationEnabled ||
!isInfiniteMode ||
hasMore === false ||
isFetchingMore
) {
return
}
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
onFetchMore?.()
}
}, [
centerRows.length,
hasMore,
isFetchingMore,
isInfiniteMode,
isVirtualizationEnabled,
onFetchMore,
resolvedFetchMoreOffset,
virtualItems,
])
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: { height, overflow: "auto", position: "relative" }
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, hIndex) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={hIndex}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
columnCount={columnCount}
topRows={topRows}
centerRows={centerRows}
bottomRows={bottomRows}
virtualItems={virtualItems}
totalSize={totalSize}
isVirtualizationEnabled={isVirtualizationEnabled}
isInfiniteMode={isInfiniteMode}
isFetchingMore={isFetchingMore}
hasMore={hasMore}
loadingMoreMessage={loadingMoreMessage}
allRowsLoadedMessage={allRowsLoadedMessage}
measureRowRef={measureRowRef}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
)
}
export { DataGridTableVirtual }
export type {
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,268 @@
import { createContext, ReactNode, useContext, useMemo } from "react"
import {
Column,
ColumnFiltersState,
RowData,
SortingState,
Table,
} from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
headerTitle?: string
headerClassName?: string
cellClassName?: string
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
}
}
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
export function getColumnHeaderLabel<TData, TValue>(
column: Column<TData, TValue>
): string {
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
if (typeof meta?.headerTitle === "string") return meta.headerTitle
const defHeader = column.columnDef.header
if (typeof defHeader === "string") return defHeader
return String(column.id)
}
export type DataGridApiFetchParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
filters?: ColumnFiltersState
searchQuery?: string
}
export type DataGridApiResponse<T> = {
data: T[]
empty: boolean
pagination: {
total: number
page: number
}
}
export interface DataGridContextProps<TData extends object> {
props: DataGridProps<TData>
table: Table<TData>
recordCount: number
isLoading: boolean
}
export type DataGridRequestParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
columnFilters?: ColumnFiltersState
}
export interface DataGridProps<TData extends object> {
className?: string
table?: Table<TData>
recordCount: number
children?: ReactNode
onRowClick?: (row: TData) => void
isLoading?: boolean
loadingMode?: "skeleton" | "spinner"
loadingMessage?: ReactNode | string
fetchingMoreMessage?: ReactNode | string
allRowsLoadedMessage?: ReactNode | string
emptyMessage?: ReactNode | string
tableLayout?: {
dense?: boolean
cellBorder?: boolean
rowBorder?: boolean
rowRounded?: boolean
stripped?: boolean
headerBackground?: boolean
headerBorder?: boolean
headerSticky?: boolean
width?: "auto" | "fixed"
columnsVisibility?: boolean
columnsResizable?: boolean
columnsResizeMode?: "onChange" | "onEnd"
columnsPinnable?: boolean
columnsMovable?: boolean
columnsDraggable?: boolean
rowsDraggable?: boolean
rowsPinnable?: boolean
}
tableClassNames?: {
base?: string
header?: string
headerRow?: string
headerSticky?: string
body?: string
bodyRow?: string
footer?: string
edgeCell?: string
}
}
const DataGridContext = createContext<
DataGridContextProps<any> | undefined
>(undefined)
function useDataGrid() {
const context = useContext(DataGridContext)
if (!context) {
throw new Error("useDataGrid must be used within a DataGridProvider")
}
return context
}
function DataGridProvider<TData extends object>({
children,
table,
...props
}: DataGridProps<TData> & { table: Table<TData> }) {
const tableState = table.getState()
const resolvedColumnsResizeMode =
props.tableLayout?.columnsResizeMode ?? "onEnd"
// Keep resize mode aligned with the DataGrid contract every render so
// consumer-level useReactTable options cannot flip it back between drags.
if (props.tableLayout?.columnsResizable) {
table.options.columnResizeMode = resolvedColumnsResizeMode
}
// Memoize context value so consumers don't re-render during column resize.
// Column sizing state is intentionally excluded from deps -- CSS variables
// on the <table> element handle width updates without React re-renders.
const value = useMemo(
() => ({
props,
table,
recordCount: props.recordCount,
isLoading: props.isLoading || false,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
table,
props.recordCount,
props.isLoading,
props.loadingMode,
props.loadingMessage,
props.fetchingMoreMessage,
props.allRowsLoadedMessage,
props.emptyMessage,
props.onRowClick,
props.className,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableLayout),
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableClassNames),
tableState.sorting,
tableState.pagination,
tableState.columnFilters,
tableState.rowSelection,
tableState.expanded,
tableState.columnVisibility,
tableState.columnOrder,
tableState.columnPinning,
tableState.globalFilter,
]
)
return (
<DataGridContext.Provider value={value}>
{children}
</DataGridContext.Provider>
)
}
function DataGrid<TData extends object>({
children,
table,
...props
}: DataGridProps<TData>) {
const defaultProps: Partial<DataGridProps<TData>> = {
loadingMode: "skeleton",
tableLayout: {
dense: false,
cellBorder: false,
rowBorder: true,
rowRounded: false,
stripped: false,
headerSticky: false,
headerBackground: true,
headerBorder: true,
width: "fixed",
columnsVisibility: false,
columnsResizable: false,
columnsResizeMode: "onEnd",
columnsPinnable: false,
columnsMovable: false,
columnsDraggable: false,
rowsDraggable: false,
rowsPinnable: false,
},
tableClassNames: {
base: "",
header: "",
headerRow: "",
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
body: "",
bodyRow: "",
footer: "",
edgeCell: "",
},
}
const mergedProps: DataGridProps<TData> = {
...defaultProps,
...props,
tableLayout: {
...defaultProps.tableLayout,
...(props.tableLayout || {}),
},
tableClassNames: {
...defaultProps.tableClassNames,
...(props.tableClassNames || {}),
},
}
// Ensure table is provided
if (!table) {
throw new Error('DataGrid requires a "table" prop')
}
return (
<DataGridProvider table={table} {...mergedProps}>
{children}
</DataGridProvider>
)
}
function DataGridContainer({
children,
className,
border = true,
}: {
children: ReactNode
className?: string
border?: boolean
}) {
return (
<div
data-slot="data-grid"
className={cn(
"w-full overflow-hidden",
border &&
"border-border rounded-lg border",
className
)}
>
{children}
</div>
)
}
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,258 @@
import { createContext, ReactNode, useContext, useId } from "react"
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
import { cva, VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
import { Label } from "@evobgp/ui/components/label"
import { MinusIcon, PlusIcon } from "lucide-react"
const NumberFieldContext = createContext<{
fieldId: string
size: "sm" | "default" | "lg"
} | null>(null)
const numberFieldGroupVariants = cva(
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
{
variants: {
size: {
sm: "h-7 text-sm",
default:
"h-8 text-sm",
lg: "h-9 text-sm",
},
},
defaultVariants: {
size: "default",
},
}
)
const numberFieldButtonVariants = cva(
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
{
variants: {
size: {
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
default:
"px-2 [&_svg:not([class*='size-'])]:size-4",
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
size: "default",
},
}
)
const numberFieldInputVariants = cva(
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
{
variants: {
size: {
sm: "px-2 py-0.5",
default:
"px-2.5 py-1",
lg: "px-2.5 py-1.5",
},
},
defaultVariants: {
size: "default",
},
}
)
function NumberField({
id,
className,
size = "default",
...props
}: NumberFieldPrimitive.Root.Props &
VariantProps<typeof numberFieldGroupVariants>) {
const generatedId = useId()
const fieldId = id ?? generatedId
const sizeValue = size ?? "default"
return (
<NumberFieldContext.Provider value={{ fieldId, size: sizeValue }}>
<NumberFieldPrimitive.Root
className={cn("flex w-full flex-col items-start gap-2", className)}
data-size={sizeValue}
data-slot="number-field"
id={fieldId}
{...props}
/>
</NumberFieldContext.Provider>
)
}
function NumberFieldGroup({
className,
size: sizeProp,
...props
}: NumberFieldPrimitive.Group.Props &
Partial<VariantProps<typeof numberFieldGroupVariants>>) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldGroup must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Group
className={cn(numberFieldGroupVariants({ size }), className)}
data-slot="number-field-group"
{...props}
/>
)
}
function NumberFieldDecrement({
className,
size: sizeProp,
children,
...props
}: NumberFieldPrimitive.Decrement.Props &
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
children?: React.ReactNode
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldDecrement must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Decrement
className={cn(
numberFieldButtonVariants({ size }),
"rounded-s-lg border-e-0",
className
)}
data-slot="number-field-decrement"
{...props}
>
{children ?? (
<MinusIcon
/>
)}
</NumberFieldPrimitive.Decrement>
)
}
function NumberFieldIncrement({
className,
size: sizeProp,
children,
...props
}: NumberFieldPrimitive.Increment.Props &
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
children?: ReactNode
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldIncrement must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Increment
className={cn(
numberFieldButtonVariants({ size }),
"rounded-e-lg border-s-0",
className
)}
data-slot="number-field-increment"
{...props}
>
{children ?? (
<PlusIcon
/>
)}
</NumberFieldPrimitive.Increment>
)
}
function NumberFieldInput({
className,
size: sizeProp,
...props
}: NumberFieldPrimitive.Input.Props &
Partial<VariantProps<typeof numberFieldInputVariants>>) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldInput must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Input
className={cn(numberFieldInputVariants({ size }), className)}
data-slot="number-field-input"
{...props}
/>
)
}
function NumberFieldScrubArea({
className,
label,
...props
}: NumberFieldPrimitive.ScrubArea.Props & {
label: string
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
)
}
return (
<NumberFieldPrimitive.ScrubArea
className={cn("flex cursor-ew-resize", className)}
data-slot="number-field-scrub-area"
{...props}
>
<Label className="cursor-ew-resize" htmlFor={context.fieldId}>
{label}
</Label>
<NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
<CursorGrowIcon />
</NumberFieldPrimitive.ScrubAreaCursor>
</NumberFieldPrimitive.ScrubArea>
)
}
function CursorGrowIcon(props: React.ComponentProps<"svg">) {
return (
<svg
fill="black"
height="14"
stroke="white"
viewBox="0 0 24 14"
width="26"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
</svg>
)
}
export {
NumberField,
NumberFieldScrubArea,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldGroup,
NumberFieldInput,
}
+109
View File
@@ -0,0 +1,109 @@
import type { ReactElement, ReactNode } from 'react'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { cn } from '@evobgp/ui/lib/utils'
import { TruncatedText } from '@/components/truncated-text'
export interface SectionCardItem {
label: ReactNode
value: string | number | ReactElement
hint?: ReactNode
icon?: ReactNode
badge?: ReactNode
variant?: 'default' | 'warning' | 'destructive'
active?: boolean
onClick?: () => void
}
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'border-warning/50',
destructive: 'border-destructive/50',
}
function sectionGridClass(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'sm:grid-cols-2'
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4'
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6'
return 'sm:grid-cols-2 lg:grid-cols-3'
}
const VALUE_VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'text-warning-foreground',
destructive: 'text-destructive',
}
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
return (
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
{items.map((item, idx) => {
const clickable = Boolean(item.onClick)
const content = (
<CardContent className="flex items-start gap-2.5 px-3 py-2.5">
{item.icon ? (
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
{item.icon}
</span>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center justify-between gap-2">
{typeof item.label === 'string' ? (
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
) : (
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
)}
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
</div>
<div className="flex min-w-0 items-baseline gap-1.5">
<span
className={cn(
'flex items-center gap-1 text-lg font-semibold tabular-nums',
VALUE_VARIANT_CLASS[item.variant ?? 'default'],
)}
>
{item.value}
</span>
{item.hint ? (
typeof item.hint === 'string' ? (
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
) : (
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
)
) : null}
</div>
</div>
</CardContent>
)
return (
<Card
key={typeof item.label === 'string' ? item.label : idx}
className={cn(
'gap-0',
VARIANT_CLASS[item.variant ?? 'default'],
item.active && 'border-primary ring-1 ring-primary/30',
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
)}
onClick={item.onClick}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
item.onClick?.()
}
}
: undefined
}
>
{content}
</Card>
)
})}
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { SectionCards } from './section-cards'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { Card, CardContent } from '@evobgp/ui/components/card'
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
return (
<SectionCards
items={Array.from({ length: count }, (_, i) => ({
icon: <Skeleton className="size-4 rounded-sm" key={`icon-${i}`} />,
label: <Skeleton className="h-3 w-20" key={`label-${i}`} />,
value: <Skeleton className="h-5 w-16" key={`value-${i}`} />,
}))}
/>
)
}
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
return (
<Card className="gap-0">
<CardContent className="p-0">
<div className="flex flex-col">
<div className="flex gap-2 border-b p-3">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
))}
</div>
{Array.from({ length: rows }).map((_, r) => (
<div className="flex gap-2 border-b p-3" key={`r-${r}`}>
{Array.from({ length: cols }).map((_, c) => (
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
))}
</div>
))}
</div>
</CardContent>
</Card>
)
}
+30
View File
@@ -0,0 +1,30 @@
import type { ComponentProps } from 'react'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'success',
ok: 'success',
paid: 'success',
established: 'success',
succeeded: 'success',
healthy: 'success',
paused: 'secondary',
disabled: 'secondary',
archived: 'outline',
error: 'destructive',
failed: 'destructive',
running: 'info',
queued: 'info',
overdue: 'warning',
stale: 'warning',
warning: 'warning',
mismatch: 'warning',
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
const variant = STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
return <Badge variant={variant}>{label ?? status}</Badge>
}
@@ -0,0 +1,16 @@
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import type { ReactNode } from 'react'
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
storageKey="evobgp-theme"
>
{children}
</NextThemesProvider>
)
}
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@evobgp/ui/components/tooltip'
import { cn } from '@evobgp/ui/lib/utils'
interface TruncatedTextProps {
children: ReactNode
className?: string
as?: 'span' | 'p' | 'div'
/** Явный текст подсказки, если children — не строка. */
tooltip?: string
}
export function TruncatedText({ children, className, as: Tag = 'span', tooltip }: TruncatedTextProps) {
const tip =
tooltip ??
(typeof children === 'string' || typeof children === 'number' ? String(children) : null)
if (!tip) {
return <Tag className={cn('truncate', className)}>{children}</Tag>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<Tag className={cn('truncate', className)} />}>{children}</TooltipTrigger>
<TooltipContent>{tip}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { ApiKeyRole } from '@/types/api'
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: 'viewer — только чтение' },
{ value: 'editor', label: 'editor — CRUD без apply' },
{ value: 'operator', label: 'operator — полный доступ' },
{ value: 'node', label: 'node — только API ноды' },
]
export function apiKeyRoleLabel(role: ApiKeyRole): string {
return API_KEY_ROLE_ITEMS.find((o) => o.value === role)?.label ?? role
}
export function formatApiKeyDate(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleString('ru-RU')
}
+220
View File
@@ -0,0 +1,220 @@
import type {
AsEntriesResponse,
CdnSourcesResponse,
DomainEntriesResponse,
IpRangeEntriesResponse,
JobRow,
ModuleType,
RevisionPrefix,
RevisionPrefixesResponse,
} from '@/types/api'
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
/** Локальный demo-токен (operator) при включённом demo-seed — см. docs/access.md */
export const DEV_API_TOKEN = 'dev'
export type Problem = {
type?: string
title?: string
status?: number
detail?: string
}
/** Убирает пробелы и опциональный префикс Bearer (UI часто вставляет «Bearer dev»). */
export function normalizeApiToken(raw: string): string {
let t = raw.trim()
if (/^bearer\s+/i.test(t)) {
t = t.replace(/^bearer\s+/i, '').trim()
}
return t
}
function getToken(): string | null {
if (typeof window === 'undefined') return null
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
if (!raw) return null
const normalized = normalizeApiToken(raw)
return normalized || null
}
export function setToken(token: string | null): void {
if (typeof window === 'undefined') return
if (!token) {
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
return
}
const normalized = normalizeApiToken(token)
if (normalized) window.localStorage.setItem(TOKEN_STORAGE_KEY, normalized)
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
}
function mergeHeaders(init?: RequestInit, extraHeaders?: Record<string, string>): Headers {
const h = new Headers(init?.headers)
if (!h.has('Accept')) h.set('Accept', 'application/json')
const t = getToken()
if (t && !h.has('Authorization')) h.set('Authorization', `Bearer ${t}`)
if (extraHeaders) {
for (const [k, v] of Object.entries(extraHeaders)) {
if (!h.has(k)) h.set(k, v)
}
}
return h
}
/**
* Idempotency keys: `crypto.randomUUID()` exists only in secure contexts (HTTPS / localhost).
* Over plain HTTP to a LAN IP it is often undefined — use getRandomValues or a fallback.
*/
function newIdempotencyKey(): string {
const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined
if (c?.randomUUID) return c.randomUUID()
if (c?.getRandomValues) {
const buf = new Uint8Array(16)
c.getRandomValues(buf)
buf[6] = (buf[6]! & 0x0f) | 0x40
buf[8] = (buf[8]! & 0x3f) | 0x80
const hex = [...buf].map((b) => b.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`
}
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly problem?: Problem,
) {
super(message)
}
}
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
if (typeof window === 'undefined') throw new Error('API is only available in the browser')
return fetch(path, { ...init, headers: mergeHeaders(init) })
}
/** GET / DELETE без тела */
export async function apiJSON<T>(path: string, init?: RequestInit): Promise<T> {
const res = await apiFetch(path, init)
return parseResponse<T>(res)
}
/** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */
export async function apiMutate<T = void>(
path: string,
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
body?: unknown,
opts?: { idempotent?: boolean },
): Promise<T> {
const headers: Record<string, string> = {}
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (opts?.idempotent !== false) {
headers['Idempotency-Key'] = newIdempotencyKey()
}
const res = await fetch(path, {
method,
headers: mergeHeaders({ headers }, headers),
body: body !== undefined ? JSON.stringify(body) : undefined,
})
return parseResponse<T>(res)
}
async function parseResponse<T>(res: Response): Promise<T> {
if (res.status === 204 || res.status === 205) return undefined as T
const text = await res.text()
if (!res.ok) {
let problem: Problem | undefined
let detail = `HTTP ${res.status}`
try {
problem = JSON.parse(text) as Problem
detail = problem.detail ?? problem.title ?? detail
} catch {
if (text) detail = text
}
throw new ApiError(res.status, detail, problem)
}
if (!text) return undefined as T
return JSON.parse(text) as T
}
const terminalJobStatuses = new Set(['succeeded', 'failed', 'cancelled'])
/** Ожидает завершения фоновой задачи (poll GET /v1/jobs/{id}). */
export async function waitForJob(
jobId: string,
opts?: { pollMs?: number; timeoutMs?: number },
): Promise<JobRow> {
const pollMs = opts?.pollMs ?? 400
const timeoutMs = opts?.timeoutMs ?? 120000
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const j = await apiJSON<JobRow>(`/v1/jobs/${jobId}`)
if (terminalJobStatuses.has(j.status)) return j
await new Promise((r) => setTimeout(r, pollMs))
}
throw new Error(`Таймаут ожидания задачи ${jobId}`)
}
export async function apiPageAll<T>(path: string, limit = 500): Promise<T[]> {
const items: T[] = []
let cursor: string | null = null
while (true) {
const [basePath, rawQuery = ''] = path.split('?')
const query = new URLSearchParams(rawQuery)
if (!query.has('limit')) query.set('limit', String(limit))
if (cursor) query.set('cursor', cursor)
else query.delete('cursor')
const page = await apiJSON<{ items?: T[]; next_cursor?: string | null; has_more?: boolean }>(
`${basePath}?${query.toString()}`,
)
items.push(...(page.items ?? []))
if (!page.has_more || !page.next_cursor) break
cursor = page.next_cursor
}
return items
}
export async function fetchRevisionPrefixesAll(revisionId: string): Promise<RevisionPrefix[]> {
const items = await apiPageAll<RevisionPrefix>(`/v1/revisions/${revisionId}/prefixes`)
return items
}
export async function fetchRevisionPrefixesResponse(
revisionId: string,
cursor?: string | null,
limit = 500,
): Promise<RevisionPrefixesResponse> {
const query = new URLSearchParams({ limit: String(limit) })
if (cursor) query.set('cursor', cursor)
return apiJSON<RevisionPrefixesResponse>(`/v1/revisions/${revisionId}/prefixes?${query.toString()}`)
}
export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) {
if (moduleType === 'DOMAINS') {
const entries = await apiPageAll<DomainEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/domain-entries`,
)
return { domains: entries }
}
if (moduleType === 'AS_PREFIXES') {
const entries = await apiPageAll<AsEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/as-entries`,
)
return { asns: entries }
}
if (moduleType === 'CDN_CIDRS') {
const entries = await apiPageAll<CdnSourcesResponse['items'][number]>(
`/v1/modules/${moduleId}/cdn-sources`,
)
return { cdnSources: entries }
}
if (moduleType === 'IP_RANGES') {
const entries = await apiPageAll<IpRangeEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/ip-range-entries`,
)
return { ipRanges: entries }
}
return {}
}
+24
View File
@@ -0,0 +1,24 @@
import type { ModuleRow } from '@/types/api'
export function formatDateTime(value: string | null | undefined): string {
if (typeof value !== 'string' || value.trim().length === 0) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return '—'
return parsed.toLocaleString('ru-RU')
}
export function moduleIntervalLabel(moduleRow: ModuleRow): string {
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : ''
const raw = moduleRow.refresh_interval_sec as unknown
const interval =
typeof raw === 'number'
? raw
: typeof raw === 'string' && raw.trim().length > 0
? Number(raw)
: null
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : ''
if (cron && intervalLabel) return `${intervalLabel} (${cron})`
if (cron) return cron
if (intervalLabel) return intervalLabel
return '—'
}
+41
View File
@@ -0,0 +1,41 @@
import type { BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
export const NONE_OPTION = '__none__'
export function communityLabel(id: string | null | undefined, communities: BgpCommunity[]): string {
if (!id) return '—'
const c = communities.find((x) => x.id === id)
if (!c) return `${id.slice(0, 8)}`
const t = c.title?.trim()
return t || c.community
}
export function communityOptionLabel(c: BgpCommunity): string {
const t = c.title?.trim()
return t || c.community
}
export function nullableSelectValue(value: string | null | undefined): string {
if (value === null || value === undefined || value === '') return NONE_OPTION
return value
}
export function fromNullableSelect(value: string): string | null {
if (value === NONE_OPTION || value === '') return null
return value
}
export function moduleDohProfileIds(modRow: ModuleRow | null): string[] {
if (!modRow) return []
if (modRow.doh_profile_ids?.length) return modRow.doh_profile_ids
return modRow.doh_profile_id ? [modRow.doh_profile_id] : []
}
export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
const p = dohProfiles.find((d) => d.id === id)
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : `${id.slice(0, 8)}`
}
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext'
}
+11
View File
@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
+25
View File
@@ -0,0 +1,25 @@
import { QueryClient } from '@tanstack/react-query'
import {
createRouter as tanstackCreateRouter,
rootRouteId,
} from '@tanstack/react-router'
import { routeTree } from '../routeTree.gen'
import { queryClient } from './queryClient'
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createRouter>
}
}
export function createRouter(opts?: { context?: { queryClient: QueryClient } }) {
return tanstackCreateRouter({
routeTree,
context: opts?.context ?? { queryClient },
defaultPreload: 'intent',
scrollRestoration: true,
})
}
export { rootRouteId }
+29
View File
@@ -0,0 +1,29 @@
import type { DohResolverPolicy } from '@/types/api'
export function dohPolicyRu(policy: DohResolverPolicy | string | null | undefined): string {
switch (policy) {
case 'primary_only':
return 'Только первый'
case 'failover':
return 'Резервирование'
case 'union':
return 'Объединение'
default:
return 'Только первый'
}
}
export function moduleTypeRu(type: string): string {
switch (type) {
case 'AS_PREFIXES':
return 'AS (номера)'
case 'CDN_CIDRS':
return 'CDN CIDR'
case 'DOMAINS':
return 'Домены'
case 'IP_RANGES':
return 'IP-диапазоны'
default:
return type
}
}
+27
View File
@@ -0,0 +1,27 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import { Toaster } from '@evobgp/ui/components/sonner'
import '@evobgp/ui/globals.css'
import { queryClient } from '@/lib/queryClient'
import { createRouter } from '@/lib/router'
import { ThemeProvider } from '@/components/theme-provider'
const router = createRouter({ context: { queryClient } })
const rootEl = document.getElementById('root')
if (!rootEl) throw new Error('Root element #root not found')
createRoot(rootEl).render(
<StrictMode>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster richColors position="top-right" />
</QueryClientProvider>
</ThemeProvider>
</StrictMode>,
)
+66
View File
@@ -0,0 +1,66 @@
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON, apiMutate } from '@/lib/api-client'
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeysResponse } from '@/types/api'
export const apiKeysKeys = {
all: ['api-keys'] as const,
list: () => [...apiKeysKeys.all, 'list'] as const,
}
export function apiKeysQueryOptions() {
return queryOptions<ApiKey[]>({
queryKey: apiKeysKeys.list(),
queryFn: async () => {
const page = await apiJSON<ApiKeysResponse>('/v1/api-keys?limit=500')
return page.items ?? []
},
staleTime: 60_000,
})
}
function invalidateApiKeysList(qc: ReturnType<typeof useQueryClient>) {
void qc.invalidateQueries({ queryKey: apiKeysKeys.list() })
}
export function useCreateApiKeyMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ApiKeyCreate) =>
apiMutate<ApiKeyCreated>('/v1/api-keys', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('Ключ создан')
invalidateApiKeysList(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать ключ'),
})
}
export function useRevokeApiKeyMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }),
onSuccess: () => {
toast.success('Ключ отозван')
invalidateApiKeysList(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'),
})
}
export function useRotateApiKeyMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
idempotent: false,
}),
onSuccess: () => {
toast.success('Ключ ротирован')
invalidateApiKeysList(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
})
}
+17
View File
@@ -0,0 +1,17 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { AuthSession } from '@/types/api'
export const authKeys = {
all: ['auth'] as const,
session: () => [...authKeys.all, 'session'] as const,
}
export function authSessionQueryOptions() {
return queryOptions<AuthSession>({
queryKey: authKeys.session(),
queryFn: () => apiJSON<AuthSession>('/v1/auth/session'),
retry: false,
staleTime: 30_000,
})
}
+25
View File
@@ -0,0 +1,25 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { CommunitiesResponse, DohProfilesResponse } from '@/types/api'
export const directoriesKeys = {
all: ['directories'] as const,
communities: () => [...directoriesKeys.all, 'communities'] as const,
doh: () => [...directoriesKeys.all, 'doh'] as const,
}
export function directoriesCommunitiesQueryOptions() {
return queryOptions<CommunitiesResponse>({
queryKey: directoriesKeys.communities(),
queryFn: () => apiJSON<CommunitiesResponse>('/v1/communities?limit=200'),
staleTime: 30_000,
})
}
export function directoriesDohQueryOptions() {
return queryOptions<DohProfilesResponse>({
queryKey: directoriesKeys.doh(),
queryFn: () => apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200'),
staleTime: 30_000,
})
}
+83
View File
@@ -0,0 +1,83 @@
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type {
FirewallClient,
FirewallClientsResponse,
FirewallInstallContext,
FirewallRule,
FirewallRulesResponse,
} from '@/types/api'
export const firewallKeys = {
all: ['firewall'] as const,
clients: () => [...firewallKeys.all, 'clients'] as const,
installContext: () => [...firewallKeys.all, 'install-context'] as const,
rules: (scope: string, clientId?: string) =>
[...firewallKeys.all, 'rules', scope, clientId ?? ''] as const,
}
export function firewallInstallContextQueryOptions() {
return queryOptions<FirewallInstallContext>({
queryKey: firewallKeys.installContext(),
queryFn: () => apiJSON<FirewallInstallContext>('/v1/firewall/install-context'),
staleTime: 60_000,
retry: false,
})
}
export function firewallClientsQueryOptions() {
return queryOptions<FirewallClientsResponse>({
queryKey: firewallKeys.clients(),
queryFn: () => apiJSON<FirewallClientsResponse>('/v1/firewall/clients'),
staleTime: 15_000,
})
}
export function firewallRulesQueryOptions(scope: 'tenant' | 'client', clientId?: string) {
const qs =
scope === 'client' && clientId
? `?scope=client&client_id=${encodeURIComponent(clientId)}`
: '?scope=tenant'
return queryOptions<FirewallRulesResponse>({
queryKey: firewallKeys.rules(scope, clientId),
queryFn: () => apiJSON<FirewallRulesResponse>(`/v1/firewall/rules${qs}`),
staleTime: 15_000,
})
}
export function useApproveFirewallClient() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
},
})
}
export function useCreateFirewallRule() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiJSON<FirewallRule>('/v1/firewall/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: firewallKeys.all })
},
})
}
export function useDeleteFirewallRule() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<void>(`/v1/firewall/rules/${id}`, { method: 'DELETE' }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: firewallKeys.all })
},
})
}
+53
View File
@@ -0,0 +1,53 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type {
ModuleRow,
ModulesResponse,
Page,
} from '@/types/api'
export const modulesKeys = {
all: ['modules'] as const,
list: () => [...modulesKeys.all, 'list'] as const,
detail: (id: string) => [...modulesKeys.all, 'detail', id] as const,
domainEntries: (id: string) => [...modulesKeys.all, 'domain-entries', id] as const,
asEntries: (id: string) => [...modulesKeys.all, 'as-entries', id] as const,
cdnSources: (id: string) => [...modulesKeys.all, 'cdn-sources', id] as const,
ipRangeEntries: (id: string) => [...modulesKeys.all, 'ip-range-entries', id] as const,
}
export function modulesListQueryOptions() {
return queryOptions<ModulesResponse>({
queryKey: modulesKeys.list(),
queryFn: () => apiJSON<ModulesResponse>('/v1/modules?limit=200'),
})
}
export function moduleDetailQueryOptions(id: string) {
return queryOptions<ModuleRow>({
queryKey: modulesKeys.detail(id),
queryFn: () => apiJSON<ModuleRow>(`/v1/modules/${id}`),
})
}
export type ModuleEntriesPage = Page<Record<string, unknown>>
export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
const pathByType: Record<ModuleRow['type'], string> = {
DOMAINS: `/v1/modules/${id}/domain-entries?limit=500`,
AS_PREFIXES: `/v1/modules/${id}/as-entries?limit=500`,
CDN_CIDRS: `/v1/modules/${id}/cdn-sources?limit=500`,
IP_RANGES: `/v1/modules/${id}/ip-range-entries?limit=500`,
}
const path = pathByType[type]
const keyByType: Record<ModuleRow['type'], readonly string[]> = {
DOMAINS: modulesKeys.domainEntries(id),
AS_PREFIXES: modulesKeys.asEntries(id),
CDN_CIDRS: modulesKeys.cdnSources(id),
IP_RANGES: modulesKeys.ipRangeEntries(id),
}
return queryOptions<ModuleEntriesPage>({
queryKey: keyByType[type],
queryFn: () => apiJSON<ModuleEntriesPage>(path),
})
}
+71
View File
@@ -0,0 +1,71 @@
import { queryOptions } from '@tanstack/react-query'
import { apiFetch, apiJSON, apiMutate } from '@/lib/api-client'
export interface HealthStatus {
ok: boolean
status?: string
error?: string
}
export interface ReadyStatus {
status?: string
checks?: Record<string, boolean | { ok?: boolean; error?: string }>
}
export interface VersionInfo {
version?: string
app?: string
git_sha?: string
build_time?: string
}
export const monitoringKeys = {
all: ['monitoring'] as const,
health: () => [...monitoringKeys.all, 'health'] as const,
ready: () => [...monitoringKeys.all, 'ready'] as const,
version: () => [...monitoringKeys.all, 'version'] as const,
}
async function fetchHealth(): Promise<HealthStatus> {
const res = await apiFetch('/v1/health', { method: 'GET' })
let body: { status?: string } = {}
try {
body = (await res.json()) as { status?: string }
} catch {
/* ignore */
}
return { ok: res.ok, status: body.status, error: res.ok ? undefined : `HTTP ${res.status}` }
}
export function monitoringHealthQueryOptions() {
return queryOptions<HealthStatus>({
queryKey: monitoringKeys.health(),
queryFn: fetchHealth,
staleTime: 15_000,
})
}
export function monitoringReadyQueryOptions() {
return queryOptions<ReadyStatus>({
queryKey: monitoringKeys.ready(),
queryFn: () => apiJSON<ReadyStatus>('/v1/ready'),
staleTime: 15_000,
})
}
export function monitoringVersionQueryOptions() {
return queryOptions<VersionInfo>({
queryKey: monitoringKeys.version(),
queryFn: () => apiJSON<VersionInfo>('/v1/version'),
staleTime: 60_000,
})
}
export async function fetchLog(endpoint: string): Promise<string> {
const res = await apiFetch(endpoint, { method: 'GET' })
return await res.text()
}
export async function clearLog(endpoint: string): Promise<void> {
await apiMutate(endpoint, 'DELETE', {})
}
+36
View File
@@ -0,0 +1,36 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { BirdStatus, PeersResponse, SpeakersResponse } from '@/types/api'
export const NETWORK_AUTO_REFRESH_MS = 30_000
export const networkKeys = {
all: ['network'] as const,
peers: () => [...networkKeys.all, 'peers'] as const,
speakers: () => [...networkKeys.all, 'speakers'] as const,
bird: () => [...networkKeys.all, 'bird'] as const,
}
export function networkPeersQueryOptions() {
return queryOptions<PeersResponse>({
queryKey: networkKeys.peers(),
queryFn: () => apiJSON<PeersResponse>('/v1/peers?limit=200&live=1'),
staleTime: 15_000,
})
}
export function networkSpeakersQueryOptions() {
return queryOptions<SpeakersResponse>({
queryKey: networkKeys.speakers(),
queryFn: () => apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1'),
staleTime: 15_000,
})
}
export function networkBirdQueryOptions() {
return queryOptions<BirdStatus>({
queryKey: networkKeys.bird(),
queryFn: () => apiJSON<BirdStatus>('/v1/bird/status'),
staleTime: 15_000,
})
}
+40
View File
@@ -0,0 +1,40 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { JobsResponse, RevisionsResponse, RevisionDiff } from '@/types/api'
export const operationsKeys = {
all: ['operations'] as const,
revisions: () => [...operationsKeys.all, 'revisions'] as const,
jobs: (params?: { status?: string; kind?: string }) =>
[...operationsKeys.all, 'jobs', params ?? {}] as const,
diff: (a: string, b: string) => [...operationsKeys.all, 'diff', a, b] as const,
}
export function operationsRevisionsQueryOptions() {
return queryOptions<RevisionsResponse>({
queryKey: operationsKeys.revisions(),
queryFn: () => apiJSON<RevisionsResponse>('/v1/revisions?limit=100'),
staleTime: 30_000,
})
}
export function operationsJobsQueryOptions(params?: { status?: string; kind?: string }) {
return queryOptions<JobsResponse>({
queryKey: operationsKeys.jobs(params),
queryFn: () => {
const sp = new URLSearchParams({ limit: '200' })
if (params?.status) sp.set('status', params.status)
if (params?.kind) sp.set('kind', params.kind)
return apiJSON<JobsResponse>(`/v1/jobs?${sp.toString()}`)
},
staleTime: 10_000,
})
}
export function operationsDiffQueryOptions(a: string, b: string) {
return queryOptions<RevisionDiff>({
queryKey: operationsKeys.diff(a, b),
queryFn: () => apiJSON<RevisionDiff>(`/v1/revisions/${a}/diff/${b}`),
enabled: Boolean(a) && Boolean(b),
})
}
+115
View File
@@ -0,0 +1,115 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type {
JobRow,
JobsResponse,
ModuleRow,
ModulesResponse,
PeerRow,
PeersResponse,
RevisionRow,
RevisionsResponse,
SpeakerRow,
SpeakersResponse,
} from '@/types/api'
export const overviewKeys = {
all: ['overview'] as const,
modules: () => [...overviewKeys.all, 'modules'] as const,
peers: () => [...overviewKeys.all, 'peers'] as const,
speakers: () => [...overviewKeys.all, 'speakers'] as const,
revisions: () => [...overviewKeys.all, 'revisions'] as const,
jobs: () => [...overviewKeys.all, 'jobs'] as const,
health: () => [...overviewKeys.all, 'health'] as const,
}
export function overviewModulesQueryOptions() {
return queryOptions<ModulesResponse>({
queryKey: overviewKeys.modules(),
queryFn: () => apiJSON<ModulesResponse>('/v1/modules?limit=200'),
staleTime: 60_000,
})
}
export function overviewPeersQueryOptions() {
return queryOptions<PeersResponse>({
queryKey: overviewKeys.peers(),
queryFn: () => apiJSON<PeersResponse>('/v1/peers?limit=200&live=1'),
staleTime: 30_000,
})
}
export function overviewSpeakersQueryOptions() {
return queryOptions<SpeakersResponse>({
queryKey: overviewKeys.speakers(),
queryFn: () => apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1'),
staleTime: 30_000,
})
}
export function overviewRevisionsQueryOptions() {
return queryOptions<RevisionsResponse>({
queryKey: overviewKeys.revisions(),
queryFn: () => apiJSON<RevisionsResponse>('/v1/revisions?limit=10'),
staleTime: 60_000,
})
}
export function overviewJobsQueryOptions() {
return queryOptions<JobsResponse>({
queryKey: overviewKeys.jobs(),
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=10'),
staleTime: 15_000,
})
}
export function overviewHealthQueryOptions() {
return queryOptions<boolean>({
queryKey: overviewKeys.health(),
queryFn: async () => {
const res = await fetch('/v1/health')
return res.ok
},
staleTime: 30_000,
})
}
// Selectors / helpers
export type NetworkMetrics = {
peersTotal: number
peersEnabled: number
peersEstablished: number
peersMismatch: number
speakersTotal: number
speakersOnline: number
}
export function aggregateNetworkMetrics(
peers: PeerRow[],
speakers: SpeakerRow[],
): NetworkMetrics {
const peersEnabled = peers.filter((p) => p.enabled !== false).length
const peersEstablished = peers.filter((p) => p.session_state === 'Established').length
const peersMismatch = peers.filter((p) => p.session_mismatch).length
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
return {
peersTotal: peers.length,
peersEnabled,
peersEstablished,
peersMismatch,
speakersTotal: speakers.length,
speakersOnline,
}
}
export function runningJobCount(jobs: JobRow[]): number {
return jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
}
export function moduleNameById(modules: ModuleRow[]): Map<string, string> {
return new Map(modules.map((m) => [m.id, m.name]))
}
export function recentRevisions(revisions: RevisionRow[], n = 10): RevisionRow[] {
return revisions.slice(0, n)
}
+128
View File
@@ -0,0 +1,128 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
export type AppSettings = Record<string, unknown>
export const BIRD_SETTING_KEYS = [
'bird_router_id',
'bird_local_ipv4',
'bird_local_ipv6',
'bird_local_asn',
'bird_bgp_source_ipv4',
'bird_bgp_source_ipv6',
] as const
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const
export const RUNTIME_LOGS_SETTING_KEYS = [
'runtime_logs_auto_enabled',
'runtime_logs_max_file_mb',
'runtime_logs_auto_schedule',
'runtime_logs_auto_mode',
] as const
export const KNOWN_SETTING_KEYS = [
...BIRD_SETTING_KEYS,
...REVISION_SETTING_KEYS,
...RUNTIME_LOGS_SETTING_KEYS,
] as const
export type KnownSettingKey = (typeof KNOWN_SETTING_KEYS)[number]
export type BirdSettingKey = (typeof BIRD_SETTING_KEYS)[number]
export type RevisionSettingKey = (typeof REVISION_SETTING_KEYS)[number]
export type RuntimeLogsSettingKey = (typeof RUNTIME_LOGS_SETTING_KEYS)[number]
export const NUMERIC_SETTING_KEYS = new Set<KnownSettingKey>([
'bird_local_asn',
'revision_retention_minutes',
'runtime_logs_max_file_mb',
])
export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>(['runtime_logs_auto_enabled'])
export const settingsKeys = {
all: ['settings'] as const,
}
export function settingsQueryOptions() {
return queryOptions<AppSettings>({
queryKey: settingsKeys.all,
queryFn: () => apiJSON<AppSettings>('/v1/settings'),
staleTime: 30_000,
})
}
export function parseKnownValue(key: KnownSettingKey, value: unknown): string {
if (NUMERIC_SETTING_KEYS.has(key)) {
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'string') return value
return ''
}
if (typeof value === 'string') return value
return ''
}
export interface PartitionedSettings {
bird: Partial<Record<BirdSettingKey, string>>
revision: Partial<Record<RevisionSettingKey, string>>
runtimeLogs: Partial<Record<RuntimeLogsSettingKey, string>>
additional: { id: number; key: string; value: string }[]
}
export function partitionSettings(settings: AppSettings): PartitionedSettings {
const bird: Partial<Record<BirdSettingKey, string>> = {}
const revision: Partial<Record<RevisionSettingKey, string>> = {}
const runtimeLogs: Partial<Record<RuntimeLogsSettingKey, string>> = {}
const additional: { id: number; key: string; value: string }[] = []
let id = 1
for (const [key, value] of Object.entries(settings)) {
if ((BIRD_SETTING_KEYS as readonly string[]).includes(key)) {
bird[key as BirdSettingKey] = parseKnownValue(key as KnownSettingKey, value)
} else if (key === 'revision_retention_minutes') {
revision.revision_retention_minutes = parseKnownValue(
key as RevisionSettingKey,
value,
)
} else if ((RUNTIME_LOGS_SETTING_KEYS as readonly string[]).includes(key)) {
const rk = key as RuntimeLogsSettingKey
if (rk === 'runtime_logs_auto_enabled') {
runtimeLogs.runtime_logs_auto_enabled =
value === true || value === 1 || value === 'true' || value === '1'
? 'true'
: 'false'
} else if (rk === 'runtime_logs_auto_mode') {
const m = String(value ?? '').trim()
runtimeLogs.runtime_logs_auto_mode = m === 'delete' ? 'delete' : m === 'truncate' ? 'truncate' : ''
} else {
runtimeLogs[rk] = parseKnownValue(rk, value)
}
} else {
additional.push({
id: id++,
key,
value: typeof value === 'string' ? value : String(value),
})
}
}
return { bird, revision, runtimeLogs, additional }
}
export function buildPayload(
keys: readonly KnownSettingKey[],
form: Record<string, string>,
): Record<string, string | number | boolean> {
const payload: Record<string, string | number | boolean> = {}
for (const key of keys) {
const value = String(form[key] ?? '').trim()
if (BOOLEAN_SETTING_KEYS.has(key)) {
payload[key] = value === 'true'
continue
}
if (!value) continue
if (NUMERIC_SETTING_KEYS.has(key)) payload[key] = Number(value)
else payload[key] = value
}
return payload
}
+18
View File
@@ -0,0 +1,18 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import { AppShell } from '@/components/layout/app-shell'
interface RouterContext {
queryClient: import('@tanstack/react-query').QueryClient
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
})
function RootComponent() {
return (
<AppShell>
<Outlet />
</AppShell>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
export const Route = createFileRoute('/_auth')({
beforeLoad: ({ location }) => {
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
if (location.pathname === '/settings') return
const raw =
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
if (!raw || !normalizeApiToken(raw)) {
throw redirect({ to: '/settings' })
}
},
component: AuthLayout,
})
function AuthLayout() {
return <Outlet />
}
+157
View File
@@ -0,0 +1,157 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Info, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
import { PageHeader } from '@/components/page-header'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { authSessionQueryOptions } from '@/queries/auth'
import { apiKeysQueryOptions } from '@/queries/api-keys'
export const Route = createFileRoute('/_auth/access')({
component: AccessComponent,
})
function AccessComponent() {
const sessionQuery = useQuery(authSessionQueryOptions())
const session = sessionQuery.data ?? null
const isOperator = session?.role === 'operator'
const keysQuery = useQuery({
...apiKeysQueryOptions(),
enabled: isOperator,
})
const keys = keysQuery.data ?? []
const activeCount = keys.filter((k) => !k.revoked_at).length
const revokedCount = keys.filter((k) => k.revoked_at).length
const refreshing = sessionQuery.isFetching || keysQuery.isFetching
const kpiItems: SectionCardItem[] = useMemo(
() => [
{
label: 'Всего ключей',
value: keys.length,
icon: <KeyRound className="size-4" />,
hint: 'в tenant',
},
{
label: 'Активных',
value: activeCount,
icon: <ShieldCheck className="size-4" />,
hint: 'не отозваны',
},
{
label: 'Отозванных',
value: revokedCount,
icon: <ShieldOff className="size-4" />,
hint: 'revoked',
variant: revokedCount > 0 ? 'warning' : 'default',
},
],
[keys.length, activeCount, revokedCount],
)
function refetchAll() {
void sessionQuery.refetch()
if (isOperator) void keysQuery.refetch()
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена."
actions={
isOperator ? (
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
) : undefined
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О API-ключах</AlertTitle>
<AlertDescription>
Роли: <code className="text-xs">viewer</code> (чтение),{' '}
<code className="text-xs">editor</code> (CRUD), <code className="text-xs">operator</code>{' '}
(apply и настройки), <code className="text-xs">node</code> (API ноды). Полный токен
показывается один раз при создании и ротации. Токен браузера в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках
</Link>
; для локальной разработки с demo-seed подойдёт <code className="text-xs">dev</code>{' '}
(роль operator).
</AlertDescription>
</Alert>
{session ? (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Текущая сессия</CardTitle>
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 p-4 text-sm sm:grid-cols-2">
<div>
<p className="text-muted-foreground">Tenant</p>
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
</div>
<div>
<p className="text-muted-foreground">Роль</p>
<p className="font-mono">{session.role}</p>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">
Не удалось определить сессию. Укажите токен в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках
</Link>{' '}
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
) : null}
</CardContent>
</Card>
)}
{isOperator ? (
<>
{keysQuery.isLoading ? (
<SectionCardsSkeleton count={3} />
) : (
<SectionCards items={kpiItems} />
)}
<AccessApiKeysCard
items={keys}
isLoading={keysQuery.isLoading}
isError={keysQuery.isError}
error={keysQuery.error}
onRetry={() => keysQuery.refetch()}
/>
</>
) : session ? (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
<span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с
operator-ключом или создайте ключ через API / переменную{' '}
<code className="text-xs">EVOBGP_API_KEYS</code>.
</CardContent>
</Card>
) : null}
</div>
)
}
+400
View File
@@ -0,0 +1,400 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query'
import {
Boxes,
CheckCircle,
Clock,
GitBranch,
Info,
Network,
Play,
Plus,
Radio,
RefreshCw,
Activity,
Share2,
Tags,
Gauge,
XCircle,
} from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { PageHeader } from '@/components/page-header'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import {
aggregateNetworkMetrics,
moduleNameById,
overviewHealthQueryOptions,
overviewJobsQueryOptions,
overviewModulesQueryOptions,
overviewPeersQueryOptions,
overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions,
runningJobCount,
} from '@/queries/overview'
export const Route = createFileRoute('/_auth/dashboard')({
component: DashboardComponent,
})
function DashboardComponent() {
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const results = useQueries({
queries: [
overviewHealthQueryOptions(),
overviewModulesQueryOptions(),
overviewPeersQueryOptions(),
overviewSpeakersQueryOptions(),
overviewRevisionsQueryOptions(),
overviewJobsQueryOptions(),
],
})
const [healthQ, modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
const initialLoading =
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
if (!lastUpdated && !initialLoading && results.every((r) => r.isSuccess || r.isError)) {
setTimeout(() => setLastUpdated(new Date()), 0)
}
function refetchAll() {
setLastUpdated(null)
results.forEach((r) => r.refetch())
}
const modules = modulesQ.data?.items ?? []
const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const revisions = revisionsQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const modulesHasMore = modulesQ.data?.has_more ?? false
const peersHasMore = peersQ.data?.has_more ?? false
const speakersHasMore = speakersQ.data?.has_more ?? false
const revisionsHasMore = revisionsQ.data?.has_more ?? false
const net = aggregateNetworkMetrics(peers, speakers)
const running = runningJobCount(jobs)
const nameById = moduleNameById(modules)
const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix)
const items: SectionCardItem[] = [
{
label: 'Модули',
value: initialLoading ? '—' : String(modules.length),
icon: <Boxes className="size-4" />,
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
onClick: () => window.location.assign('/modules'),
},
{
label: 'Пиры',
value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
icon: <GitBranch className="size-4" />,
hint: countBadge(peers.length, peersHasMore, 'Established / включённых'),
badge: net.peersMismatch > 0 ? `mismatch ${net.peersMismatch}` : undefined,
variant: net.peersMismatch > 0 ? 'warning' : 'default',
onClick: () => window.location.assign('/network?tab=peers'),
},
{
label: 'Спикеры',
value: initialLoading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
icon: <Radio className="size-4" />,
hint: countBadge(speakers.length, speakersHasMore, 'online / всего'),
variant: net.speakersOnline < net.speakersTotal ? 'warning' : 'default',
onClick: () => window.location.assign('/network?tab=overview'),
},
{
label: 'Ревизии',
value: initialLoading ? '—' : String(revisions.length),
icon: <Activity className="size-4" />,
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
onClick: () => window.location.assign('/operations'),
},
{
label: 'Активных задач',
value: initialLoading ? '—' : String(running),
icon: <Clock className="size-4" />,
hint: 'queued и running',
onClick: () => window.location.assign('/operations?tab=jobs'),
},
]
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Обзор"
description={
lastUpdated
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Состояние панели управления EvoBGP.'
}
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Панель управления EvoBGP</AlertTitle>
<AlertDescription>
Сводка по модулям, сети и фоновым задачам. BGP и ноды «Сеть», префиксы «Модули»,
деплой «Операции», здоровье API «Мониторинг».
</AlertDescription>
</Alert>
<HealthAlert
loading={healthQ.isLoading}
ok={healthQ.data === true}
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
/>
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} />}
<div className="grid gap-4 lg:grid-cols-3">
<RecentJobsCard jobs={jobs} nameById={nameById} loading={refreshing} />
<RecentRevisionsCard revisions={revisions} loading={refreshing} />
<NetworkStatusCard peers={peers} speakers={speakers} loading={refreshing} />
</div>
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Быстрые действия</CardTitle>
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2 p-4">
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
<Plus className="size-4" />
Создать модуль
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/directories')}>
<Tags className="size-4" />
Добавить community
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=overview')}>
<Network className="size-4" />
Сеть
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=peers')}>
<Share2 className="size-4" />
Добавить пира
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/operations')}>
<Play className="size-4" />
Деплой (Apply)
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/monitoring')}>
<Gauge className="size-4" />
Мониторинг
</Button>
</CardContent>
</Card>
</div>
)
}
function HealthAlert({
loading,
ok,
loadError,
}: {
loading: boolean
ok: boolean | undefined
loadError: string | null
}) {
if (loading) {
return (
<Alert>
<Skeleton className="size-5 rounded-full" />
<AlertTitle>Проверка API</AlertTitle>
<AlertDescription>
Запрос к <code className="text-xs">/v1/health</code>
</AlertDescription>
</Alert>
)
}
if (ok && !loadError) {
return (
<Alert className="border-success/30 bg-success/5">
<CheckCircle className="text-success" />
<AlertTitle>API работает</AlertTitle>
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
</Alert>
)
}
if (ok && loadError) {
return (
<Alert className="border-warning/30 bg-warning/5">
<Info className="text-warning" />
<AlertTitle>API доступен, данные не загружены</AlertTitle>
<AlertDescription>{loadError}. Проверьте Bearer-токен в «Настройках».</AlertDescription>
</Alert>
)
}
return (
<Alert variant="destructive" className="border-destructive/30 bg-destructive/5">
<XCircle className="text-destructive" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080) и в dev работает
прокси Vite.
</AlertDescription>
</Alert>
)
}
function RecentJobsCard({
jobs,
nameById,
loading,
}: {
jobs: import('@/types/api').JobRow[]
nameById: Map<string, string>
loading: boolean
}) {
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Недавние задачи</CardTitle>
<CardDescription>Последние фоновые операции</CardDescription>
</CardHeader>
<CardContent className="p-3">
{loading && jobs.length === 0 ? (
<Skeleton className="h-24 w-full" />
) : jobs.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет задач</p>
) : (
<ul className="flex flex-col gap-1">
{jobs.slice(0, 8).map((j) => (
<li
key={j.job_id}
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-mono text-xs text-muted-foreground">{j.kind}</span>
<span className="truncate text-xs">
{j.meta?.module_id ? nameById.get(String(j.meta.module_id)) ?? '' : ''}
</span>
</span>
<span
className={
j.status === 'succeeded'
? 'text-xs text-success'
: j.status === 'failed'
? 'text-xs text-destructive'
: 'text-xs text-muted-foreground'
}
>
{j.status}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
)
}
function RecentRevisionsCard({
revisions,
loading,
}: {
revisions: import('@/types/api').RevisionRow[]
loading: boolean
}) {
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Последние ревизии</CardTitle>
<CardDescription>История конфигураций</CardDescription>
</CardHeader>
<CardContent className="p-3">
{loading && revisions.length === 0 ? (
<Skeleton className="h-24 w-full" />
) : revisions.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет ревизий</p>
) : (
<ul className="flex flex-col gap-1">
{revisions.slice(0, 8).map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
>
<span className="truncate font-mono text-xs text-muted-foreground">{r.id.slice(0, 10)}</span>
<span className="text-xs text-muted-foreground">
{new Date(r.created_at).toLocaleString('ru-RU')}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
)
}
function NetworkStatusCard({
peers,
speakers,
loading,
}: {
peers: import('@/types/api').PeerRow[]
speakers: import('@/types/api').SpeakerRow[]
loading: boolean
}) {
const m = aggregateNetworkMetrics(peers, speakers)
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Состояние сети</CardTitle>
<CardDescription>BGP-сессии и спикеры</CardDescription>
</CardHeader>
<CardContent className="p-3">
{loading && peers.length === 0 && speakers.length === 0 ? (
<Skeleton className="h-24 w-full" />
) : (
<div className="flex flex-col gap-2 px-1 py-1 text-sm">
<Row label="Пиры Established" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
<Row label="Спикеры online" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
{m.peersMismatch > 0 ? (
<Row label="Mismatches" value={String(m.peersMismatch)} variant="warning" />
) : null}
</div>
)}
</CardContent>
</Card>
)
}
function Row({
label,
value,
variant = 'default',
}: {
label: string
value: string
variant?: 'default' | 'warning'
}) {
return (
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">{label}</span>
<span className={variant === 'warning' ? 'font-medium text-warning-foreground' : 'font-medium tabular-nums'}>
{value}
</span>
</div>
)
}
+186
View File
@@ -0,0 +1,186 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories'
export const Route = createFileRoute('/_auth/directories')({
component: DirectoriesComponent,
})
function DirectoriesComponent() {
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
const communities = communitiesQ.data?.items ?? []
const dohProfiles = dohQ.data?.items ?? []
const loading = communitiesQ.isLoading || dohQ.isLoading
const items: SectionCardItem[] = [
{
label: 'Сообщества BGP',
value: communities.length,
icon: <Tags className="size-4" />,
hint: 'теги префиксов в AS- и CDN-модулях',
},
{
label: 'DoH профили',
value: dohProfiles.length,
icon: <Globe className="size-4" />,
hint: 'резолвинг доменных модулей',
},
{
label: 'Справочники',
value: 'Общие',
icon: <BookText className="size-4" />,
hint: 'используются всеми модулями tenant',
},
]
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Справочники"
description="Сообщества BGP и DoH-профили для резолвинга доменов"
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void communitiesQ.refetch()
void dohQ.refetch()
}}
disabled={loading}
>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О справочниках</AlertTitle>
<AlertDescription>
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили в
доменных модулях для DNS-over-HTTPS резолвинга.
</AlertDescription>
</Alert>
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<Tabs defaultValue="communities">
<TabsList>
<TabsTrigger value="communities">Сообщества BGP</TabsTrigger>
<TabsTrigger value="doh">DoH профили</TabsTrigger>
</TabsList>
<TabsContent value="communities" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Сообщества BGP</CardTitle>
<CardDescription>Теги для префиксов в фильтрах BIRD</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={communities}
isLoading={communitiesQ.isLoading}
isError={communitiesQ.isError}
error={communitiesQ.error}
empty={communities.length === 0}
emptyTitle="Нет сообществ"
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => communitiesQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Значение</TableHead>
<TableHead>Тип</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.title}</TableCell>
<TableCell className="font-mono text-xs">{c.community}</TableCell>
<TableCell>
<Badge variant="outline">community</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="doh" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">DoH профили</CardTitle>
<CardDescription>Резолверы DNS-over-HTTPS для доменных модулей</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={dohProfiles}
isLoading={dohQ.isLoading}
isError={dohQ.isError}
error={dohQ.error}
empty={dohProfiles.length === 0}
emptyTitle="Нет DoH профилей"
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => dohQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>URL</TableHead>
<TableHead>По умолчанию</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name ?? p.url}</TableCell>
<TableCell className="font-mono text-xs">{p.url}</TableCell>
<TableCell>
<Badge variant="outline"></Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+351
View File
@@ -0,0 +1,351 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Copy, Info, RefreshCw, Shield } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { CommunitySelect } from '@/components/modules/community-select'
import { StatusBadge } from '@/components/status-badge'
import { communityLabel } from '@/lib/modules/helpers'
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
import {
firewallClientsQueryOptions,
firewallInstallContextQueryOptions,
firewallRulesQueryOptions,
useApproveFirewallClient,
useCreateFirewallRule,
useDeleteFirewallRule,
} from '@/queries/firewall'
import type { BgpCommunity, FirewallClient } from '@/types/api'
export const Route = createFileRoute('/_auth/firewall')({
component: FirewallPage,
})
function FirewallPage() {
const installCtxQ = useQuery(firewallInstallContextQueryOptions())
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const clientsQ = useQuery(firewallClientsQueryOptions())
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
const approve = useApproveFirewallClient()
const createRule = useCreateFirewallRule()
const deleteRule = useDeleteFirewallRule()
const installCtx = installCtxQ.data
const [clientName, setClientName] = useState('web-01')
const [cpUrl, setCpUrl] = useState(() =>
typeof window !== 'undefined' ? window.location.origin : 'https://api.example.com',
)
const [seed, setSeed] = useState('')
useEffect(() => {
if (installCtx?.suggested_cp_url) {
setCpUrl(installCtx.suggested_cp_url)
}
if (installCtx?.bundle_seed) {
setSeed(installCtx.bundle_seed)
}
}, [installCtx?.bundle_seed, installCtx?.suggested_cp_url])
const [ruleAction, setRuleAction] = useState<'block' | 'accept'>('block')
const [ruleCommunityId, setRuleCommunityId] = useState<string | null>(null)
const [ruleComment, setRuleComment] = useState('')
const communities = communitiesQ.data?.items ?? []
const clients = clientsQ.data?.items ?? []
const pending = clients.filter((c) => c.status === 'pending')
const rules = rulesQ.data?.items ?? []
const installCmd = useMemo(() => {
const s = seed.trim() || '<bundle_seed_hex>'
return `curl -fsSL ${cpUrl.replace(/\/$/, '')}/v1/firewall/install.sh | \\
EVOBGP_CP_URL=${cpUrl.replace(/\/$/, '')} \\
EVOBGP_SEED=${s} \\
EVOBGP_CLIENT_NAME="${clientName}" \\
bash`
}, [clientName, cpUrl, seed])
async function copyInstall() {
if (!seed.trim()) {
toast.error(
installCtx?.bundle_seed_configured === false
? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX'
: 'Bundle seed недоступен (нужна роль operator)',
)
return
}
try {
await navigator.clipboard.writeText(installCmd)
toast.success('Команда скопирована')
} catch {
toast.error('Не удалось скопировать')
}
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Firewall blocklist"
description="Linux-серверы: синхронизация CIDR по policy block/accept"
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void clientsQ.refetch()
void rulesQ.refetch()
}}
disabled={clientsQ.isFetching}
>
<RefreshCw className={clientsQ.isFetching ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Политика</AlertTitle>
<AlertDescription>
Правила сопоставляются с <strong>BGP community</strong> префиксов опубликованной revision.{' '}
<strong>block</strong> добавляет префиксы community в kernel; <strong>accept</strong> не блокирует.
Community «Все» правило для любого community. Default без совпадений accept.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="size-5" />
Установка на сервер
</CardTitle>
<CardDescription>One-liner для root на целевом Linux (bash, curl). После enroll approve в «Запросы».</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="fw-name">Имя сервера</Label>
<Input id="fw-name" value={clientName} onChange={(e) => setClientName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="fw-url">URL API</Label>
<Input id="fw-url" value={cpUrl} onChange={(e) => setCpUrl(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="fw-seed">Bundle seed</Label>
<Input
id="fw-seed"
type="password"
readOnly
placeholder="EVOBGP_BUNDLE_SEED_HEX"
value={seed}
className="font-mono text-xs"
/>
<p className="text-muted-foreground text-xs">
{installCtxQ.isLoading
? 'Загрузка из control plane…'
: installCtx?.bundle_seed_configured
? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)'
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — enroll невозможен'}
</p>
</div>
</div>
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-xs">{installCmd}</pre>
<Button variant="outline" size="sm" className="w-fit" onClick={copyInstall}>
<Copy />
Копировать команду
</Button>
</CardContent>
</Card>
<Tabs defaultValue="clients">
<TabsList>
<TabsTrigger value="clients">Клиенты ({clients.length})</TabsTrigger>
<TabsTrigger value="rules">Правила ({rules.length})</TabsTrigger>
<TabsTrigger value="requests">Запросы ({pending.length})</TabsTrigger>
</TabsList>
<TabsContent value="clients" className="mt-4">
<ClientsTable clients={clients} onApprove={(id) => approve.mutate(id)} />
</TabsContent>
<TabsContent value="rules" className="mt-4 space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label>Действие</Label>
<select
className="border-input bg-background h-9 rounded-md border px-2 text-sm"
value={ruleAction}
onChange={(e) => setRuleAction(e.target.value as 'block' | 'accept')}
>
<option value="block">block</option>
<option value="accept">accept</option>
</select>
</div>
<CommunitySelect
id="fw-rule-community"
label="Community"
value={ruleCommunityId}
onValueChange={setRuleCommunityId}
communities={communities}
nullable
placeholder="Все communities"
/>
<div className="space-y-1">
<Label htmlFor="fw-rule-comment">Комментарий</Label>
<Input
id="fw-rule-comment"
className="max-w-xs"
placeholder="Комментарий"
value={ruleComment}
onChange={(e) => setRuleComment(e.target.value)}
/>
</div>
<Button
size="sm"
className="mb-0.5"
onClick={() =>
createRule.mutate({
scope: 'tenant',
action: ruleAction,
community_id: ruleCommunityId,
comment: ruleComment,
})
}
>
Добавить правило
</Button>
</div>
<RulesTable
rules={rules}
communities={communities}
onDelete={(id) => deleteRule.mutate(id)}
/>
</TabsContent>
<TabsContent value="requests" className="mt-4">
<ClientsTable
clients={pending}
onApprove={(id) => approve.mutate(id)}
emptyTitle="Нет pending-запросов"
/>
</TabsContent>
</Tabs>
</div>
)
}
function ClientsTable({
clients,
onApprove,
emptyTitle = 'Нет клиентов',
}: {
clients: FirewallClient[]
onApprove: (id: string) => void
emptyTitle?: string
}) {
if (clients.length === 0) {
return <p className="text-muted-foreground text-sm">{emptyTitle}</p>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Last seen</TableHead>
<TableHead>Apply</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{clients.map((c) => (
<TableRow key={c.id}>
<TableCell>
<div className="font-medium">{c.name}</div>
<div className="text-muted-foreground text-xs">{c.hostname || c.token_prefix}</div>
</TableCell>
<TableCell>
<StatusBadge status={c.status} />
</TableCell>
<TableCell className="text-xs">{c.last_seen_at?.slice(0, 19) ?? '—'}</TableCell>
<TableCell className="text-xs">
{c.last_apply_status ?? '—'}
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</TableCell>
<TableCell>
{c.status === 'pending' ? (
<Button size="sm" variant="outline" onClick={() => onApprove(c.id)}>
Approve
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function RulesTable({
rules,
communities,
onDelete,
}: {
rules: { id: string; priority: number; action: string; community_id?: string | null; comment?: string }[]
communities: BgpCommunity[]
onDelete: (id: string) => void
}) {
if (rules.length === 0) {
return <p className="text-muted-foreground text-sm">Нет правил blocklist пуст (default accept).</p>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Действие</TableHead>
<TableHead>Community</TableHead>
<TableHead>Комментарий</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>
<StatusBadge status={r.action} label={r.action} />
</TableCell>
<TableCell className="text-sm">
{r.community_id ? communityLabel(r.community_id, communities) : 'Все'}
</TableCell>
<TableCell>{r.comment || '—'}</TableCell>
<TableCell>
<Button size="sm" variant="ghost" onClick={() => onDelete(r.id)}>
Удалить
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
@@ -0,0 +1,147 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Info, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { StatusBadge } from '@/components/status-badge'
import { ModuleEntriesSection } from '@/components/modules/module-entries-section'
import { ModuleKpiCards } from '@/components/modules/module-kpi-cards'
import { moduleTypeRu } from '@/lib/ui-labels'
import {
directoriesCommunitiesQueryOptions,
directoriesDohQueryOptions,
} from '@/queries/directories'
import { moduleDetailQueryOptions, moduleEntriesQueryOptions, modulesKeys } from '@/queries/modules'
import type { AsEntry, ModuleRow } from '@/types/api'
export const Route = createFileRoute('/_auth/modules/$moduleId')({
component: ModuleDetailComponent,
})
function moduleTypeAlert(type: ModuleRow['type']): string {
switch (type) {
case 'AS_PREFIXES':
return 'Модуль AS получает префиксы через RIPEstat по указанным ASN. После refresh счётчики префиксов обновляются в таблице записей.'
case 'CDN_CIDRS':
return 'Модуль CDN скачивает списки CIDR по URL (plaintext или JSON). Используйте предпросмотр при добавлении источника.'
case 'DOMAINS':
return 'Модуль доменов резолвит FQDN через DoH-профили и конвертирует IP в префиксы. Политика и профили настраиваются в редактировании модуля.'
case 'IP_RANGES':
return 'Модуль IP-диапазонов использует статические CIDR без внешнего refresh (сервер может вернуть 204). Записи участвуют в агрегации напрямую.'
}
}
function ModuleDetailComponent() {
const { moduleId } = Route.useParams()
const queryClient = useQueryClient()
const detail = useQuery(moduleDetailQueryOptions(moduleId))
const mod = detail.data
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
const entriesQuery = useQuery({
...moduleEntriesQueryOptions(moduleId, mod?.type ?? 'DOMAINS'),
enabled: !!mod,
})
const asEntriesQ = useQuery({
...moduleEntriesQueryOptions(moduleId, 'AS_PREFIXES'),
enabled: mod?.type === 'AS_PREFIXES',
select: (data) => data.items as unknown as AsEntry[],
})
async function refreshAll() {
await Promise.all([detail.refetch(), entriesQuery.refetch(), communitiesQ.refetch(), dohQ.refetch()])
}
async function onEntriesChanged() {
await entriesQuery.refetch()
if (mod?.type === 'AS_PREFIXES') {
await queryClient.invalidateQueries({ queryKey: modulesKeys.asEntries(moduleId) })
}
await detail.refetch()
}
const communities = communitiesQ.data?.items ?? []
const dohProfiles = dohQ.data?.items ?? []
const asEntries = mod?.type === 'AS_PREFIXES' ? (asEntriesQ.data ?? []) : []
return (
<div className="flex flex-col gap-6">
<PageHeader
title={mod?.name ?? moduleId}
description={mod ? `Тип: ${moduleTypeRu(mod.type)} (${mod.type})` : 'Загрузка модуля…'}
actions={
<>
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
<ArrowLeft />
К списку
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void refreshAll()}
disabled={detail.isFetching}
>
<RefreshCw className={detail.isFetching ? 'animate-spin' : ''} />
Обновить
</Button>
</>
}
/>
<QueryState
data={mod}
isLoading={detail.isLoading}
isError={detail.isError}
error={detail.error}
skeleton={<TableSkeleton rows={4} cols={2} />}
onRetry={() => detail.refetch()}
>
{(m) => (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-2">
{m.enabled ? (
<StatusBadge status="active" label="включён" />
) : (
<StatusBadge status="paused" label="выключен" />
)}
</div>
<Alert>
<Info />
<AlertTitle>О модуле</AlertTitle>
<AlertDescription>{moduleTypeAlert(m.type)}</AlertDescription>
</Alert>
<ModuleKpiCards
mod={m}
communities={communities}
dohProfiles={dohProfiles}
asEntries={asEntries}
loading={detail.isLoading || communitiesQ.isLoading}
/>
<ModuleEntriesSection
moduleId={moduleId}
mod={m}
items={entriesQuery.data?.items ?? []}
communities={communities}
isLoading={entriesQuery.isLoading}
isError={entriesQuery.isError}
error={entriesQuery.error}
onRetry={() => entriesQuery.refetch()}
onChanged={onEntriesChanged}
/>
</div>
)}
</QueryState>
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Boxes, Plus, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { Badge } from '@/components/reui/badge'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { TruncatedText } from '@/components/truncated-text'
import { modulesListQueryOptions } from '@/queries/modules'
import type { ModuleRow } from '@/types/api'
export const Route = createFileRoute('/_auth/modules/')({
component: ModulesListComponent,
})
function ModulesListComponent() {
const query = useQuery(modulesListQueryOptions())
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Модули"
description="Маршрутные списки: AS, CDN, домены, IP-диапазоны"
actions={
<>
<Button
variant="outline"
size="sm"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw className={query.isFetching ? 'animate-spin' : ''} />
Обновить
</Button>
</>
}
/>
<Card>
<CardHeader className="flex flex-row items-center justify-between border-b py-3">
<CardTitle className="text-base">Все модули</CardTitle>
<Button size="sm" render={<Link to="/modules/new" />}>
<Plus />
Создать
</Button>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={query.data?.items}
isLoading={query.isLoading}
isError={query.isError}
error={query.error}
empty={query.data?.items?.length === 0}
emptyTitle="Нет модулей"
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()}
>
{(items) => <ModulesTable items={items} />}
</QueryState>
</CardContent>
</Card>
</div>
)
}
function ModulesTable({ items }: { items: ModuleRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Приоритет</TableHead>
<TableHead>Состояние</TableHead>
<TableHead>Обновлено</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((m) => (
<TableRow
key={m.id}
className="cursor-pointer hover:bg-muted/40"
onClick={() => (window.location.href = `/modules/${m.id}`)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Boxes className="size-4 text-muted-foreground" />
<TruncatedText className="max-w-[280px]">{m.name}</TruncatedText>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{m.type}</Badge>
</TableCell>
<TableCell className="font-mono text-sm tabular-nums">{m.priority}</TableCell>
<TableCell>
{m.enabled ? (
<Badge variant="success">включён</Badge>
) : (
<Badge variant="secondary">выключен</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { Button } from '@evobgp/ui/components/button'
import { PageHeader } from '@/components/page-header'
export const Route = createFileRoute('/_auth/modules/new')({
component: NewModuleComponent,
})
function NewModuleComponent() {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<PageHeader
title="Новый модуль"
description="Создание модуля — через API или будущая форма"
/>
<Card>
<CardContent className="flex flex-col gap-3 py-6 text-sm text-muted-foreground">
<p>
Форма создания модуля будет добавлена позже. Сейчас модули можно создать через API:
</p>
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{`POST /v1/modules
{ "type": "DOMAINS", "name": "Мой список" }`}
</pre>
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
Назад к списку
</Button>
</CardContent>
</Card>
</div>
)
}
+457
View File
@@ -0,0 +1,457 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Activity, AlertTriangle, Bird, Database, Gauge, HardDrive, HeartPulse, Info, ListTodo, RefreshCw, ShieldCheck } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Separator } from '@evobgp/ui/components/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import {
monitoringHealthQueryOptions,
monitoringReadyQueryOptions,
monitoringVersionQueryOptions,
type ReadyStatus,
type VersionInfo,
} from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network'
import { operationsJobsQueryOptions } from '@/queries/operations'
export const Route = createFileRoute('/_auth/monitoring')({
component: MonitoringComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'postgres' || search.tab === 'runtime-logs' ? search.tab : 'system') as
| 'system'
| 'postgres'
| 'runtime-logs',
}),
})
function MonitoringComponent() {
const search = useSearch({ from: '/_auth/monitoring' })
const healthQ = useQuery(monitoringHealthQueryOptions())
const readyQ = useQuery(monitoringReadyQueryOptions())
const versionQ = useQuery(monitoringVersionQueryOptions())
const birdQ = useQuery(networkBirdQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions())
const refreshing =
healthQ.isFetching ||
readyQ.isFetching ||
versionQ.isFetching ||
birdQ.isFetching ||
jobsQ.isFetching
const jobs = jobsQ.data?.items ?? []
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const versionText = formatVersion(versionQ.data)
const items: SectionCardItem[] = [
{
label: 'Общий статус',
value: overallStatusLabel({ health: healthQ.data, ready: readyQ.data, jobsFailed: failed }),
icon: <Gauge className="size-4" />,
hint: overallHint({ health: healthQ.data, jobsFailed: failed }),
},
{
label: 'BGP сессии',
value: birdQ.data
? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}`
: '—',
icon: <Bird className="size-4" />,
hint: birdQ.data?.birdc_configured
? 'Established / total на API-хосте'
: 'birdc не настроен',
},
{
label: 'Задачи',
value: running,
icon: <Activity className="size-4" />,
hint: `активных из ${jobs.length}`,
variant: failed > 0 ? 'warning' : 'default',
},
{
label: 'Версия',
value: versionText,
icon: <Gauge className="size-4" />,
hint: versionQ.data?.git_sha ?? versionQ.data?.build_time ?? 'GET /v1/version',
},
]
function refetchAll() {
void healthQ.refetch()
void readyQ.refetch()
void versionQ.refetch()
void birdQ.refetch()
void jobsQ.refetch()
}
const failedJobs = jobs
.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
.slice(0, 5)
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Мониторинг"
description="Состояние API, BGP и задач для диагностики инцидентов"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Tabs defaultValue={search.tab}>
<TabsList>
<TabsTrigger value="system">Система</TabsTrigger>
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
</TabsList>
<TabsContent value="system" className="mt-4 flex flex-col gap-6">
{refreshing ? <SectionCardsSkeleton count={4} /> : <SectionCards items={items} />}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Доступность и готовность</CardTitle>
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<QueryState
data={readyQ.data}
isLoading={readyQ.isLoading}
isError={readyQ.isError}
error={readyQ.error}
skeleton={<div className="h-40" />}
onRetry={() => readyQ.refetch()}
>
{(ready) => <ReadyTable health={healthQ.data} ready={ready} />}
</QueryState>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bird className="size-4" />
BGP на API-хосте
</CardTitle>
<CardDescription>GET /v1/bird/status</CardDescription>
</CardHeader>
<CardContent>
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<div className="h-40" />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</CardContent>
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="size-4" />
Задачи
</CardTitle>
<CardDescription>Последние 100 задач · GET /v1/jobs</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-4 text-sm">
<Metric label="Активных" value={running} />
<Metric
label="С ошибками"
value={failed}
valueClass={failed > 0 ? 'text-warning' : 'text-success'}
/>
<Metric label="В выборке" value={jobs.length} />
</div>
<Separator />
{failedJobs.length > 0 ? (
<div className="space-y-3">
<p className="text-sm font-medium">Последние ошибки</p>
<ul className="space-y-2">
{failedJobs.map((job) => (
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
<div className="flex items-start justify-between gap-2">
<p className="font-medium">{job.kind}</p>
<Badge variant="destructive">{job.status}</Badge>
</div>
{job.error ? (
<p className="mt-1 text-xs text-muted-foreground">
{job.error.slice(0, 120)}
{job.error.length > 120 ? '…' : ''}
</p>
) : null}
</li>
))}
</ul>
</div>
) : (
<p className="text-sm text-muted-foreground">
Критичных сбоев в последних 100 задачах нет.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="size-4 text-muted-foreground" />
Что проверять при деградации
</CardTitle>
<CardDescription>Короткая шпаргалка для triage</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Alert>
<HeartPulse className="size-4" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
Если <code className="text-xs">/v1/health</code> возвращает ошибку проверьте процесс
API и его логи.
</AlertDescription>
</Alert>
<Alert>
<Database className="size-4" />
<AlertTitle>Readiness не «Готов»</AlertTitle>
<AlertDescription>
Сначала <code className="text-xs">postgres</code>, затем{' '}
<code className="text-xs">store</code> и <code className="text-xs">jobs</code> в checks.
</AlertDescription>
</Alert>
<Alert>
<Bird className="size-4" />
<AlertTitle>Низкий ratio BGP</AlertTitle>
<AlertDescription>
Проверьте <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</AlertDescription>
</Alert>
<Alert>
<ListTodo className="size-4" />
<AlertTitle>Ошибки задач</AlertTitle>
<AlertDescription>
Откройте Операции и проверьте последние неуспешные jobs.
</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="postgres" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-base">PostgreSQL</CardTitle>
<CardDescription>Статус соединения и пул</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<Database className="size-4" />
<AlertTitle>Статус готовности</AlertTitle>
<AlertDescription>
PostgreSQL-соединение отображается в readiness-проверке на вкладке «Система» (check{' '}
<code className="text-xs">postgres</code>).
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="runtime-logs" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Файловые логи</CardTitle>
<CardDescription>Логи API и pipeline</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<Info className="size-4" />
<AlertTitle>Логи на сервере</AlertTitle>
<AlertDescription>
Файловые логи настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
управляются tenant-settings на странице «Настройки BIRD».
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function Metric({ label, value, valueClass }: { label: string; value: number | string; valueClass?: string }) {
return (
<div>
<p className="text-muted-foreground">{label}</p>
<p className={`text-2xl font-bold tabular-nums ${valueClass ?? ''}`}>{value}</p>
</div>
)
}
function formatVersion(version?: VersionInfo | null): string {
if (!version) return '—'
return version.version ?? version.app ?? '—'
}
interface OverallInput {
health?: { ok?: boolean } | null
ready?: ReadyStatus | null
jobsFailed: number
}
function overallStatusLabel(input: OverallInput): string {
if (!input.health?.ok) return 'Ошибка'
if (input.jobsFailed > 0) return 'Внимание'
if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание'
return 'В норме'
}
function overallHint(input: OverallInput): string {
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
return 'Все системы работают в штатном режиме'
}
function ReadyTable({
health,
ready,
}: {
health?: { ok?: boolean; status?: string; error?: string } | null
ready: ReadyStatus
}) {
const checks = ready.checks ?? {}
const iconByKey: Record<string, typeof Database> = {
postgres: Database,
store: HardDrive,
jobs: ListTodo,
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[55%]">Проверка</TableHead>
<TableHead>Статус</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<HeartPulse className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Liveness</p>
<p className="text-xs text-muted-foreground">/v1/health</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={health?.ok ? 'default' : 'destructive'}>
{health?.ok ? 'OK' : 'Ошибка'}
</Badge>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Readiness</p>
<p className="text-xs text-muted-foreground">/v1/ready</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={ready.status === 'ok' ? 'default' : 'secondary'}>
{ready.status ?? '—'}
</Badge>
</TableCell>
</TableRow>
{Object.entries(checks).map(([key, value]) => {
const ok = typeof value === 'boolean' ? value : value?.ok !== false
const Icon = iconByKey[key] ?? ListTodo
return (
<TableRow key={key}>
<TableCell>
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{key}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={ok ? 'default' : 'destructive'}>{ok ? 'OK' : 'Ошибка'}</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
if (!bird.birdc_configured) {
return (
<p className="text-sm text-muted-foreground">
{bird.message ?? 'birdc не настроен на API-хосте (EVOBGP_BIRDC_SOCKET).'}
</p>
)
}
const ratio =
bird.bgp_sessions_total > 0
? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100)
: null
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Established / total</span>
<span className="font-medium tabular-nums">
{bird.bgp_established} / {bird.bgp_sessions_total}
{ratio !== null ? <span className="text-muted-foreground"> ({ratio}%)</span> : null}
</span>
</div>
{ratio !== null ? (
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full transition-all ${
ratio >= 100 ? 'bg-success' : ratio >= 50 ? 'bg-warning' : 'bg-destructive'
}`}
style={{ width: `${ratio}%` }}
/>
</div>
) : null}
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
</div>
)
}
+273
View File
@@ -0,0 +1,273 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Info, RefreshCw } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { Badge } from '@/components/reui/badge'
import { StatusBadge } from '@/components/status-badge'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
import { aggregateNetworkMetrics } from '@/queries/overview'
export const Route = createFileRoute('/_auth/network')({
component: NetworkComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'peers' || search.tab === 'speakers' || search.tab === 'control-plane'
? search.tab
: 'overview') as 'overview' | 'peers' | 'speakers' | 'control-plane',
}),
})
function NetworkComponent() {
const search = useSearch({ from: '/_auth/network' })
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const refreshing = peersQ.isFetching || speakersQ.isFetching
const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const net = aggregateNetworkMetrics(peers, speakers)
function refetchAll() {
void peersQ.refetch()
void speakersQ.refetch()
void birdQ.refetch()
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Сеть"
description="BGP-пиры, спикеры и live-метрики нод"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О сетевой конфигурации</AlertTitle>
<AlertDescription>
Вкладка «Обзор» live-статус agent и BGP на CP и репликах. Apply и ревизии на странице «Операции».
</AlertDescription>
</Alert>
<Tabs defaultValue={search.tab}>
<TabsList>
<TabsTrigger value="overview">Обзор</TabsTrigger>
<TabsTrigger value="peers">Пиры ({peers.length})</TabsTrigger>
<TabsTrigger value="speakers">Спикеры ({speakers.length})</TabsTrigger>
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Сводка сети</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
<Field label="Пиры всего" value={String(net.peersTotal)} />
<Field label="Established" value={`${net.peersEstablished} / ${net.peersEnabled}`} />
<Field label="Спикеры всего" value={String(net.speakersTotal)} />
<Field label="Online" value={`${net.speakersOnline} / ${net.speakersTotal}`} />
{net.peersMismatch > 0 ? (
<Field label="Mismatches" value={String(net.peersMismatch)} />
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
<CardDescription>Статус birdc на хосте API</CardDescription>
</CardHeader>
<CardContent className="p-4">
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<TableSkeleton rows={3} cols={2} />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="peers" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Пиры</CardTitle>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={peers}
isLoading={peersQ.isLoading}
isError={peersQ.isError}
error={peersQ.error}
empty={peers.length === 0}
emptyTitle="Нет пиров"
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => peersQ.refetch()}
>
{(items) => <PeersTable items={items} />}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="speakers" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Спикеры</CardTitle>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={speakers}
isLoading={speakersQ.isLoading}
isError={speakersQ.isError}
error={speakersQ.error}
empty={speakers.length === 0}
emptyTitle="Нет спикеров"
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => speakersQ.refetch()}
>
{(items) => <SpeakersTable items={items} />}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="control-plane" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle>
<CardDescription>Конфигурация tenant-level в разделе «Настройки BIRD»</CardDescription>
</CardHeader>
<CardContent className="p-4 text-sm text-muted-foreground">
См. раздел «Настройки BIRD».
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
return (
<div className="flex flex-col gap-2 text-sm">
<Field
label="Состояние"
value={bird.healthy === true ? 'В норме' : bird.healthy === false ? 'Проблема' : 'Н/Д'}
/>
<Field label="Сессий BGP" value={`${bird.bgp_established} / ${bird.bgp_sessions_total}`} />
{bird.message ? <p className="text-xs text-muted-foreground">{bird.message}</p> : null}
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
</div>
)
}
function PeersTable({ items }: { items: import('@/types/api').PeerRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Neighbor</TableHead>
<TableHead>ASN</TableHead>
<TableHead>Состояние</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name ?? p.neighbor}</TableCell>
<TableCell className="font-mono text-xs">{p.neighbor}</TableCell>
<TableCell className="font-mono text-xs">{p.remote_asn ?? '—'}</TableCell>
<TableCell>
<StatusBadge status={p.session_state} />
{p.session_mismatch ? (
<Badge variant="warning" className="ml-1">
mismatch
</Badge>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function SpeakersTable({ items }: { items: import('@/types/api').SpeakerRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Endpoint</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Agent</TableHead>
<TableHead>BGP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-mono text-xs">{s.endpoint}</TableCell>
<TableCell>
<Badge variant="outline">{s.role}</Badge>
</TableCell>
<TableCell>
{s.live?.agent_ok === true ? (
<StatusBadge status="ok" label="online" />
) : s.live?.agent_ok === false ? (
<StatusBadge status="error" label="offline" />
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell>
{s.live ? (
<span className="text-xs">
{s.live.bgp_established ?? 0} / {s.live.bgp_sessions_total ?? 0}
</span>
) : (
'—'
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+457
View File
@@ -0,0 +1,457 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState, useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
import { apiMutate, waitForJob } from '@/lib/api-client'
import type { JobRow } from '@/types/api'
export const Route = createFileRoute('/_auth/operations')({
component: OperationsComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'diff' || search.tab === 'jobs' ? search.tab : 'revisions') as
| 'revisions'
| 'diff'
| 'jobs',
}),
})
function OperationsComponent() {
const search = useSearch({ from: '/_auth/operations' })
const qc = useQueryClient()
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions())
const modulesQ = useQuery(overviewModulesQueryOptions())
const revisions = revisionsQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const nameById = moduleNameById(modulesQ.data?.items ?? [])
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const failed = jobs.filter(
(j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const items: SectionCardItem[] = [
{
label: 'Ревизий',
value: revisions.length,
icon: <Activity className="size-4" />,
hint: 'история конфигов',
},
{
label: 'Активных задач',
value: running,
icon: <Clock className="size-4" />,
hint: 'queued и running',
},
{
label: 'Задач с ошибкой',
value: failed,
icon: <AlertTriangle className="size-4" />,
hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет',
variant: failed > 0 ? 'warning' : 'default',
},
]
function refetchAll() {
void revisionsQ.refetch()
void jobsQ.refetch()
void modulesQ.refetch()
}
const applyMutation = useMutation({
mutationFn: async () => {
const revId = revisions[0]?.id
if (!revId) throw new Error('Нет ревизий')
const res = await apiMutate<{ job_id: string }>('/v1/apply', 'POST', { revision_id: revId })
if (!res.job_id) throw new Error('Ответ API без job_id')
const job = await waitForJob(res.job_id, { timeoutMs: 180_000 })
if (job.status !== 'succeeded') throw new Error(job.error ?? job.status)
return job
},
onSuccess: () => {
toast.success('Конфигурация успешно применена')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось применить'),
})
const birdReloadMutation = useMutation({
mutationFn: async () => {
const res = await apiMutate<{ job_id: string }>('/v1/bird/reload', 'POST', {})
if (!res.job_id) throw new Error('Ответ API без job_id')
const job = await waitForJob(res.job_id, { timeoutMs: 120_000 })
if (job.status !== 'succeeded') throw new Error(job.error ?? job.status)
return job
},
onSuccess: () => toast.success('Команда birdc configure выполнена'),
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось перезагрузить'),
})
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Ревизии и операции"
description="Деплой конфигурации, управление ревизиями и задачами"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Три раздела на одной странице</AlertTitle>
<AlertDescription>
<strong>Ревизии</strong> история конфигов и откат; <strong>Сравнение</strong> diff
префиксов; <strong>Задачи</strong> ingest, apply, rollback.
</AlertDescription>
</Alert>
<div className="flex flex-wrap gap-2">
<ConfirmDialog
trigger={
<Button variant="default" size="sm" disabled={applyMutation.isPending}>
Apply
</Button>
}
title="Применить конфигурацию на всех спикерах?"
description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator."
confirmLabel="Применить"
onConfirm={() => applyMutation.mutate()}
/>
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" disabled={birdReloadMutation.isPending}>
BIRD reload
</Button>
}
title="Перезагрузить BIRD?"
description="BIRD перезагрузит конфигурацию. Требуется роль operator."
confirmLabel="Перезагрузить"
onConfirm={() => birdReloadMutation.mutate()}
/>
</div>
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<Tabs defaultValue={search.tab}>
<TabsList>
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
<TabsTrigger value="diff">Сравнение</TabsTrigger>
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
</TabsList>
<TabsContent value="revisions" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">История ревизий</CardTitle>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={revisions}
isLoading={revisionsQ.isLoading}
isError={revisionsQ.isError}
error={revisionsQ.error}
empty={revisions.length === 0}
emptyTitle="Нет ревизий"
onRetry={() => revisionsQ.refetch()}
>
{(items) => <RevisionsTable items={items} qc={qc} />}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="diff" className="mt-4">
<DiffTab revisions={revisions} />
</TabsContent>
<TabsContent value="jobs" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Задачи</CardTitle>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={jobs}
isLoading={jobsQ.isLoading}
isError={jobsQ.isError}
error={jobsQ.error}
empty={jobs.length === 0}
emptyTitle="Нет задач"
onRetry={() => jobsQ.refetch()}
>
{(items) => <JobsTable items={items} nameById={nameById} qc={qc} />}
</QueryState>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function RevisionsTable({
items,
qc,
}: {
items: import('@/types/api').RevisionRow[]
qc: import('@tanstack/react-query').QueryClient
}) {
const rollbackMutation = useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
onSuccess: () => {
toast.success('Откат выполнен')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
})
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Префиксов</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.id.slice(0, 12)}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(r.created_at).toLocaleString('ru-RU')}
</TableCell>
<TableCell className="font-mono text-sm tabular-nums">
{r.materialized_prefix_count}
</TableCell>
<TableCell>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" className="text-destructive">
<RefreshCw className="size-3.5" />
</Button>
}
title={`Откатиться к ревизии ${r.id.slice(0, 8)}…?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
confirmLabel="Откатить"
destructive
onConfirm={() => rollbackMutation.mutate(r.id)}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function JobsTable({
items,
nameById,
qc,
}: {
items: JobRow[]
nameById: Map<string, string>
qc: import('@tanstack/react-query').QueryClient
}) {
const cancelMutation = useMutation({
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
onSuccess: () => {
toast.success('Задача отменена')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
})
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Вид</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Завершена</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((j) => (
<TableRow key={j.job_id}>
<TableCell className="font-medium">
<div className="flex flex-col gap-0.5">
<span>{j.kind}</span>
{j.meta?.module_id ? (
<span className="text-xs text-muted-foreground">
{nameById.get(String(j.meta.module_id)) ?? String(j.meta.module_id)}
</span>
) : null}
</div>
</TableCell>
<TableCell>
<StatusBadgeColored status={j.status} />
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell>
{j.status === 'running' || j.status === 'queued' ? (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
onClick={() => cancelMutation.mutate(j.job_id)}
>
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function StatusBadgeColored({ status }: { status: string }) {
const cls =
status === 'succeeded'
? 'text-success'
: status === 'failed' || status === 'cancelled'
? 'text-destructive'
: 'text-info'
return <span className={`text-sm font-medium ${cls}`}>{status}</span>
}
function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) {
const [a, setA] = useState('')
const [b, setB] = useState('')
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
const revisionItems = useMemo(
() =>
revisions.map((r) => ({
value: r.id,
label: `${r.id.slice(0, 12)}`,
})),
[revisions],
)
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Сравнение ревизий</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия A</span>
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.id.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия B</span>
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.id.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
Сравнить
</Button>
</div>
<QueryState
data={diffQ.data}
isLoading={diffQ.isFetching}
isError={diffQ.isError}
error={diffQ.error}
empty={!diffQ.data}
emptyTitle="Выберите две ревизии"
onRetry={() => diffQ.refetch()}
>
{(diff) => <DiffView diff={diff} />}
</QueryState>
</CardContent>
</Card>
)
}
function DiffView({ diff }: { diff: import('@/types/api').RevisionDiff }) {
const added = diff.prefixes?.added ?? (diff.added as string[]) ?? []
const removed = diff.prefixes?.removed ?? (diff.removed as string[]) ?? []
return (
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="mb-2 text-sm font-medium text-success">Добавлено: {added.length}</p>
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{added.join('\n')}
</pre>
</div>
<div>
<p className="mb-2 text-sm font-medium text-destructive">Удалено: {removed.length}</p>
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{removed.join('\n')}
</pre>
</div>
</div>
)
}
+263
View File
@@ -0,0 +1,263 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Info, ListTodo, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { LoadingButton } from '@/components/loading-button'
import { operationsJobsQueryOptions } from '@/queries/operations'
import { modulesListQueryOptions } from '@/queries/modules'
import { apiMutate } from '@/lib/api-client'
import type { JobRow } from '@/types/api'
export const Route = createFileRoute('/_auth/schedule')({
component: ScheduleComponent,
})
function ScheduleComponent() {
const modulesQ = useQuery(modulesListQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions())
const qc = useQueryClient()
const [refreshing, setRefreshing] = useState<Record<string, boolean>>({})
const modules = modulesQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const loading = modulesQ.isLoading || jobsQ.isLoading
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const items: SectionCardItem[] = [
{ label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' },
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'queued и running' },
{
label: 'С ошибкой',
value: failed,
icon: <AlertTriangle className="size-4" />,
hint: failed > 0 ? 'требуют внимания' : 'без ошибок',
variant: failed > 0 ? 'warning' : 'default',
},
]
const refreshMutation = useMutation({
mutationFn: async (id: string) => apiMutate<{ job_id?: string }>(`/v1/modules/${id}/refresh`, 'POST'),
onMutate: (id) => setRefreshing((s) => ({ ...s, [id]: true })),
onSuccess: (data, id) => {
if (data === undefined) toast.message('Обновление не требуется (тип IP_RANGES)')
else toast.success('Задача поставлена в очередь')
void qc.invalidateQueries({ queryKey: ['operations'] })
void qc.invalidateQueries({ queryKey: ['modules'] })
setRefreshing((s) => ({ ...s, [id]: false }))
},
onError: (e, id) => {
toast.error(e instanceof Error ? e.message : 'Не удалось запустить')
setRefreshing((s) => ({ ...s, [id]: false }))
},
})
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Расписание и задачи"
description="Интервалы обновления модулей и ручной запуск"
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void modulesQ.refetch()
void jobsQ.refetch()
}}
disabled={loading}
>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Как работает расписание</AlertTitle>
<AlertDescription>
Планировщик использует <code className="text-xs">refresh_interval_sec</code> и опционально{' '}
<code className="text-xs">cron_expr</code>. Ручной запуск {' '}
<code className="text-xs">POST /v1/modules/&#123;id&#125;/refresh</code>.
</AlertDescription>
</Alert>
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<Card>
<CardHeader>
<CardTitle className="text-base">Модули</CardTitle>
<CardDescription>Расписание обновления и ручной запуск ingest</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={modules}
isLoading={modulesQ.isLoading}
isError={modulesQ.isError}
error={modulesQ.error}
empty={modules.length === 0}
emptyTitle="Нет модулей"
onRetry={() => modulesQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Модуль</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Расписание</TableHead>
<TableHead>Обновлено</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="w-32 text-right" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.name}</TableCell>
<TableCell>
<Badge variant="outline">{m.type}</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{m.cron_expr ?? (m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—')}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell>
{m.enabled ? (
<Badge variant="default">Вкл</Badge>
) : (
<Badge variant="secondary">Выкл</Badge>
)}
</TableCell>
<TableCell className="text-right">
<LoadingButton
size="sm"
variant="secondary"
loading={!!refreshing[m.id]}
onClick={() => refreshMutation.mutate(m.id)}
>
<RefreshCw />
Обновить
</LoadingButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Задачи</CardTitle>
<CardDescription>Последние задачи из API</CardDescription>
</CardHeader>
<CardContent className="p-0">
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
</CardContent>
</Card>
</div>
)
}
function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
const refresh = jobs.filter((j) => j.kind === 'module_refresh')
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
)
return (
<Tabs defaultValue="all">
<TabsList>
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger>
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger>
</TabsList>
<TabsContent value="all" className="mt-0">
<JobsTable items={jobs} loading={loading} />
</TabsContent>
<TabsContent value="refresh" className="mt-0">
<JobsTable items={refresh} loading={loading} />
</TabsContent>
<TabsContent value="failed" className="mt-0">
<JobsTable items={failed} loading={loading} />
</TabsContent>
</Tabs>
)
}
function JobsTable({ items, loading }: { items: JobRow[]; loading: boolean }) {
if (loading) return <div className="p-6 text-center text-sm text-muted-foreground">Загрузка</div>
if (items.length === 0)
return <div className="p-6 text-center text-sm text-muted-foreground">Нет задач</div>
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Вид</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Завершена</TableHead>
<TableHead>Ошибка</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((j) => (
<TableRow key={j.job_id}>
<TableCell className="font-medium">{j.kind}</TableCell>
<TableCell>
<Badge
variant={
j.status === 'succeeded'
? 'default'
: j.status === 'failed'
? 'destructive'
: 'secondary'
}
>
{j.status}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="max-w-xs truncate text-xs text-destructive">
{j.error ?? ''}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+163
View File
@@ -0,0 +1,163 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button'
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
import { toast } from 'sonner'
import { Info, Save } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
export const Route = createFileRoute('/_auth/settings')({
component: SettingsComponent,
})
const THEME_SELECT_ITEMS = [
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' },
{ value: 'system', label: 'Как в системе' },
] as const
function SettingsComponent() {
const navigate = useNavigate()
const qc = useQueryClient()
const { data: session, isError: sessionError, error: sessionQueryError } = useQuery({
...authSessionQueryOptions(),
enabled: Boolean(
typeof window !== 'undefined' && window.localStorage.getItem(TOKEN_STORAGE_KEY)?.trim(),
),
})
const { theme, setTheme } = useTheme()
const [token, setTokenValue] = useState('')
useEffect(() => {
const t = window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''
setTokenValue(t)
}, [])
async function applyToken(raw: string) {
const normalized = normalizeApiToken(raw)
setToken(normalized || null)
setTokenValue(normalized)
await qc.invalidateQueries({ queryKey: authKeys.all })
toast.success('Токен сохранён')
if (normalized) {
void navigate({ to: '/dashboard' })
}
}
function saveTokenHandler() {
void applyToken(token)
}
function useDevToken() {
void applyToken(DEV_API_TOKEN)
}
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<PageHeader
title="Настройки"
description="Параметры интерфейса и подключения браузера к API."
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Локальная разработка</AlertTitle>
<AlertDescription>
При включённом demo-seed API принимает токен <code className="text-xs">dev</code> (роль{' '}
<code className="text-xs">operator</code>). Вводите только значение токена, без префикса{' '}
<code className="text-xs">Bearer</code> он добавляется автоматически.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Подключение к API</CardTitle>
<CardDescription>
Токен хранится только в этом браузере (localStorage). Управление ключами tenant в
разделе «Права доступа».
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="token">Токен для запросов</Label>
<Input
id="token"
type="password"
autoComplete="off"
value={token}
onChange={(e) => setTokenValue(e.target.value)}
placeholder="dev или API-ключ"
/>
</div>
<div className="flex flex-wrap gap-2">
<LoadingButton onClick={saveTokenHandler}>
<Save />
Сохранить токен
</LoadingButton>
<Button type="button" variant="outline" onClick={useDevToken}>
Использовать dev
</Button>
</div>
{session ? (
<p className="text-xs text-muted-foreground">
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
<code className="font-mono">{session.role}</code>.
</p>
) : null}
{sessionError ? (
<p className="text-xs text-destructive">
{sessionQueryError instanceof Error
? sessionQueryError.message
: 'Не удалось проверить сессию'}
. Для токена <code className="font-mono">dev</code> нужен demo-seed (
<code className="text-xs">EVOBGP_SEED_DEMO</code> 0) и запущенный API.
</p>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Оформление</CardTitle>
<CardDescription>
Тема интерфейса. Быстрый переключатель также доступен в боковой панели.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<Label htmlFor="theme-select">Тема</Label>
<Select
items={[...THEME_SELECT_ITEMS]}
value={theme ?? 'system'}
onValueChange={(v) => v && setTheme(v)}
>
<SelectTrigger id="theme-select" className="w-full max-w-xs">
<SelectValue placeholder="Выберите тему" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Светлая</SelectItem>
<SelectItem value="dark">Тёмная</SelectItem>
<SelectItem value="system">Как в системе</SelectItem>
</SelectContent>
</Select>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,393 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Info, Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { LoadingButton } from '@/components/loading-button'
import {
BIRD_SETTING_KEYS,
REVISION_SETTING_KEYS,
RUNTIME_LOGS_SETTING_KEYS,
buildPayload,
partitionSettings,
settingsKeys,
settingsQueryOptions,
type BirdSettingKey,
} from '@/queries/settings'
import { apiMutate } from '@/lib/api-client'
export const Route = createFileRoute('/_auth/tenant-settings')({
component: TenantSettingsComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
? search.tab
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
}),
})
const RUNTIME_LOGS_ENABLED_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'truncate — обнулить' },
{ value: 'delete', label: 'delete — удалить файл' },
] as const
const BIRD_LABELS: Record<BirdSettingKey, string> = {
bird_router_id: 'Router ID',
bird_local_ipv4: 'Локальный IPv4',
bird_local_ipv6: 'Локальный IPv6',
bird_local_asn: 'Локальный ASN',
bird_bgp_source_ipv4: 'BGP source IPv4',
bird_bgp_source_ipv6: 'BGP source IPv6',
}
function TenantSettingsComponent() {
const search = useSearch({ from: '/_auth/tenant-settings' })
const settingsQ = useQuery(settingsQueryOptions())
const qc = useQueryClient()
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
useEffect(() => {
if (partitioned) {
setBirdForm({ ...partitioned.bird })
setRevisionForm({ ...partitioned.revision })
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsQ.data])
const patchMutation = useMutation({
mutationFn: (payload: Record<string, string | number | boolean>) =>
apiMutate('/v1/settings', 'PATCH', payload),
onSuccess: () => {
toast.success('Параметры сохранены')
void qc.invalidateQueries({ queryKey: settingsKeys.all })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
function saveBird() {
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
}
function saveRevision() {
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
}
function saveRuntimeLogs() {
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Параметры tenant"
description="Параметры control plane для текущего tenant (API /v1/settings)"
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Operator-only</AlertTitle>
<AlertDescription>
Изменение значений через <code className="text-xs">PATCH /v1/settings</code> требует роли
operator. При отсутствии прав API вернёт 403.
</AlertDescription>
</Alert>
<Tabs defaultValue={search.tab}>
<TabsList>
<TabsTrigger value="bird">BIRD</TabsTrigger>
<TabsTrigger value="revision">Ревизии</TabsTrigger>
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
</TabsList>
<TabsContent value="bird" className="mt-4">
<Card>
<CardHeader>
<CardTitle>BIRD control plane</CardTitle>
<CardDescription>
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Подстановка в конфиг</AlertTitle>
<AlertDescription>
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса).
Пиры и спикеры настраиваются в разделе «Сеть».
</AlertDescription>
</Alert>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-64" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="grid gap-4 md:grid-cols-2">
{BIRD_SETTING_KEYS.map((key) => (
<div key={key} className="flex flex-col gap-1.5">
<Label htmlFor={key}>{BIRD_LABELS[key]}</Label>
<Input
id={key}
value={birdForm[key] ?? ''}
onChange={(e) => setBirdForm((s) => ({ ...s, [key]: e.target.value }))}
placeholder={BIRD_LABELS[key]}
/>
<p className="font-mono text-xs text-muted-foreground">{key}</p>
</div>
))}
<div className="md:col-span-2">
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
</div>
</div>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="revision" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Ревизии</CardTitle>
<CardDescription>Время хранения ревизий в БД</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="flex max-w-sm flex-col gap-1.5">
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label>
<Input
id="revision_retention_minutes"
type="number"
value={revisionForm.revision_retention_minutes ?? ''}
onChange={(e) =>
setRevisionForm((s) => ({
...s,
revision_retention_minutes: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
revision_retention_minutes
</p>
<LoadingButton
className="mt-2 w-fit"
onClick={saveRevision}
loading={patchMutation.isPending}
>
<Save />
Сохранить
</LoadingButton>
</div>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="runtime-logs" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Файловые логи</CardTitle>
<CardDescription>Автоматическая очистка логов</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-48" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>Авто-очистка включена</Label>
<Select
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_enabled: v,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Вкл</SelectItem>
<SelectItem value="false">Выкл</SelectItem>
</SelectContent>
</Select>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_enabled
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
<Input
id="runtime_logs_max_file_mb"
type="number"
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_max_file_mb: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_max_file_mb
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label>
<Input
id="runtime_logs_auto_schedule"
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_schedule: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_schedule
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>Режим очистки</Label>
<Select
items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_mode: v,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="truncate">truncate обнулить</SelectItem>
<SelectItem value="delete">delete удалить файл</SelectItem>
</SelectContent>
</Select>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_mode
</p>
</div>
<div className="md:col-span-2">
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
</div>
</div>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="additional" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Дополнительные параметры</CardTitle>
<CardDescription>
Параметры вне стандартных групп (readonly изменяются только через API)
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={partitioned?.additional ?? []}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
empty={(partitioned?.additional ?? []).length === 0}
emptyTitle="Нет дополнительных параметров"
skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Ключ</TableHead>
<TableHead>Значение</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.key}</TableCell>
<TableCell className="font-mono text-xs">{row.value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
beforeLoad: () => {
throw redirect({ to: '/dashboard' })
},
})
+382
View File
@@ -0,0 +1,382 @@
// ---- Pagination ----
export type Page<T> = {
items: T[]
next_cursor: string | null
has_more: boolean
}
// ---- Modules ----
export type ModuleType = 'AS_PREFIXES' | 'CDN_CIDRS' | 'DOMAINS' | 'IP_RANGES'
export type DohResolverPolicy = 'primary_only' | 'failover' | 'union'
export type ModuleRow = {
id: string
type: ModuleType
name: string
enabled: boolean
priority: number
refresh_interval_sec: number | null
cron_expr: string | null
default_community_id: string | null
/** @deprecated use doh_profile_ids */
doh_profile_id: string | null
doh_profile_ids: string[]
doh_resolver_policy: DohResolverPolicy
last_refreshed_at: string | null
}
export type ModulesResponse = Page<ModuleRow>
export type RouterListsCatalogResponse = {
modules: { items: ModuleRow[] }
domains: { items: { module_id: string; entry: DomainEntry }[] }
asns: { items: { module_id: string; entry: AsEntry }[] }
ip_ranges: { items: { module_id: string; entry: IpRangeEntry }[] }
communities: { items: BgpCommunity[] }
}
export type ModuleCreate = {
type: ModuleType
name: string
enabled?: boolean
priority?: number
doh_profile_id?: string | null
doh_profile_ids?: string[]
doh_resolver_policy?: DohResolverPolicy
refresh_interval_sec?: number | null
cron_expr?: string | null
default_community_id?: string | null
}
export type ModulePatch = Partial<Omit<ModuleCreate, 'type'>>
// ---- AS Entries ----
export type AsEntry = {
id: string
asn: number
community_id: string | null
/** Имя/держатель AS (RIPEstat), после успешного обновления модуля */
asn_name?: string | null
/** Число объявленных префиксов на момент последнего резолва */
prefix_count?: number | null
/** ISO-время последнего успешного резолва ASN */
asn_resolved_at?: string | null
}
export type AsEntryCreate = {
asn: number
community_id?: string | null
}
export type AsEntryPatch = {
asn?: number
community_id?: string | null
}
export type AsEntriesResponse = Page<AsEntry>
// ---- CDN Sources ----
export type CdnSource = {
id: string
url: string
source_kind: string
prefix_path: string
community_id: string | null
refresh_interval_sec: number | null
last_refreshed_at: string | null
}
export type CdnSourceCreate = {
url: string
source_kind: string
prefix_path?: string
community_id?: string | null
}
export type CdnSourcePatch = Partial<CdnSourceCreate> & { refresh_interval_sec?: number | null }
export type CdnSourcesResponse = Page<CdnSource>
export type CdnPreviewResponse = {
items: string[]
total: number
truncated: boolean
source_url: string
}
// ---- Domain Entries ----
export type DomainEntry = {
id: string
fqdn: string
community_id: string | null
}
export type DomainEntryCreate = {
fqdn: string
community_id?: string | null
}
export type DomainEntriesResponse = Page<DomainEntry>
// ---- IP Range Entries ----
export type IpRangeEntry = {
id: string
prefix: string
community_id: string
}
export type IpRangeEntryCreate = {
prefix: string
community_id: string
}
export type IpRangeEntriesResponse = Page<IpRangeEntry>
// ---- DoH Profiles ----
export type DohProfile = {
id: string
name?: string
url: string
timeout_ms: number | null
vault_secret_ref: string | null
}
export type DohProfileCreate = {
name?: string
url: string
timeout_ms?: number | null
vault_secret_ref?: string | null
}
export type DohProfilePatch = Partial<DohProfileCreate>
export type DohProfilesResponse = Page<DohProfile>
// ---- Communities ----
export type BgpCommunity = {
id: string
community: string
title: string
}
export type BgpCommunityCreate = {
community: string
title?: string
}
export type BgpCommunityPatch = Partial<BgpCommunityCreate>
export type CommunitiesResponse = Page<BgpCommunity>
// ---- Peers ----
export type PeerSessionOnSpeaker = {
speaker_id: string
label: string
state: string
poll_error?: string
}
export type PeerRow = {
id: string
name?: string
neighbor: string
remote_asn?: number
enabled?: boolean
session_state: string
bgp_speaker_id: string | null
connected_speaker_id?: string | null
connected_speaker_label?: string
session_on_speakers?: PeerSessionOnSpeaker[]
established_on_speakers?: PeerSessionOnSpeaker[]
session_mismatch?: boolean
}
export type LiveSpeakerPoll = {
speaker_id: string
label: string
ok: boolean
session_count: number
poll_error?: string
}
export type PeersResponse = Page<PeerRow> & { live_speaker_poll?: LiveSpeakerPoll[] }
export type BgpPeerCreate = {
name?: string
neighbor: string
remote_asn: number
bgp_speaker_id?: string | null
enabled?: boolean
}
export type BgpPeerPatch = Partial<BgpPeerCreate>
// ---- Speakers ----
export type BgpSessionLive = {
name: string
neighbor?: string
state: string
}
export type SpeakerLiveStatus = {
label?: string
agent_ok?: boolean
agent_error?: string
agent_last_sync_at?: string
agent_last_applied_revision_id?: string
bgp_poll_ok?: boolean
bgp_poll_error?: string
bgp_sessions_total?: number
bgp_established?: number
sessions?: BgpSessionLive[]
}
export type SpeakerRow = {
id: string
role: string
endpoint: string
last_applied_revision_id: string | null
published_revision_id?: string | null
published_at?: string | null
agent_domain?: string
node_ipv4?: string
bird_bgp_source_ipv4?: string
dispatch_status?: string
sync_status?: string
last_dispatch_at?: string | null
last_dispatch_error?: string | null
meta_json?: Record<string, unknown>
agent_secret?: string
live?: SpeakerLiveStatus
}
export type SpeakersResponse = Page<SpeakerRow>
export type BgpSpeakerCreate = {
endpoint: string
role?: string
meta_json?: string
}
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>
export type BundleSigningPublicKey = {
public_key_base64: string
}
// ---- Revisions ----
export type RevisionRow = {
id: string
content_hash: string
created_at: string
parent_revision_id: string | null
materialized_prefix_count: number
module_id: string | null
}
export type RevisionsResponse = Page<RevisionRow>
export type RevisionPrefix = {
/** Обычно CIDR; для AS-модуля в снимке ревизии — строка вида `as:<номер_asn>`. */
prefix: string
/** Источник материализации (например, domain:<fqdn>, as:<asn>, cdn:<source_id>, ip_range). */
source?: string
community_id?: string | null
}
export type RevisionPrefixesResponse = Page<RevisionPrefix>
export type RevisionPreview = {
id: string
prefixes?: RevisionPrefix[]
[key: string]: unknown
}
/** Ответ GET /v1/revisions/{a}/diff/{b}: префиксы в `prefixes` (источник правды в бэкенде). */
export type RevisionDiff = {
revision_a?: string
revision_b?: string
prefixes?: {
added: string[]
removed: string[]
unchanged_count?: number
}
/** Устаревший/нормализованный вид — см. нормализацию в UI */
added?: (string | RevisionPrefix)[]
removed?: (string | RevisionPrefix)[]
[key: string]: unknown
}
// ---- BIRD (локальный birdc на хосте с API, если задан EVOBGP_BIRDC_SOCKET) ----
export type BirdStatus = {
birdc_configured: boolean
message?: string
error?: string
protocols_excerpt?: string
bgp_sessions_total: number
bgp_established: number
healthy: boolean | null
}
// ---- Jobs ----
export type JobRow = {
job_id: string
kind: string
status: string
idempotency_key?: string | null
created_at?: string
started_at?: string | null
finished_at?: string | null
error?: string | null
meta?: Record<string, unknown>
}
export type JobsResponse = Page<JobRow>
// ---- Settings ----
export type AppSettings = Record<string, unknown>
// ---- Auth / API keys ----
export type AuthSession = {
tenant_id: string
role: 'viewer' | 'editor' | 'operator' | 'node'
}
export type ApiKeyRole = AuthSession['role']
export type ApiKey = {
id: string
name: string
role: ApiKeyRole
prefix: string
created_at: string
updated_at: string
expires_at: string | null
revoked_at: string | null
last_used_at: string | null
}
export type ApiKeysResponse = Page<ApiKey>
export type ApiKeyCreate = {
name: string
role: ApiKeyRole
expires_at?: string | null
}
export type ApiKeyCreated = ApiKey & { token: string }
// ---- AsyncJobAccepted (RFC 9457 companion: 202 with job_id) ----
export type AsyncJobAccepted = {
job_id: string
}
// ---- Firewall blocklist ----
export type FirewallClient = {
id: string
name: string
hostname?: string
token_prefix: string
status: 'pending' | 'approved' | 'revoked'
last_seen_at?: string | null
last_seen_at_source?: string
last_apply_at?: string | null
last_apply_status?: string
last_apply_prefix_count?: number
last_apply_source?: string
client_version?: string
created_at: string
}
export type FirewallClientsResponse = { items: FirewallClient[] }
export type FirewallRule = {
id: string
client_id?: string | null
priority: number
action: 'block' | 'accept'
community_id?: string | null
comment?: string
}
export type FirewallRulesResponse = { items: FirewallRule[] }
export type FirewallInstallContext = {
bundle_seed: string
bundle_seed_configured: boolean
suggested_cp_url: string
install_sh_url: string
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/hooks/use-mobile": ["../../packages/ui/src/hooks/use-mobile.ts"],
"@evobgp/ui/components/*": ["../../packages/ui/src/components/*"],
"@evobgp/ui/hooks/*": ["../../packages/ui/src/hooks/*"],
"@evobgp/ui/lib/utils": ["../../packages/ui/src/lib/utils.ts"],
"@evobgp/ui/globals.css": ["../../packages/ui/src/styles/globals.css"]
},
"types": ["vite/client"],
"verbatimModuleSyntax": false,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src", "vite.config.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { TanStackRouterVite as TanStackRouterPlugin } from '@tanstack/router-plugin/vite'
import path from 'node:path'
export default defineConfig({
plugins: [
TanStackRouterPlugin({ target: 'react', autoCodeSplitting: true }),
react(),
tailwindcss(),
],
resolve: {
alias: [
// shadcn components inside packages/ui import '@/hooks/use-mobile' (their own alias).
// Vite doesn't read tsconfig paths from packages/ui; expose a fallback so those imports
// resolve to packages/ui/src/hooks instead of apps/web/src/hooks.
{
find: '@/hooks/use-mobile',
replacement: path.resolve(__dirname, '../../packages/ui/src/hooks/use-mobile.ts'),
},
{ find: '@', replacement: path.resolve(__dirname, './src') },
{ find: '@evobgp/ui/components', replacement: path.resolve(__dirname, '../../packages/ui/src/components') },
{ find: '@evobgp/ui/hooks', replacement: path.resolve(__dirname, '../../packages/ui/src/hooks') },
{ find: '@evobgp/ui/lib/utils', replacement: path.resolve(__dirname, '../../packages/ui/src/lib/utils.ts') },
],
},
server: {
port: 5173,
proxy: {
'/v1': { target: 'http://127.0.0.1:8080', changeOrigin: true },
'/metrics': { target: 'http://127.0.0.1:8080', changeOrigin: true },
},
},
test: {
environment: 'happy-dom',
globals: true,
},
})
+1 -1
View File
@@ -18,7 +18,7 @@ apt-get install -y --no-install-recommends \
cd /tmp
rm -rf "bird-${BIRD_VERSION}"
curl -fsSL "https://bird.network.cz/download/bird-${BIRD_VERSION}.tar.gz" | tar xz
curl -fsSL "https://bird.nic.cz/download/bird-${BIRD_VERSION}.tar.gz" | tar xz
cd "bird-${BIRD_VERSION}"
./configure --prefix=/usr/local --enable-client
make -j"$(nproc)"
+15 -8
View File
@@ -1,19 +1,26 @@
# syntax=docker/dockerfile:1.7
# Статическая панель EvoBGP (SvelteKit) + nginx.
# React + Vite статическая панель EvoBGP + nginx.
# Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build).
# Сборка ведётся из корня репозитория (context = "../.." в docker-bake.hcl).
FROM public.ecr.aws/docker/library/node:22-alpine AS deps
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm ci
WORKDIR /repo
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/ui/package.json ./packages/ui/
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
FROM deps AS build
COPY web/ ./
RUN npm run build
COPY tsconfig.base.json ./
COPY apps/web/ ./apps/web/
COPY packages/ui/ ./packages/ui/
RUN pnpm --filter @evobgp/web run build
FROM public.ecr.aws/docker/library/nginx:1.27-alpine AS web
ARG EVOBGP_UPSTREAM=evobgp-api
COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf
RUN sed -e "s/evobgp-api/${EVOBGP_UPSTREAM}/g" /tmp/nginx-default.conf > /etc/nginx/conf.d/default.conf \
&& rm -f /tmp/nginx-default.conf
COPY --from=web-artifacts /web/build /usr/share/nginx/html
COPY --from=web-artifacts /repo/apps/web/dist /usr/share/nginx/html
+2
View File
@@ -58,6 +58,8 @@ RUN apt-get update \
FROM runtime-base AS runtime
ARG BIN=evobgp-api
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
COPY scripts/firewall /opt/evobgp/scripts/firewall
ENV EVOBGP_FIREWALL_SCRIPTS=/opt/evobgp/scripts/firewall
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/evobgp"]

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