Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66b785f7cb | ||
|
|
cb14194a5f | ||
|
|
9c38e1bc57 | ||
|
|
4ac99e43ae | ||
|
|
f26c15401c | ||
|
|
c144b49acf | ||
|
|
db75126bea |
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: shadcn-react
|
||||
description: Управление shadcn/ui (React) + ReUI компонентами в EvoBGP — registry, CLI, импорты, матрица выбора. Использовать при любых UI-задачах в apps/web/ или packages/ui/ (новые экраны, компоненты, формы, data-grid, filters и др.).
|
||||
---
|
||||
|
||||
# shadcn/ui (React) + ReUI в EvoBGP
|
||||
|
||||
EvoBGP использует **React 19 + shadcn/ui (base-nova) + ReUI** (`style: base-nova`, не Radix). Источники правды: MCP `plugin-shadcn-shadcn` + [ui.shadcn.com/docs](https://ui.shadcn.com/docs/components) + [reui.io/docs](https://reui.io/docs/components/base/).
|
||||
|
||||
См. `.cursor/rules/web-shadcn.mdc` (основные правила) и `.cursor/rules/context7-stack.mdc` (Context7 ID стека).
|
||||
|
||||
## Порядок UI-задачи (строго)
|
||||
|
||||
0. **Codegraph** `codegraph_explore` — найти существующие реализации и shared-обёртки.
|
||||
1. **MCP `plugin-shadcn-shadcn`** — `get_project_registries` (должны быть `@shadcn` и `@reui`).
|
||||
2. **`search_items_in_registries`** — компонент/block/example:
|
||||
- shadcn primitives/blocks → omit `registries` или `["@shadcn"]`
|
||||
- Data Grid, Filters, Stepper, Kanban, Autocomplete и др. → `registries: ["@reui"]`
|
||||
3. **`get_item_examples_from_registries`** — полный код примера перед JSX.
|
||||
4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...`.
|
||||
5. Выполнить add **из `apps/web`** (не из корня монорепо, не из `packages/ui`).
|
||||
6. **CLI docs (обязательно):**
|
||||
- `@shadcn/*` → `cd apps/web && pnpm dlx shadcn@latest docs <component>` — [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components)
|
||||
- `@reui/*` → [ReUI docs](https://reui.io/docs/components/base/<name>) + [llms.txt](https://reui.io/llms.txt)
|
||||
7. Сверить examples из MCP с API из docs CLI — реализовать только после совпадения.
|
||||
8. Адаптировать под TanStack Router / Query → `apps/web/src/`.
|
||||
9. **`get_audit_checklist`** — перед merge PR.
|
||||
|
||||
## Размещение и импорты
|
||||
|
||||
| Слой | Путь | Импорт |
|
||||
|------|------|--------|
|
||||
| shadcn primitives | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
||||
| ReUI enterprise | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
||||
| Проектные обёртки | `apps/web/src/components/` | `@/components/<name>` |
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm dlx shadcn@latest add button # @shadcn primitive → packages/ui/src/components/
|
||||
pnpm dlx shadcn@latest add @reui/data-grid # ReUI enterprise → apps/web/src/components/reui/
|
||||
```
|
||||
|
||||
## Матрица выбора shadcn vs ReUI
|
||||
|
||||
| Задача | Registry | Импорт |
|
||||
|--------|----------|--------|
|
||||
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
|
||||
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
|
||||
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` |
|
||||
| Мультифильтры | `@reui` | `@/components/reui/filters` |
|
||||
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
||||
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
||||
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
||||
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
||||
|
||||
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid (через `DataGridCard`), не shadcn Data Table.
|
||||
|
||||
## Уже установленные shared-обёртки
|
||||
|
||||
В `apps/web/src/components/`:
|
||||
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
|
||||
- `QueryState` — обёртка loading/error/empty для TanStack Query
|
||||
- `EmptyState` — пустые списки
|
||||
- `ConfirmDialog` — подтверждения (не `window.confirm`)
|
||||
- `LoadingButton` — кнопка с loading-состоянием
|
||||
- `StatusBadge` — статусные бейджи
|
||||
- `SectionCards` — сетка KPI-карточек
|
||||
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
|
||||
- `TruncatedText` — текст с тултипом
|
||||
- `ModeToggle` — переключатель темы
|
||||
|
||||
Перед созданием новой обёртки — проверить существующие через Codegraph.
|
||||
|
||||
## Уже установленные ReUI-компоненты
|
||||
|
||||
В `apps/web/src/components/reui/`:
|
||||
- `autocomplete`, `badge`, `data-grid/*`, `date-selector`, `filters`, `number-field`
|
||||
|
||||
Перед добавлением дубликата — проверить Codegraph и существующие обёртки.
|
||||
|
||||
## Зависимости (только `apps/web`, не `packages/ui`)
|
||||
|
||||
| npm-пакет | ReUI-компоненты |
|
||||
|-----------|-----------------|
|
||||
| `@tanstack/react-table` | data-grid |
|
||||
| `@tanstack/react-virtual` | data-grid (virtual) |
|
||||
| `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/modifiers`, `@dnd-kit/utilities` | data-grid dnd, sortable, kanban |
|
||||
| `date-fns`, `react-day-picker` | date-selector |
|
||||
|
||||
После `shadcn add @reui/...` — проверить, что CLI добавил недостающие deps в `apps/web/package.json`.
|
||||
|
||||
## Semantic tokens (Styling)
|
||||
|
||||
ReUI расширяет тему shadcn токенами `--success`, `--info`, `--warning`, `--destructive-foreground`, `--invert` — уже в `packages/ui/src/styles/globals.css`.
|
||||
|
||||
- Badge/Alert: `variant="success"` / `"info"` / `"warning"` — не `bg-emerald-*`
|
||||
- Базовая тема: `pnpm dlx shadcn@latest apply b2fA --only theme`
|
||||
- ReUI-токены: по [Styling guide](https://reui.io/docs/styling); не править `globals.css` вручную без сверки с docs
|
||||
|
||||
## Запрещено
|
||||
|
||||
- Писать UI по памяти, не проверив MCP
|
||||
- Копипаст с ui.shadcn.com без examples/add из MCP
|
||||
- Самописные примитивы, если есть item в registry
|
||||
- Пропускать MCP «потому что компонент простой»
|
||||
- Класть ReUI в `packages/ui` или импортировать как `@evobgp/ui`
|
||||
- Radix-варианты (`/docs/components/radix/...`) — только Base UI
|
||||
- Raw Tailwind-цвета вместо ReUI semantic `variant`
|
||||
- Использовать Tabler/Bootstrap/Material UI
|
||||
|
||||
## Чеклист перед завершением UI-задачи
|
||||
|
||||
- [ ] MCP search (с правильным registry) + examples
|
||||
- [ ] `shadcn add <name>` (или `@reui/<name>`) из `apps/web`
|
||||
- [ ] Импорты: `@evobgp/ui/components/*` для shadcn, `@/components/reui/*` для ReUI
|
||||
- [ ] Зависимости в `apps/web/package.json`
|
||||
- [ ] `pnpm --filter @evobgp/web run typecheck` — exit 0
|
||||
- [ ] `pnpm --filter @evobgp/web run lint` — exit 0
|
||||
- [ ] `pnpm --filter @evobgp/web run build` — exit 0
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 44608,
|
||||
"pid": 52448,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1781240018712
|
||||
"startedAt": 1782969267595
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
---
|
||||
name: EvoBGP → React+shadcn/ui+ReUI
|
||||
overview: "Big-bang миграция web UI EvoBGP с SvelteKit+Svelte5 на React 19 + Vite + TanStack Router/Query + shadcn/ui (Base UI, base-nova) + ReUI registry, идентично эталону vps-tracker. Структура — pnpm workspaces monorepo (apps/web + packages/ui как @evobgp/ui). Скоуп: только фронтенд; Go-бэкенд, OpenAPI, nginx, compose и bake-конфиги сохраняются с минимальной правкой путей сборки. Старый web/ заменяется полностью; перенос доменных экранов идёт по одному роуту через MCP-shadcn с проверкой по docs."
|
||||
todos:
|
||||
- id: "1"
|
||||
content: "Этап 0: Установить MCP в workspace EvoBGP (plugin-shadcn-shadcn с @reui, plugin-context7, cursor-ide-browser) — скопировать .cursor/mcp.json из vps-tracker"
|
||||
status: pending
|
||||
- id: "2"
|
||||
content: "Этап 1: Архивировать web/ → web-legacy-svelte/, зафиксировать инвентарь 13 роутов и компонентов"
|
||||
status: pending
|
||||
- id: "3"
|
||||
content: "Этап 2: Создать pnpm workspaces монорепо — pnpm-workspace.yaml, корневой package.json, tsconfig.base.json, .npmrc, .nvmrc"
|
||||
status: pending
|
||||
- id: "4"
|
||||
content: "Этап 3: Создать packages/ui (@evobgp/ui) — package.json, components.json (base-nova + @reui), src/styles/globals.css (копия vps-tracker), lib/utils.ts, hooks/use-mobile.ts"
|
||||
status: pending
|
||||
- id: "5"
|
||||
content: "Этап 4: Добавить shadcn-примитивы в packages/ui через MCP+CLI (33 компонента идентично vps-tracker)"
|
||||
status: pending
|
||||
- id: "6"
|
||||
content: "Этап 5: Создать apps/web каркас — package.json (@evobgp/web), components.json, tsconfig, vite.config.ts (TanStackRouterPlugin+react+tailwindcss, alias @ и @evobgp/ui/*, proxy /v1)"
|
||||
status: pending
|
||||
- id: "7"
|
||||
content: "Этап 6: React-инициализация — main.tsx (StrictMode→ThemeProvider→QC→Router+Toaster), lib/queryClient.ts, lib/router.ts (Register augmentation), routes/__root.tsx, theme-provider.tsx, index.html"
|
||||
status: pending
|
||||
- id: "8"
|
||||
content: "Этап 7: Перенос app-shell (sidebar-07) и shared-обёрток (PageShell, PageHeader, EmptyState, QueryState, ConfirmDialog, StatusBadge, DataGridCard, FormSheet, FormField, LoadingButton, skeletons) из vps-tracker с адаптацией брендинга"
|
||||
status: pending
|
||||
- id: "9"
|
||||
content: "Этап 8: Перенос API-клиента и типов из legacy — api-client.ts (Bearer localStorage, Idempotency-Key, RFC 9457 Problem, waitForJob, apiPageAll), types/api.ts, queries/* по доменам"
|
||||
status: pending
|
||||
- id: "10"
|
||||
content: "Этап 9: Добавить ReUI enterprise через MCP+CLI — @reui/data-grid, filters, autocomplete, date-selector, number-field, color-picker, badge"
|
||||
status: pending
|
||||
- id: "11"
|
||||
content: "Этап 10.1: Роуты _auth layout + settings (token+theme) + access (session+api-keys) — простые экраны"
|
||||
status: pending
|
||||
- id: "12"
|
||||
content: "Этап 10.2: Роуты index (dashboard KPI+recent) + modules/index (DataGridCard) + modules/$moduleId (детали с cards)"
|
||||
status: pending
|
||||
- id: "13"
|
||||
content: "Этап 10.3: Роуты network (peers/speakers/BIRD tabs, live refetchInterval) + operations (jobs/revisions/diff, waitForJob) + schedule"
|
||||
status: pending
|
||||
- id: "14"
|
||||
content: "Этап 10.4: Роуты directories (communities/DoH) + monitoring (bird/postgres/runtime) + tenant-settings + редиректы peers→network, revisions→operations"
|
||||
status: pending
|
||||
- id: "15"
|
||||
content: "Этап 11: Обновить deploy/docker/evobgp-web/Dockerfile (pnpm+corepack, COPY apps/web + packages, dist вместо build); nginx.conf НЕ трогать; проверить bake"
|
||||
status: pending
|
||||
- id: "16"
|
||||
content: "Этап 12: Обновить .gitea/workflows/ci.yaml web job (pnpm, tsc --noEmit, eslint, build) + path-filter apps/web/** + packages/ui/**"
|
||||
status: pending
|
||||
- id: "17"
|
||||
content: "Этап 13: Cursor rules — удалить web-shadcn.mdc; скопировать shadcn-mcp/reui-mcp/frontend-* из vps-tracker; обновить engineering.mdc (DEP-04, TEST-04, DOC-SYNC) и context7-stack.mdc; создать .agents/skills/{shadcn,reui}"
|
||||
status: pending
|
||||
- id: "18"
|
||||
content: "Этап 14: Финал — pnpm build без ошибок, MCP get_audit_checklist, cursor-ide-browser smoke 13 роутов, удалить web-legacy-svelte/, коммит feat(frontend)"
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Plan: Миграция EvoBGP web UI на React + shadcn/ui + ReUI
|
||||
|
||||
## Контекст
|
||||
|
||||
**Цель:** Перевести `web/` EvoBGP с SvelteKit 2.50 + Svelte 5.54 + shadcn-svelte на стек идентичный `vps-tracker/apps/web`:
|
||||
- **Стек:** React 19 + Vite 7 + TanStack Router/Query v5 + shadcn/ui (Base UI, `style: base-nova`) + ReUI registry `@reui` + Tailwind v4 monorepo + lucide-react + react-hook-form + Zod + recharts + sonner + next-themes
|
||||
- **Структура:** pnpm workspaces monorepo — `apps/web` (SPA) + `packages/ui` (`@evobgp/ui` barrel)
|
||||
- **Скоуп:** только frontend. Go-бэкенд (`internal/*`), OpenAPI (`docs/openapi.yaml`), compose/bake/nginx — не трогаются (минимальная правка только путей сборки в Dockerfile)
|
||||
- **Стратегия:** big-bang. Старый `web/` архивируется в `web-legacy-svelte/` и удаляется в финале. Рабочий UI создаётся с нуля
|
||||
- **Эталон:** `c:\Users\shats\Dev\vps-tracker\apps\web\` + `c:\Users\shats\Dev\vps-tracker\packages\ui\`
|
||||
|
||||
**Инвентарь существующего EvoBGP web** (из исследования):
|
||||
- 13 роутов SvelteKit: `/`, `/modules`, `/modules/[id]`, `/network` (+редирект `/peers`), `/operations` (+редирект `/revisions`), `/monitoring`, `/schedule`, `/directories`, `/access`, `/tenant-settings`, `/settings`
|
||||
- ~130 доменных `.svelte`-компонентов в `web/src/lib/components/{modules,network,operations,monitoring,tenant-settings,access,...}`
|
||||
- API-клиент `web/src/lib/api/client.ts` + типы `web/src/lib/api/types.ts` (4301 строка OpenAPI, 76 эндпоинтов)
|
||||
- Токен в localStorage (`evobgp_api_token`), Bearer, RFC 9457 errors, cursor-пагинация, Idempotency-Key, polling jobs
|
||||
- Тема `neutral` в `web/src/routes/layout.css` с semantic tokens `--success/--warning/--info`
|
||||
- nginx.conf — SPA fallback `try_files $uri $uri/ /index.html` (нейтрален к фреймворку, не меняется)
|
||||
- Сейчас npm (НЕ pnpm), корневого `pnpm-workspace.yaml` нет
|
||||
|
||||
**Архитектура после миграции**:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph EvoBGP[EvoBGP repo root]
|
||||
PWS[pnpm-workspace.yaml]
|
||||
RP[package.json + pnpm-lock.yaml]
|
||||
TSB[tsconfig.base.json]
|
||||
NVM[.nvmrc Node 22]
|
||||
MCP[.cursor/mcp.json]
|
||||
subgraph Apps
|
||||
AW[apps/web — React SPA]
|
||||
end
|
||||
subgraph Packages
|
||||
UI[packages/ui — @evobgp/ui]
|
||||
end
|
||||
Deploy[deploy/ nginx+compose+bake]
|
||||
GoAPI[internal/httpapi — Go API]
|
||||
end
|
||||
AW -->|imports| UI
|
||||
AW -->|/v1 proxy dev| GoAPI
|
||||
Deploy -->|Dockerfile build| AW
|
||||
Deploy -->|nginx /v1 proxy| GoAPI
|
||||
UI -->|globals.css @source| AW
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Этапы (последовательность, safe-by-design)
|
||||
|
||||
### Этап 0 — MCP environment (предварительный)
|
||||
|
||||
Установить MCP-серверы в workspace EvoBGP. Источник: `c:\Users\shats\.cursor\projects\c-Users-shats-Dev-vps-tracker\mcps\` — там работают:
|
||||
- `plugin-shadcn-shadcn` (serverName `shadcn`) — даёт инструменты `get_project_registries`, `search_items_in_registries` (с поддержкой `registries: ["@reui"]`), `get_item_examples_from_registries`, `get_add_command_for_items`, `view_items_in_registries`, `list_items_in_registries`, `get_audit_checklist`
|
||||
- `plugin-context7-plugin-context7` — docs-lookup React/TanStack/Recharts
|
||||
- `cursor-ide-browser` — визуальная проверка UI
|
||||
|
||||
Скопировать `.cursor/mcp.json` из vps-tracker → `c:\Users\shats\Dev\EvoBGP\.cursor\mcp.json`. Enable серверы в Cursor Settings. ReUI MCP и codegraph — **не существуют как отдельные MCP** (исследовано); ReUI доступен **через** `plugin-shadcn-shadcn` с `registries: ["@reui"]`.
|
||||
|
||||
### Этап 1 — Подготовка
|
||||
|
||||
1. **Зафиксировать инвентарь**: список 13 роутов + ~130 компонентов + API-клиент + типы. Не удалять пока ничего.
|
||||
2. **Архивировать старый стек**: `web/` → `web-legacy-svelte/`. Оставить до финала как референс при переносе экранов.
|
||||
|
||||
### Этап 2 — Монорепо-каркас
|
||||
|
||||
Корневые файлы:
|
||||
|
||||
- `pnpm-workspace.yaml`: `packages: ['apps/*', 'packages/*']`
|
||||
- Корневой `package.json`: `{"private": true, "scripts": {"dev": "pnpm --filter @evobgp/web dev", "build": "pnpm --filter @evobgp/web build", "lint": "pnpm -r lint"}}`. Существующий корневой `package.json` (semantic-release/commitlint) — слить в один или оставить как `package.release.json` (решить по ходу).
|
||||
- `tsconfig.base.json`: `strict: true`, `target: ES2022`, `moduleResolution: bundler`, `jsx: react-jsx`, `paths: {"@/*": ["./apps/web/src/*"], "@evobgp/ui/components/*": ["./packages/ui/src/components/*"], "@evobgp/ui/hooks/*": ["./packages/ui/src/hooks/*"], "@evobgp/ui/lib/utils": ["./packages/ui/src/lib/utils.ts"]}`
|
||||
- `.npmrc`: `engine-strict=true`
|
||||
- `.nvmrc`: `22` (приводим к единой версии с Dockerfile)
|
||||
- `.gitignore`: добавить `node_modules/`, `dist/`, `routeTree.gen.ts` (опц.)
|
||||
|
||||
### Этап 3 — `packages/ui` (@evobgp/ui)
|
||||
|
||||
Структура полностью повторяет `c:\Users\shats\Dev\vps-tracker\packages\ui\`:
|
||||
|
||||
- `packages/ui/package.json`:
|
||||
- `name: "@evobgp/ui"`
|
||||
- `exports`: `./components/*`, `./hooks/*`, `./lib/utils`, `./globals.css`
|
||||
- `dependencies`: `@base-ui/react`, `class-variance-authority`, `clsx`, `cmdk`, `date-fns`, `lucide-react`, `next-themes`, `react-day-picker`, `recharts`, `sonner`, `tailwind-merge`
|
||||
- `peerDependencies`: `react`, `react-dom` (19)
|
||||
- `packages/ui/components.json` (точная копия vps-tracker с заменой `@cfdm` → `@evobgp`):
|
||||
```json
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {"config": "", "css": "src/styles/globals.css", "baseColor": "neutral", "cssVariables": true},
|
||||
"iconLibrary": "lucide",
|
||||
"registries": {"@reui": "https://reui.io/r/{style}/{name}.json"},
|
||||
"aliases": {"components": "@evobgp/ui/components", "utils": "@evobgp/ui/lib/utils", "hooks": "@evobgp/ui/hooks", "lib": "@evobgp/ui/lib", "ui": "@evobgp/ui/components"}
|
||||
}
|
||||
```
|
||||
- `packages/ui/src/styles/globals.css`: **точная копия** `c:\Users\shats\Dev\vps-tracker\packages\ui\src\styles\globals.css` (Tailwind v4 + `tw-animate-css` + `@source "../"` + `@source "../../../apps/web/src"` + `:root`/`.dark` со всеми oklch-токенами + ReUI semantic tokens `--success/--info/--warning/--destructive-foreground/--invert/--focus` + chart-1..5 + sidebar-* + `@theme inline` + `@layer base`)
|
||||
- `packages/ui/src/lib/utils.ts`: `cn()` через `clsx` + `tailwind-merge`
|
||||
- `packages/ui/src/hooks/use-mobile.ts`
|
||||
|
||||
### Этап 4 — shadcn-примитивы через MCP
|
||||
|
||||
Перед каждым `add` — обязательно через MCP:
|
||||
1. `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`
|
||||
2. CLI `pnpm dlx shadcn@latest docs <component>` сверка с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components)
|
||||
|
||||
Компоненты (идентично набору `vps-tracker/packages/ui/src/components/`):
|
||||
```
|
||||
button button-group card input textarea label select checkbox field
|
||||
separator scroll-area table badge tabs dialog alert-dialog sheet popover
|
||||
dropdown-menu tooltip breadcrumb sidebar skeleton sonner spinner alert
|
||||
command kbd calendar slider chart input-group input-otp toggle
|
||||
```
|
||||
Команда: `cd apps/web && pnpm dlx shadcn@latest add button card input ...` (CLI пишет в `packages/ui/src/components/` через aliases).
|
||||
|
||||
Применение темы: `pnpm dlx shadcn@latest apply b2fA --only theme -y` (обновит `:root`/`.dark` в `globals.css`).
|
||||
|
||||
### Этап 5 — `apps/web` каркас
|
||||
|
||||
- `apps/web/package.json` (`@evobgp/web`): deps из `vps-tracker/apps/web/package.json` с заменой `@cfdm/*` → `@evobgp/*`:
|
||||
- `react`, `react-dom` 19
|
||||
- `@tanstack/react-router`, `@tanstack/react-router-devtools`, `@tanstack/react-query`, `@tanstack/react-query-devtools`, `@tanstack/react-table`, `@tanstack/react-virtual`
|
||||
- `@hookform/resolvers`, `react-hook-form`, `zod` (v3 для совместимости с RHF-resolvers — как в vps-tracker)
|
||||
- `class-variance-authority`, `cmdk`, `date-fns`, `lucide-react`, `next-themes`, `react-day-picker`, `recharts`, `sonner`
|
||||
- `@dnd-kit/core`, `@dnd-kit/modifiers`, `@dnd-kit/sortable`, `@dnd-kit/utilities`
|
||||
- devDeps: `@tailwindcss/vite`, `@tanstack/router-plugin`, `@types/react`, `@types/react-dom`, `@vitejs/plugin-react`, `happy-dom`, `tailwindcss`, `tw-animate-css`, `typescript`, `vite`, `vitest`
|
||||
- `apps/web/components.json` (алиасы web-side: `@/components`, `@/hooks`, `@/lib`, `utils: @evobgp/ui/lib/utils`, `ui: @evobgp/ui/components`; registries `@reui`; css `../../packages/ui/src/styles/globals.css`)
|
||||
- `apps/web/vite.config.ts` (точная копия vps-tracker с заменой `@cfdm` → `@evobgp`): плагины `TanStackRouterPlugin({ target: 'react', autoCodeSplitting: true })` → `react()` → `tailwindcss()`; alias `@`, `@evobgp/ui/*`; server port 5173, proxy `/v1` и `/metrics` → `http://127.0.0.1:8080`
|
||||
- `apps/web/tsconfig.json` extends `../../tsconfig.base.json`
|
||||
- `apps/web/index.html` (#root, anti-FOUC тема-скрипт `evobgp-theme`)
|
||||
|
||||
### Этап 6 — React-инициализация
|
||||
|
||||
Точная копия vps-tracker с заменой путей:
|
||||
|
||||
- `apps/web/src/main.tsx`: `StrictMode → ThemeProvider → QueryClientProvider → RouterProvider + <Toaster richColors position="top-right" />`, единственный `import '@evobgp/ui/globals.css'`
|
||||
- `apps/web/src/lib/queryClient.ts`: `staleTime: 60_000, retry: 1, refetchOnWindowFocus: false`
|
||||
- `apps/web/src/lib/router.ts`: `createRouter({ routeTree, context, defaultPreload: 'intent', scrollRestoration: true })` + `declare module '@tanstack/react-router'` Register
|
||||
- `apps/web/src/components/theme-provider.tsx`: next-themes `attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange`
|
||||
- `apps/web/src/routes/__root.tsx`: `createRootRouteWithContext<{ queryClient }>` + `<AppShell><Outlet /></AppShell>`
|
||||
|
||||
### Этап 7 — Перенос app-shell и shared-обёрток
|
||||
|
||||
Перенести из `vps-tracker/apps/web/src/components/` с адаптацией брендинга/навигации:
|
||||
- `layout/app-shell.tsx` (block sidebar-07, `SidebarProvider → Sidebar collapsible="icon" → SidebarHeader/SidebarContent(navGroups)/SidebarFooter + SidebarInset(header sticky h-16 backdrop-blur + SidebarTrigger + Breadcrumb + actions + main)`). `render={<Link to={item.to} />}` — **Base UI render-prop, не Radix asChild**. Навигация подставляется под EvoBGP-экраны.
|
||||
- `mode-toggle.tsx`, `page-shell.tsx`, `page-header.tsx`, `empty-state.tsx`, `query-state.tsx`, `confirm-dialog.tsx`, `status-badge.tsx`, `section-cards.tsx`, `form-sheet.tsx`, `form-field.tsx`, `loading-button.tsx`, `skeletons.tsx`
|
||||
- `data-grid-card.tsx` (обёртка над `@reui/data-grid`) — полная копия с типизированным `DataGridCardProps<TData>`
|
||||
|
||||
### Этап 8 — API-клиент и типы (перенос из legacy)
|
||||
|
||||
- `apps/web/src/lib/api-client.ts`: перенос логики из `web-legacy-svelte/src/lib/api/client.ts`:
|
||||
- `TOKEN_STORAGE_KEY = 'evobgp_api_token'`
|
||||
- `mergeHeaders`: Accept JSON + Bearer из localStorage
|
||||
- `apiFetch`, `apiJSON<T>`, `apiMutate<T>` с auto-Idempotency-Key
|
||||
- `parseResponse<T>`: 204/205→undefined, ошибки → `ApiError` с RFC 9457 Problem
|
||||
- `waitForJob(jobId, opts?)`: poll `GET /v1/jobs/{id}` каждые 400ms
|
||||
- `apiPageAll<T>`: cursor-пагинация (`items`/`next_cursor`/`has_more`)
|
||||
- `apps/web/src/types/api.ts`: перенос всех типов из `web-legacy-svelte/src/lib/api/types.ts` (`ModuleRow`, `BgpPeer`, `SpeakerRow`, `RevisionRow`, `JobRow`, `AuthSession`, `ApiKey`, `PostgresOverview`, etc.)
|
||||
- `apps/web/src/queries/` — по доменам: `auth.ts`, `modules.ts`, `network.ts`, `operations.ts`, `monitoring.ts`, `directories.ts`, `access.ts`, `settings.ts` (queryOptions + key factories)
|
||||
|
||||
### Этап 9 — ReUI enterprise-компоненты
|
||||
|
||||
Через MCP `search_items_in_registries` с `registries: ["@reui"]` → `get_item_examples` → `get_add_command`:
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm dlx shadcn@latest add @reui/data-grid
|
||||
pnpm dlx shadcn@latest add @reui/filters
|
||||
pnpm dlx shadcn@latest add @reui/autocomplete
|
||||
pnpm dlx shadcn@latest add @reui/date-selector
|
||||
pnpm dlx shadcn@latest add @reui/number-field
|
||||
pnpm dlx shadcn@latest add @reui/color-picker
|
||||
pnpm dlx shadcn@latest add @reui/badge
|
||||
```
|
||||
Документация для `@reui/*`: [reui.io/docs/components/base/](https://reui.io/docs/components/base) + [llms.txt](https://reui.io/llms.txt) — **не** ui.shadcn.com для ReUI.
|
||||
|
||||
Ложатся в `apps/web/src/components/reui/` (НЕ в packages/ui). Post-add: импорты shadcn-примитивов внутри ReUI → `@evobgp/ui/components/*`. Проверить что `@tanstack/react-table`, `@tanstack/react-virtual`, `@dnd-kit/*`, `date-fns`, `react-day-picker` попали в `apps/web/package.json`.
|
||||
|
||||
### Этап 10 — Реализация 13 роутов (поэтапно)
|
||||
|
||||
Каждый роут — отдельная подзадача. Порядок от простого к сложному (позволяет рано верифицировать стек):
|
||||
|
||||
| # | Файл (TanStack file-based) | Что делает | Источник (legacy) |
|
||||
|---|---|---|---|
|
||||
| 1 | `routes/_auth.tsx` + `_auth.tsx` layout | auth guard (токен в localStorage) | `routes/+layout.ts` |
|
||||
| 2 | `routes/_auth/settings.tsx` | API-токен + тема (свет/тёмн/сист) | `routes/settings/+page.svelte` |
|
||||
| 3 | `routes/_auth/access.tsx` | `GET /v1/auth/session` + список API-ключей | `routes/access/+page.svelte` |
|
||||
| 4 | `routes/index.tsx` | dashboard: KPI + recent jobs/revisions + network status | `routes/+page.svelte` |
|
||||
| 5 | `routes/_auth/modules/index.tsx` | список модулей (DataGridCard) | `routes/modules/+page.svelte` |
|
||||
| 6 | `routes/_auth/modules/$moduleId.tsx` | детали модуля (cards: AS/Cdn/Domain/IpRange) | `routes/modules/[moduleId]/+page.svelte` |
|
||||
| 7 | `routes/_auth/network.tsx` | peers + speakers + BIRD settings (tabs) | `routes/network/+page.svelte` |
|
||||
| 8 | `routes/_auth/operations.tsx` | jobs + revisions + diff (tabs, waitForJob) | `routes/operations/+page.svelte` |
|
||||
| 9 | `routes/_auth/schedule.tsx` | refresh jobs (tabs all/refresh/failed) | `routes/schedule/+page.svelte` |
|
||||
| 10 | `routes/_auth/directories.tsx` | BGP communities + DoH profiles (tabs) | `routes/directories/+page.svelte` |
|
||||
| 11 | `routes/_auth/monitoring.tsx` | bird status + version + postgres + runtime logs | `routes/monitoring/+page.svelte` |
|
||||
| 12 | `routes/_auth/tenant-settings.tsx` | настройки BIRD/ревизий/runtime (tabs) | `routes/tenant-settings/+page.svelte` |
|
||||
| 13 | редиректы | `/peers`→`/network`, `/revisions`→`/operations?tab=revisions` | `routes/peers`, `routes/revisions` |
|
||||
|
||||
Для каждого роута: MCP-shadcn search+examples → `pnpm dlx shadcn@latest docs <name>` сверка с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components) → ReUI для data-grid/filters → композиция @evobgp/ui + DataGridCard; логика переносится из соответствующего `+page.svelte` (но на TanStack Query вместо инлайн fetch). Live-данные (network, monitoring) — `refetchInterval` вместо кастомного `setInterval`.
|
||||
|
||||
### Этап 11 — Инфраструктура деплоя
|
||||
|
||||
**Минимальные правки (compose/bake не трогать):**
|
||||
|
||||
`deploy/docker/evobgp-web/Dockerfile` (3 стадии → 3 стадии):
|
||||
- `deps`: установить pnpm (corepack), `COPY pnpm-workspace.yaml package.json apps/web/package.json packages/ui/package.json ./` + `COPY apps/web/ apps/web/` + `COPY packages/ packages/` → `pnpm install --frozen-lockfile`
|
||||
- `build`: `COPY . .` → `pnpm --filter @evobgp/web build`
|
||||
- `web`: `COPY --from=web-artifacts /app/apps/web/dist /usr/share/nginx/html` (вместо `/web/build`)
|
||||
|
||||
`deploy/docker/evobgp-web/nginx.conf` — **не меняется** (SPA fallback совместим).
|
||||
|
||||
`deploy/docker/docker-bake.hcl` — проверить что target `web-build` корректно прокидывает контекст.
|
||||
|
||||
### Этап 12 — CI/CD
|
||||
|
||||
`.gitea/workflows/ci.yaml`, job `web` (строки 164-181):
|
||||
- `actions/setup-node@v4` → `node-version: "22"`, `cache: pnpm`, `cache-dependency-path: pnpm-lock.yaml`
|
||||
- Добавить `pnpm install` (через corepack)
|
||||
- Заменить `npm run check` → `pnpm --filter @evobgp/web exec tsc --noEmit`
|
||||
- Заменить `npm run lint` → `pnpm --filter @evobgp/web lint` (eslint)
|
||||
- Добавить `pnpm --filter @evobgp/web build` (раньше проверялось только в Docker)
|
||||
|
||||
Job `changes` — path-filter `web/*` → `apps/web/**` + `packages/ui/**` (расширить globs).
|
||||
|
||||
### Этап 13 — Cursor rules + skills
|
||||
|
||||
**Удалить** (Svelte-специфика):
|
||||
- `.cursor/rules/web-shadcn.mdc` (WEB-01..WEB-19 — всё про shadcn-svelte)
|
||||
|
||||
**Скопировать из vps-tracker `.cursor/rules/`** (с заменой `@cfdm` → `@evobgp`, `Vps*` → `EvoBgp*`):
|
||||
- `shadcn-mcp.mdc`, `reui-mcp.mdc`, `shadcn-ui-production.mdc`, `frontend-shadcn.mdc`, `frontend-monorepo.mdc`, `frontend-ui-patterns.mdc`, `vite-tanstack-frontend.mdc`
|
||||
|
||||
**Обновить** `engineering.mdc`:
|
||||
- `DEP-04`: «shadcn-svelte/bits-ui» → «shadcn/ui React (Base UI) + ReUI registry»
|
||||
- `TEST-04`: `npm run check` → `pnpm --filter @evobgp/web exec tsc --noEmit`; `npm run lint` → eslint
|
||||
- `DOC-SYNC-06/07`: `shadcn-svelte.com` → `ui.shadcn.com/docs` + `reui.io/docs`
|
||||
|
||||
**Обновить** `context7-stack.mdc` Web UI таблица: убрать svelte/sveltekit/bits-ui/formsnap/`@lucide/svelte`; добавить React 19, TanStack Router/Query/Table/Virtual, ReUI llms.txt, `lucide-react`.
|
||||
|
||||
**Создать** `c:\Users\shats\Dev\EvoBGP\.agents\skills\` (сейчас не существует):
|
||||
- `shadcn/SKILL.md` — скопировать из cloudflare-domain-manager
|
||||
- `reui/SKILL.md` — скопировать из cloudflare-domain-manager, заменить обёртки под EvoBGP (`DataGridCard`, `NetworkFiltersToolbar`)
|
||||
|
||||
### Этап 14 — Финальная проверка и очистка
|
||||
|
||||
1. `pnpm install && pnpm --filter @evobgp/web build` без ошибок
|
||||
2. MCP `get_audit_checklist` — пройти по чек-листу
|
||||
3. `cursor-ide-browser` smoke-тест всех 13 роутов (login через dev-токен, навигация, CRUD, dark/light toggle)
|
||||
4. Удалить `web-legacy-svelte/`
|
||||
5. Проверить что compose поднимается (`docker compose --profile reference up`)
|
||||
6. Коммит на main (gitflow по `.cursor/rules/conventional-commits.mdc`): `feat(frontend): миграция web UI на React + shadcn/ui + ReUI`
|
||||
|
||||
---
|
||||
|
||||
## Цитаты ключевых файлов эталона
|
||||
|
||||
- Конфиги: [apps/web/components.json](c:\Users\shats\Dev\vps-tracker\apps\web\components.json), [packages/ui/components.json](c:\Users\shats\Dev\vps-tracker\packages\ui\components.json)
|
||||
- Стили: [packages/ui/src/styles/globals.css](c:\Users\shats\Dev\vps-tracker\packages\ui\src\styles\globals.css)
|
||||
- Инициализация: [apps/web/src/main.tsx](c:\Users\shats\Dev\vps-tracker\apps\web\src\main.tsx), [lib/queryClient.ts](c:\Users\shats\Dev\vps-tracker\apps\web\src\lib\queryClient.ts), [lib/router.ts](c:\Users\shats\Dev\vps-tracker\apps\web\src\lib\router.ts)
|
||||
- Layout: [components/layout/app-shell.tsx](c:\Users\shats\Dev\vps-tracker\apps\web\src\components\layout\app-shell.tsx)
|
||||
- Vite: [apps/web/vite.config.ts](c:\Users\shats\Dev\vps-tracker\apps\web\vite.config.ts)
|
||||
- ReUI обёртка: [components/data-grid-card.tsx](c:\Users\shats\Dev\vps-tracker\apps\web\src\components\data-grid-card.tsx)
|
||||
- API-клиент legacy: [web/src/lib/api/client.ts](c:\Users\shats\Dev\EvoBGP\web\src\lib\api\client.ts), [web/src/lib/api/types.ts](c:\Users\shats\Dev\EvoBGP\web\src\lib\api\types.ts)
|
||||
|
||||
## Документация
|
||||
|
||||
- [shadcn/ui Installation](https://ui.shadcn.com/docs/installation)
|
||||
- [shadcn/ui Components](https://ui.shadcn.com/docs/components)
|
||||
- [shadcn/ui Monorepo](https://ui.shadcn.com/docs/monorepo)
|
||||
- [shadcn/ui MCP Server](https://ui.shadcn.com/docs/mcp)
|
||||
- [ReUI Get Started](https://reui.io/docs/get-started)
|
||||
- [ReUI Styling](https://reui.io/docs/styling)
|
||||
- [ReUI MCP](https://reui.io/docs/mcp)
|
||||
- [ReUI llms.txt](https://reui.io/llms.txt)
|
||||
|
||||
## Риски и митигация
|
||||
|
||||
| Риск | Митигация |
|
||||
|---|---|
|
||||
| Big-bang = длинное окно неработающего UI в dev | Этапы 3-9 делаются параллельно с рабочим `web-legacy-svelte/`; переключение атомарно в финале |
|
||||
| ReUI на React 19 / Base UI может иметь breaking changes | MCP `get_item_examples` + dry-run `--dry-run` перед add |
|
||||
| OpenAPI drift (типы в legacy vs контракт) | Типы переносятся как есть (frontend-only scope); Zod-схемы по желанию позже |
|
||||
| npm → pnpm меняет lock-файлы и CI | Пункт 12 явно покрывает CI; corepack в Dockerfile |
|
||||
| 13 роутов = большой объём работы | Этап 10 разбит по сложности; можно остановиться после базовых и продолжить инкрементально |
|
||||
@@ -32,23 +32,28 @@ alwaysApply: true
|
||||
|
||||
---
|
||||
|
||||
## Web UI (`web/`)
|
||||
## Web UI (`apps/web/` + `packages/ui/`)
|
||||
|
||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||
|------------|-------------|------------------|-------|
|
||||
| Svelte | `/websites/svelte_dev` | ^5.54 | runes, компоненты, реактивность |
|
||||
| SvelteKit | `/sveltejs/kit` | ^2.50 | routing, `load`, adapters, SSR |
|
||||
| React | `/facebook/react` | ^19.2 | hooks, components, JSX |
|
||||
| TanStack Router | `/tanstack/router` | ^1.130 | file-based routes, `createFileRoute`, `useSearch`, `Link` |
|
||||
| TanStack Query | `/tanstack/query` | ^5.90 | `useQuery`, `useMutation`, `queryOptions`, invalidation |
|
||||
| TanStack Table | `/websites/tanstack_table` | ^8.21 | data-grid колонки, сортировка (ReUI) |
|
||||
| TanStack Virtual | `/tanstack/virtual` | ^3.14 | виртуализация списков (ReUI data-grid) |
|
||||
| Vite | `/vitejs/vite/v7.3.1` | ^7.3.1 | dev server, build, plugins |
|
||||
| TypeScript | `/microsoft/typescript/v5.9.3` | ^5.9.3 | типы, strict, tsconfig |
|
||||
| Tailwind CSS | `/tailwindlabs/tailwindcss.com` | ^4.1 | v4, `@tailwindcss/vite`, утилиты |
|
||||
| shadcn-svelte | `/websites/shadcn-svelte` | CLI | примитивы `ui/core`, theming |
|
||||
| Bits UI | `/llmstxt/bits-ui_llms_txt` | ^2.17 | headless-примитивы под shadcn |
|
||||
| sveltekit-superforms | `/ciscoheat/sveltekit-superforms` | ^2.30 | формы, server actions |
|
||||
| Formsnap | `/svecosystem/formsnap` | ^2.0 | доступные поля форм |
|
||||
| Zod | `/websites/zod_dev_v4` | ^4.4 | схемы валидации |
|
||||
| TanStack Table | `/websites/tanstack_table` | table-core ^8.21 | `AppDataTable`, колонки, сортировка |
|
||||
| shadcn/ui (React) | MCP `plugin-shadcn-shadcn` + https://ui.shadcn.com/docs | base-nova | примитивы `@evobgp/ui/components/*` |
|
||||
| ReUI | https://reui.io/llms.txt + MCP с `registries: ["@reui"]` | registry | enterprise: data-grid, filters, autocomplete |
|
||||
| react-hook-form | `/react-hook-form` | ^7.60 | формы, controller |
|
||||
| Zod | `/websites/zod_dev_v4` | ^3.25 / ^4 (apps/web) | схемы валидации |
|
||||
| recharts | `/recharts/recharts` | 3.8.0 | графики через shadcn `Chart` |
|
||||
| next-themes | `/pacocoursey/next-themes` | ^0.4 | dark/light theme provider |
|
||||
| sonner | `/emilkowalski/sonner` | ^1.7 | toast notifications |
|
||||
| lucide-react | `/lucide-icons/lucide` | ^0.468 | иконки |
|
||||
|
||||
UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (shadcn-svelte docs — первичный источник для компонентов).
|
||||
UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (MCP + shadcn/ui React docs — первичный источник для компонентов).
|
||||
|
||||
---
|
||||
|
||||
@@ -82,7 +87,9 @@ UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (shadcn
|
||||
## Примеры запросов
|
||||
|
||||
```
|
||||
/docs /websites/svelte_dev runes $state $derived
|
||||
/docs /facebook/react hooks useState useEffect
|
||||
/docs /tanstack/router createFileRoute useSearch Link
|
||||
/docs /tanstack/query useQuery useMutation queryOptions
|
||||
/docs /golang/go/go1_24_6 net/http ServeMux pattern matching
|
||||
/docs /websites/pkg_go_dev_github_com_jackc_pgx_v5 pool acquire rows
|
||||
/docs /llmstxt/bird_xmsl_dev_llms_txt filter bgp import
|
||||
|
||||
@@ -94,8 +94,8 @@ alwaysApply: true
|
||||
**DEP-03** | MUST | Миграции схемы — пары `.up.sql`/`.down.sql` для **postgres** и **sqlite**, синхронная нумерация.
|
||||
*Проверка:* `migrations/postgres/`, `migrations/sqlite/`.
|
||||
|
||||
**DEP-04** | MUST | Web UI-библиотеки — только экосистема shadcn-svelte/bits-ui (см. `web-shadcn.mdc`).
|
||||
*Проверка:* `web/package.json` review.
|
||||
**DEP-04** | MUST | Web UI-библиотеки — только экосистема shadcn/ui (React) + ReUI (см. `web-shadcn.mdc`).
|
||||
*Проверка:* `apps/web/package.json`, `packages/ui/package.json` review.
|
||||
|
||||
---
|
||||
|
||||
@@ -123,7 +123,7 @@ alwaysApply: true
|
||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||
*Проверка:* CI job `bird2`.
|
||||
|
||||
**TEST-04** | MUST | Изменения `web/` — локально **`npm run check` и `npm run lint`** (обе команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`. Агент: при fail lint — `npx prettier --write` затем повтор. Только `check` не заменяет `lint`.
|
||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`.
|
||||
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
||||
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||
@@ -213,7 +213,7 @@ alwaysApply: true
|
||||
**DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией.
|
||||
**DOC-SYNC-02** | NEVER | Устаревшие примеры (Svelte 4 `export let`, deprecated pgx).
|
||||
**DOC-SYNC-03** | MUST | Конфликт docs: **OpenAPI (HTTP)** → **код** → обзорные `docs/`; `.cursor/plans/` не контракт.
|
||||
**DOC-SYNC-04** | MUST | Сомнения по Svelte — Svelte MCP / `npm run check`.
|
||||
**DOC-SYNC-04** | MUST | Сомнения по React/shadcn/ReUI — MCP `plugin-shadcn-shadcn` + `pnpm --filter @evobgp/web run typecheck`.
|
||||
**DOC-SYNC-05** | MUST | BIRD — официальная документация BIRD2 + `networking-bird.mdc` + `go test ./internal/birdfmt/...`.
|
||||
|
||||
Приоритет при сомнениях — **официальные источники**, не блоги и не «память модели».
|
||||
@@ -230,7 +230,7 @@ alwaysApply: true
|
||||
go vet ./...
|
||||
go test ./... -race -count=1
|
||||
npx @redocly/cli lint docs/openapi.yaml
|
||||
# web: cd web; npm run check; npm run lint (или scripts/lint-web.ps1)
|
||||
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
||||
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||
```
|
||||
|
||||
@@ -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
@@ -1,10 +1,19 @@
|
||||
{
|
||||
"plugins": {
|
||||
"svelte": {
|
||||
"shadcn": {
|
||||
"enabled": true
|
||||
},
|
||||
"context7-plugin": {
|
||||
"enabled": true
|
||||
},
|
||||
"claude-plugins-official/gopls-lsp": {
|
||||
"enabled": true
|
||||
},
|
||||
"claude-plugins-official/typescript-lsp": {
|
||||
"enabled": true
|
||||
},
|
||||
"svelte": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ description: Context7 lookup для стека EvoBGP — использоват
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Определи область: `internal/` (Go), `web/` (Svelte), `docs/openapi.yaml`, `birdfmt`/`pipeline` (BIRD), `deploy/compose` (Docker).
|
||||
1. Определи область: `internal/` (Go), `apps/web/` + `packages/ui/` (React + shadcn/ui + ReUI), `docs/openapi.yaml`, `birdfmt`/`pipeline` (BIRD), `deploy/compose` (Docker).
|
||||
2. Найди строку в таблице `context7-stack.mdc`.
|
||||
3. Вызови `query-docs` с `libraryId` из таблицы и полным вопросом пользователя.
|
||||
4. `resolve-library-id` — только если библиотеки нет в таблице или нужна другая major-версия.
|
||||
@@ -18,14 +18,17 @@ description: Context7 lookup для стека EvoBGP — использоват
|
||||
|
||||
| Задача | libraryId |
|
||||
|--------|-----------|
|
||||
| Svelte 5 runes | `/websites/svelte_dev` |
|
||||
| SvelteKit load/forms | `/sveltejs/kit` |
|
||||
| shadcn-svelte компонент | `/websites/shadcn-svelte` |
|
||||
| React 19 hooks | `/facebook/react` |
|
||||
| TanStack Router | `/tanstack/router` |
|
||||
| TanStack Query | `/tanstack/query` |
|
||||
| shadcn/ui (React) | MCP `plugin-shadcn-shadcn` + https://ui.shadcn.com/docs |
|
||||
| ReUI Base UI | https://reui.io/llms.txt + MCP с `registries: ["@reui"]` |
|
||||
| pgx pool/query | `/websites/pkg_go_dev_github_com_jackc_pgx_v5` |
|
||||
| Go net/http | `/golang/go/go1_24_6` |
|
||||
| OpenAPI lint | `/redocly/redocly-cli` |
|
||||
| BIRD config | `/llmstxt/bird_xmsl_dev_llms_txt` |
|
||||
| Tailwind v4 | `/tailwindlabs/tailwindcss.com` |
|
||||
| Zod 4 schema | `/websites/zod_dev_v4` |
|
||||
| Zod schema | `/websites/zod_dev_v4` |
|
||||
| recharts | `/recharts/recharts` |
|
||||
|
||||
Полный список и версии — в `context7-stack.mdc`.
|
||||
|
||||
+11
-11
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 стека.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "../../packages/ui/src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"registries": {
|
||||
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"hooks": "@/hooks",
|
||||
"lib": "@/lib",
|
||||
"utils": "@evobgp/ui/lib/utils",
|
||||
"ui": "@evobgp/ui/components"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import globals from 'globals'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'prefer-const': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/components/reui/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="ru" class="">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>EvoBGP</title>
|
||||
<script>
|
||||
// Anti-FOUC: apply persisted theme before paint (matches next-themes attribute="class")
|
||||
try {
|
||||
var t = localStorage.getItem('evobgp-theme');
|
||||
var m = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var dark = t === 'dark' || (!t || t === 'system') && m;
|
||||
if (dark) document.documentElement.classList.add('dark');
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@evobgp/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsr generate && tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsr generate && tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@evobgp/ui": "workspace:*",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-router": "^1.130.2",
|
||||
"@tanstack/react-router-devtools": "^1.130.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"recharts": "3.8.0",
|
||||
"sonner": "^1.7.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@tanstack/router-plugin": "^1.130.0",
|
||||
"@tanstack/router-cli": "^1.130.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.0.0",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.0",
|
||||
"globals": "^15.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"happy-dom": "^18.0.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"tw-animate-css": "^1.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#0f172a"/>
|
||||
<text x="16" y="22" font-family="ui-sans-serif,system-ui,sans-serif" font-size="18" font-weight="700" fill="#f8fafc" text-anchor="middle">B</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 272 B |
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@evobgp/ui/components/alert-dialog'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
trigger: ReactElement
|
||||
title: string
|
||||
description?: ReactNode
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
destructive?: boolean
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Подтвердить',
|
||||
cancelLabel = 'Отмена',
|
||||
destructive,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={trigger} />
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
Network,
|
||||
Cog,
|
||||
ListChecks,
|
||||
Activity,
|
||||
Settings,
|
||||
BookText,
|
||||
KeyRound,
|
||||
ServerCog,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@evobgp/ui/components/breadcrumb'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Обзор',
|
||||
items: [{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }],
|
||||
},
|
||||
{
|
||||
label: 'Маршрутизация',
|
||||
items: [
|
||||
{ to: '/modules', label: 'Модули', icon: Boxes },
|
||||
{ to: '/network', label: 'Сеть', icon: Network },
|
||||
{ to: '/directories', label: 'Справочники', icon: BookText },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Операции',
|
||||
items: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Система',
|
||||
items: [
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound },
|
||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog },
|
||||
{ to: '/settings', label: 'Настройки UI', icon: Settings },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
||||
|
||||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||
)
|
||||
|
||||
const PARENT_ROUTE: Record<string, string> = {}
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const activeItem =
|
||||
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
||||
ALL_NAV_ITEMS[0]
|
||||
const parentTo = PARENT_ROUTE[activeItem.to]
|
||||
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
|
||||
B
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-semibold">EvoBGP</span>
|
||||
<span className="truncate text-xs text-muted-foreground">Control Plane</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{parentLabel && parentTo ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
</>
|
||||
) : null}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? activeItem.label}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
loading?: boolean
|
||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
|
||||
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
|
||||
return (
|
||||
<Button disabled={disabled || loading} {...props}>
|
||||
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
|
||||
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Сменить тему</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>Светлая</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>Тёмная</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>Системная</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertCircle, RefreshCwIcon } from 'lucide-react'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { EmptyState } from './empty-state'
|
||||
|
||||
interface QueryStateProps<T> {
|
||||
data: T | undefined
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error?: unknown
|
||||
empty?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
onRetry?: () => void
|
||||
skeleton?: ReactNode
|
||||
children: (data: T) => ReactNode
|
||||
}
|
||||
|
||||
export function QueryState<T>({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
empty,
|
||||
emptyTitle = 'Нет данных',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
onRetry,
|
||||
skeleton,
|
||||
children,
|
||||
}: QueryStateProps<T>) {
|
||||
if (isLoading) {
|
||||
return <>{skeleton ?? <DefaultSkeleton />}</>
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<AlertCircle className="size-8" />}
|
||||
title="Ошибка загрузки"
|
||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Повторить
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (empty || data == null) {
|
||||
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
}
|
||||
return <>{children(data)}</>
|
||||
}
|
||||
|
||||
function DefaultSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client"
|
||||
|
||||
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
|
||||
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
|
||||
|
||||
const inputVariants = cva(
|
||||
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
|
||||
default:
|
||||
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
|
||||
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Autocomplete = AutocompletePrimitive.Root
|
||||
|
||||
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteInput({
|
||||
className,
|
||||
size = "default",
|
||||
showClear = false,
|
||||
showTrigger = false,
|
||||
...props
|
||||
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
|
||||
VariantProps<typeof inputVariants> & {
|
||||
showClear?: boolean
|
||||
showTrigger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<AutocompletePrimitive.Input
|
||||
data-slot="autocomplete-input"
|
||||
data-size={size}
|
||||
className={cn(inputVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
{showTrigger && <AutocompleteTrigger />}
|
||||
{showClear && <AutocompleteClear />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteStatus({
|
||||
className,
|
||||
...props
|
||||
}: AutocompletePrimitive.Status.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Status
|
||||
data-slot="autocomplete-status"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteBackdrop({
|
||||
...props
|
||||
}: AutocompletePrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Backdrop
|
||||
data-slot="autocomplete-backdrop"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompletePositioner({
|
||||
className,
|
||||
...props
|
||||
}: AutocompletePrimitive.Positioner.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Positioner
|
||||
data-slot="autocomplete-positioner"
|
||||
className={cn("z-50 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteList({
|
||||
className,
|
||||
scrollAreaClassName,
|
||||
...props
|
||||
}: AutocompletePrimitive.List.Props & {
|
||||
scrollAreaClassName?: string
|
||||
scrollFade?: boolean
|
||||
scrollbarGutter?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
|
||||
scrollAreaClassName
|
||||
)}
|
||||
>
|
||||
<AutocompletePrimitive.List
|
||||
data-slot="autocomplete-list"
|
||||
className={cn(
|
||||
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteCollection({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Collection
|
||||
data-slot="autocomplete-collection"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteRow({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Row
|
||||
data-slot="autocomplete-row"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Item
|
||||
data-slot="autocomplete-item"
|
||||
className={cn(
|
||||
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AutocompleteContentProps extends React.ComponentProps<
|
||||
typeof AutocompletePrimitive.Popup
|
||||
> {
|
||||
align?: AutocompletePrimitive.Positioner.Props["align"]
|
||||
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
|
||||
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
|
||||
side?: AutocompletePrimitive.Positioner.Props["side"]
|
||||
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
|
||||
showBackdrop?: boolean
|
||||
}
|
||||
|
||||
function AutocompleteContent({
|
||||
className,
|
||||
children,
|
||||
showBackdrop = false,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
anchor,
|
||||
...props
|
||||
}: AutocompleteContentProps) {
|
||||
return (
|
||||
<AutocompletePortal>
|
||||
{showBackdrop && <AutocompleteBackdrop />}
|
||||
<AutocompletePositioner
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
anchor={anchor}
|
||||
>
|
||||
<div className="relative flex max-h-full">
|
||||
<AutocompletePrimitive.Popup
|
||||
data-slot="autocomplete-popup"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</AutocompletePrimitive.Popup>
|
||||
</div>
|
||||
</AutocompletePositioner>
|
||||
</AutocompletePortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteGroupLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
|
||||
return (
|
||||
<AutocompletePrimitive.GroupLabel
|
||||
data-slot="autocomplete-group-label"
|
||||
className={cn(
|
||||
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Empty
|
||||
data-slot="autocomplete-empty"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteClear({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Clear
|
||||
data-slot="autocomplete-clear"
|
||||
className={cn(
|
||||
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</AutocompletePrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Trigger
|
||||
data-slot="autocomplete-trigger"
|
||||
className={cn(
|
||||
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4 opacity-70" />
|
||||
</AutocompletePrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteArrow({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Separator
|
||||
data-slot="autocomplete-separator"
|
||||
className={cn(
|
||||
"bg-border my-1.5 h-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Autocomplete,
|
||||
AutocompleteValue,
|
||||
AutocompleteTrigger,
|
||||
AutocompleteInput,
|
||||
AutocompleteStatus,
|
||||
AutocompletePortal,
|
||||
AutocompleteBackdrop,
|
||||
AutocompletePositioner,
|
||||
AutocompleteContent,
|
||||
AutocompleteList,
|
||||
AutocompleteCollection,
|
||||
AutocompleteRow,
|
||||
AutocompleteItem,
|
||||
AutocompleteGroup,
|
||||
AutocompleteGroupLabel,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteClear,
|
||||
AutocompleteArrow,
|
||||
AutocompleteSeparator,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground",
|
||||
outline: "border-border bg-transparent dark:bg-input/32",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
info: "bg-info text-white",
|
||||
success: "bg-success text-white",
|
||||
warning: "bg-warning text-white",
|
||||
destructive: "bg-destructive text-white",
|
||||
focus: "bg-focus text-focus-foreground",
|
||||
invert: "bg-invert text-invert-foreground",
|
||||
"primary-light":
|
||||
"bg-primary/10 border-none text-primary dark:bg-primary/20",
|
||||
"warning-light":
|
||||
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20",
|
||||
"success-light":
|
||||
"bg-success/10 border-none text-success-foreground dark:bg-success/20",
|
||||
"info-light":
|
||||
"bg-info/10 border-none text-info-foreground dark:bg-info/20",
|
||||
"destructive-light":
|
||||
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20",
|
||||
"invert-light":
|
||||
"bg-invert/10 border-none text-foreground dark:bg-invert/20",
|
||||
"focus-light":
|
||||
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20",
|
||||
"primary-outline":
|
||||
"bg-background border-border text-primary dark:bg-input/30",
|
||||
"warning-outline":
|
||||
"bg-background border-border text-warning-foreground dark:bg-input/30",
|
||||
"success-outline":
|
||||
"bg-background border-border text-success-foreground dark:bg-input/30",
|
||||
"info-outline":
|
||||
"bg-background border-border text-info-foreground dark:bg-input/30",
|
||||
"destructive-outline":
|
||||
"bg-background border-border text-destructive-foreground dark:bg-input/30",
|
||||
"invert-outline":
|
||||
"bg-background border-border text-invert-foreground dark:bg-input/30",
|
||||
"focus-outline":
|
||||
"bg-background border-border text-focus-foreground dark:bg-input/30",
|
||||
},
|
||||
size: {
|
||||
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
|
||||
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
|
||||
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
|
||||
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
|
||||
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
|
||||
},
|
||||
/** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */
|
||||
radius: {
|
||||
default:
|
||||
"rounded-sm",
|
||||
full: "rounded-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
radius: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface BadgeProps extends useRender.ComponentProps<"span"> {
|
||||
variant?: VariantProps<typeof badgeVariants>["variant"]
|
||||
size?: VariantProps<typeof badgeVariants>["size"]
|
||||
radius?: VariantProps<typeof badgeVariants>["radius"]
|
||||
}
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
radius,
|
||||
render,
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const defaultProps = {
|
||||
"data-slot": "badge",
|
||||
className: cn(badgeVariants({ variant, size, radius, className })),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
render,
|
||||
props: mergeProps<"span">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants, type BadgeProps }
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}
|
||||
|
||||
function DataGridColumnFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchQuery) return options
|
||||
return options.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}, [options, searchQuery])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<CirclePlusIcon className="size-4" />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder={title}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
No results found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{filteredOptions.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<div className="bg-border -mx-1 my-1 h-px" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
onClick={() => column?.setFilterValue(undefined)}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
Clear filters
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnFilter, type DataGridColumnFilterProps }
|
||||
@@ -0,0 +1,343 @@
|
||||
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
useDataGrid,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnHeaderProps<
|
||||
TData,
|
||||
TValue,
|
||||
> extends HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
pinnable?: boolean
|
||||
filter?: ReactNode
|
||||
visibility?: boolean
|
||||
}
|
||||
|
||||
function DataGridColumnHeaderInner<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
icon,
|
||||
className,
|
||||
filter,
|
||||
visibility = false,
|
||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||
const { isLoading, table, props, recordCount } = useDataGrid()
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
const columnOrder = table.getState().columnOrder
|
||||
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
|
||||
const isSorted = column.getIsSorted()
|
||||
const isPinned = column.getIsPinned()
|
||||
const canSort = column.getCanSort()
|
||||
const canPin = column.getCanPin()
|
||||
const canResize = column.getCanResize()
|
||||
|
||||
const columnIndex = columnOrder.indexOf(column.id)
|
||||
const canMoveLeft = columnIndex > 0
|
||||
const canMoveRight = columnIndex < columnOrder.length - 1
|
||||
|
||||
const handleSort = () => {
|
||||
if (isSorted === "asc") {
|
||||
column.toggleSorting(true)
|
||||
} else if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const headerLabelClassName = cn(
|
||||
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
|
||||
className
|
||||
)
|
||||
|
||||
const headerButtonClassName = cn(
|
||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
|
||||
className
|
||||
)
|
||||
|
||||
const sortIcon =
|
||||
canSort &&
|
||||
(isSorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3.25" />
|
||||
) : isSorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.25" />
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" />
|
||||
))
|
||||
|
||||
const hasControls =
|
||||
props.tableLayout?.columnsMovable ||
|
||||
(props.tableLayout?.columnsVisibility && visibility) ||
|
||||
(props.tableLayout?.columnsPinnable && canPin) ||
|
||||
filter
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items: ReactNode[] = []
|
||||
let hasPreviousSection = false
|
||||
|
||||
// Filter section
|
||||
if (filter) {
|
||||
items.push(
|
||||
<DropdownMenuGroup key="group-filter">
|
||||
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Sort section
|
||||
if (canSort) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-sort" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="sort-asc"
|
||||
onClick={() => {
|
||||
if (isSorted === "asc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5!" />
|
||||
<span className="grow">Asc</span>
|
||||
{isSorted === "asc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="sort-desc"
|
||||
onClick={() => {
|
||||
if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(true)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5!" />
|
||||
<span className="grow">Desc</span>
|
||||
{isSorted === "desc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Pin section
|
||||
if (props.tableLayout?.columnsPinnable && canPin) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-pin" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="pin-left"
|
||||
onClick={() => column.pin(isPinned === "left" ? false : "left")}
|
||||
>
|
||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to left</span>
|
||||
{isPinned === "left" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="pin-right"
|
||||
onClick={() => column.pin(isPinned === "right" ? false : "right")}
|
||||
>
|
||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to right</span>
|
||||
{isPinned === "right" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Move section
|
||||
if (props.tableLayout?.columnsMovable) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-move" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="move-left"
|
||||
onClick={() => {
|
||||
if (columnIndex > 0) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex - 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
}}
|
||||
disabled={!canMoveLeft || isPinned !== false}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Left</span>
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="move-right"
|
||||
onClick={() => {
|
||||
if (columnIndex < columnOrder.length - 1) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex + 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
}}
|
||||
disabled={!canMoveRight || isPinned !== false}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Right</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Visibility section
|
||||
if (props.tableLayout?.columnsVisibility && visibility) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-visibility" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuSub key="visibility">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Settings2Icon className="size-3.5!" />
|
||||
<span>Columns</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent side="right">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((col) => col.getCanHide())
|
||||
.map((col) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.id}
|
||||
checked={col.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => col.toggleVisibility(!!value)}
|
||||
className="capitalize"
|
||||
>
|
||||
{getColumnHeaderLabel(col)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
filter,
|
||||
canSort,
|
||||
isSorted,
|
||||
column,
|
||||
props.tableLayout?.columnsPinnable,
|
||||
props.tableLayout?.columnsMovable,
|
||||
props.tableLayout?.columnsVisibility,
|
||||
canPin,
|
||||
isPinned,
|
||||
canMoveLeft,
|
||||
canMoveRight,
|
||||
visibility,
|
||||
table,
|
||||
columnIndex,
|
||||
columnOrder,
|
||||
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
||||
])
|
||||
|
||||
if (hasControls) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-between gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent className="w-40" align="start">
|
||||
{menuItems}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="-me-1 size-7 rounded-md"
|
||||
onClick={() => column.pin(false)}
|
||||
aria-label={`Unpin ${resolvedTitle} column`}
|
||||
title={`Unpin ${resolvedTitle} column`}
|
||||
>
|
||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
onClick={handleSort}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={headerLabelClassName}>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DataGridColumnHeader = memo(
|
||||
DataGridColumnHeaderInner
|
||||
) as typeof DataGridColumnHeaderInner
|
||||
|
||||
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { ReactElement } from "react"
|
||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
||||
import { Table } from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
|
||||
function DataGridColumnVisibility<TData>({
|
||||
table,
|
||||
trigger,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
trigger: ReactElement<Record<string, unknown>>
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={trigger} />
|
||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-medium">
|
||||
Toggle Columns
|
||||
</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{getColumnHeaderLabel(column)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnVisibility }
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
interface DataGridPaginationProps {
|
||||
sizes?: number[]
|
||||
sizesInfo?: string
|
||||
sizesLabel?: string
|
||||
sizesDescription?: string
|
||||
sizesSkeleton?: ReactNode
|
||||
more?: boolean
|
||||
moreLimit?: number
|
||||
info?: string
|
||||
infoSkeleton?: ReactNode
|
||||
className?: string
|
||||
rowsPerPageLabel?: string
|
||||
previousPageLabel?: string
|
||||
nextPageLabel?: string
|
||||
ellipsisText?: string
|
||||
}
|
||||
|
||||
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
const { table, recordCount, isLoading } = useDataGrid()
|
||||
|
||||
const defaultProps: Partial<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
|
||||
value={`${pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
const newPageSize = Number(value)
|
||||
table.setPageSize(newPageSize)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-14" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" className="min-w-18">
|
||||
{mergedProps?.sizes?.map((size: number) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.infoSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
|
||||
{paginationInfo}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="order-1 flex items-center space-x-1 sm:order-2">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{mergedProps.previousPageLabel}
|
||||
</span>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
{renderEllipsisPrevButton()}
|
||||
|
||||
{renderPageButtons()}
|
||||
|
||||
{renderEllipsisNextButton()}
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">{mergedProps.nextPageLabel}</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridPagination, type DataGridPaginationProps }
|
||||
@@ -0,0 +1,421 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
PointerEvent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
const MIN_THUMB_SIZE = 24
|
||||
const FALLBACK_SCROLLBAR_SIZE = 12
|
||||
|
||||
const INITIAL_METRICS = {
|
||||
hasVerticalOverflow: false,
|
||||
headerHeight: 0,
|
||||
horizontalScrollbarSize: 0,
|
||||
thumbHeight: 0,
|
||||
thumbTop: 0,
|
||||
trackHeight: 0,
|
||||
} as const
|
||||
|
||||
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
|
||||
|
||||
type ScrollbarMetrics = {
|
||||
hasVerticalOverflow: boolean
|
||||
headerHeight: number
|
||||
horizontalScrollbarSize: number
|
||||
thumbHeight: number
|
||||
thumbTop: number
|
||||
trackHeight: number
|
||||
}
|
||||
|
||||
type ObservedElements = {
|
||||
header: HTMLElement | null
|
||||
horizontalScrollbar: HTMLElement | null
|
||||
table: HTMLElement | null
|
||||
tableViewport: HTMLElement | null
|
||||
}
|
||||
|
||||
type DataGridScrollAreaProps = Omit<
|
||||
ScrollAreaPrimitive.Root.Props,
|
||||
"children"
|
||||
> & {
|
||||
children: ReactNode
|
||||
orientation?: DataGridScrollAreaOrientation
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
|
||||
return (
|
||||
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
|
||||
next.headerHeight === prev.headerHeight &&
|
||||
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
|
||||
next.thumbHeight === prev.thumbHeight &&
|
||||
next.thumbTop === prev.thumbTop &&
|
||||
next.trackHeight === prev.trackHeight
|
||||
)
|
||||
}
|
||||
|
||||
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-header-height",
|
||||
`${metrics.headerHeight}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-thumb-height",
|
||||
`${metrics.thumbHeight}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-thumb-top",
|
||||
`${metrics.thumbTop}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-track-height",
|
||||
`${metrics.trackHeight}px`
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridScrollArea({
|
||||
children,
|
||||
className,
|
||||
orientation = "both",
|
||||
...props
|
||||
}: DataGridScrollAreaProps) {
|
||||
const { props: dataGridProps } = useDataGrid()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
startScrollTop: number
|
||||
startY: number
|
||||
} | null>(null)
|
||||
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
|
||||
const observedElementsRef = useRef<ObservedElements>({
|
||||
header: null,
|
||||
horizontalScrollbar: null,
|
||||
table: null,
|
||||
tableViewport: null,
|
||||
})
|
||||
|
||||
const showHorizontal = orientation !== "vertical"
|
||||
const showVertical = orientation !== "horizontal"
|
||||
const usesCustomVerticalScrollbar =
|
||||
showVertical && !!dataGridProps.tableLayout?.headerSticky
|
||||
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
|
||||
useState(false)
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
dragRef.current = null
|
||||
document.body.style.userSelect = ""
|
||||
document.body.style.webkitUserSelect = ""
|
||||
}, [])
|
||||
|
||||
const resetMetrics = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
|
||||
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
||||
applyMetrics(container, INITIAL_METRICS)
|
||||
metricsRef.current = INITIAL_METRICS
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
|
||||
}, [])
|
||||
|
||||
const syncCustomVerticalScrollbar = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!container || !viewport || !usesCustomVerticalScrollbar) {
|
||||
resetMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
const { header, horizontalScrollbar } = observedElementsRef.current
|
||||
const headerHeight = header?.getBoundingClientRect().height ?? 0
|
||||
const viewportHeight = viewport.clientHeight
|
||||
const viewportWidth = viewport.clientWidth
|
||||
const scrollHeight = viewport.scrollHeight
|
||||
const scrollWidth = viewport.scrollWidth
|
||||
const hasHorizontalOverflow =
|
||||
showHorizontal && scrollWidth > viewportWidth + 0.5
|
||||
const horizontalScrollbarSize = hasHorizontalOverflow
|
||||
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
|
||||
: 0
|
||||
const trackHeight = Math.max(
|
||||
0,
|
||||
viewportHeight - headerHeight - horizontalScrollbarSize
|
||||
)
|
||||
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
|
||||
|
||||
let nextMetrics: ScrollbarMetrics
|
||||
|
||||
if (trackHeight === 0 || maxScroll === 0) {
|
||||
nextMetrics = {
|
||||
hasVerticalOverflow: false,
|
||||
headerHeight,
|
||||
horizontalScrollbarSize,
|
||||
thumbHeight: trackHeight,
|
||||
thumbTop: 0,
|
||||
trackHeight,
|
||||
}
|
||||
} else {
|
||||
const bodyContentHeight = Math.max(
|
||||
trackHeight,
|
||||
scrollHeight - headerHeight
|
||||
)
|
||||
const thumbHeight = clamp(
|
||||
trackHeight * (trackHeight / bodyContentHeight),
|
||||
MIN_THUMB_SIZE,
|
||||
trackHeight
|
||||
)
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
const thumbTop =
|
||||
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
|
||||
|
||||
nextMetrics = {
|
||||
hasVerticalOverflow: true,
|
||||
headerHeight,
|
||||
horizontalScrollbarSize,
|
||||
thumbHeight,
|
||||
thumbTop,
|
||||
trackHeight,
|
||||
}
|
||||
}
|
||||
|
||||
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
|
||||
applyMetrics(container, nextMetrics)
|
||||
metricsRef.current = nextMetrics
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) =>
|
||||
prev === nextMetrics.hasVerticalOverflow
|
||||
? prev
|
||||
: nextMetrics.hasVerticalOverflow
|
||||
)
|
||||
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!container || !viewport) return
|
||||
|
||||
if (!usesCustomVerticalScrollbar) {
|
||||
resetMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
observedElementsRef.current = {
|
||||
header: container.querySelector(
|
||||
'[data-slot="data-grid-table"] thead'
|
||||
) as HTMLElement | null,
|
||||
horizontalScrollbar: container.querySelector(
|
||||
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
||||
) as HTMLElement | null,
|
||||
table: container.querySelector(
|
||||
'[data-slot="data-grid-table"]'
|
||||
) as HTMLElement | null,
|
||||
tableViewport: container.querySelector(
|
||||
'[data-slot="data-grid-table-viewport"]'
|
||||
) as HTMLElement | null,
|
||||
}
|
||||
|
||||
let frame = 0
|
||||
|
||||
const scheduleSync = () => {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
||||
}
|
||||
|
||||
scheduleSync()
|
||||
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
||||
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(scheduleSync)
|
||||
|
||||
observer?.observe(viewport)
|
||||
observedElementsRef.current.header &&
|
||||
observer?.observe(observedElementsRef.current.header)
|
||||
observedElementsRef.current.table &&
|
||||
observer?.observe(observedElementsRef.current.table)
|
||||
observedElementsRef.current.tableViewport &&
|
||||
observer?.observe(observedElementsRef.current.tableViewport)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
observer?.disconnect()
|
||||
viewport.removeEventListener("scroll", scheduleSync)
|
||||
clearDragState()
|
||||
}
|
||||
}, [
|
||||
clearDragState,
|
||||
resetMetrics,
|
||||
syncCustomVerticalScrollbar,
|
||||
usesCustomVerticalScrollbar,
|
||||
])
|
||||
|
||||
const scrollToThumbOffset = (nextThumbTop: number) => {
|
||||
const viewport = viewportRef.current
|
||||
const { thumbHeight, trackHeight } = metricsRef.current
|
||||
|
||||
if (!viewport) return
|
||||
|
||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
|
||||
if (maxScroll === 0 || maxThumbTop === 0) {
|
||||
viewport.scrollTop = 0
|
||||
return
|
||||
}
|
||||
|
||||
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
|
||||
viewport.scrollTop = ratio * maxScroll
|
||||
}
|
||||
|
||||
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startScrollTop: viewport.scrollTop,
|
||||
startY: event.clientY,
|
||||
}
|
||||
|
||||
document.body.style.userSelect = "none"
|
||||
document.body.style.webkitUserSelect = "none"
|
||||
}
|
||||
|
||||
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current
|
||||
const dragState = dragRef.current
|
||||
const { thumbHeight, trackHeight } = metricsRef.current
|
||||
|
||||
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||
|
||||
if (maxThumbTop === 0 || maxScroll === 0) return
|
||||
|
||||
const deltaY = event.clientY - dragState.startY
|
||||
const nextScrollTop =
|
||||
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
|
||||
|
||||
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
|
||||
}
|
||||
|
||||
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return
|
||||
clearDragState()
|
||||
}
|
||||
|
||||
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const { thumbHeight } = metricsRef.current
|
||||
|
||||
if (event.target !== event.currentTarget) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const offsetY = event.clientY - rect.top - thumbHeight / 2
|
||||
|
||||
scrollToThumbOffset(offsetY)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="data-grid-scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full"
|
||||
>
|
||||
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Content>
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
{showHorizontal && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
|
||||
{showVertical && !usesCustomVerticalScrollbar && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="vertical"
|
||||
orientation="vertical"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
</ScrollAreaPrimitive.Root>
|
||||
|
||||
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border absolute end-px w-2",
|
||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
||||
"rounded-full"
|
||||
)}
|
||||
onLostPointerCapture={clearDragState}
|
||||
onPointerCancel={handleThumbPointerUp}
|
||||
onPointerDown={handleThumbPointerDown}
|
||||
onPointerMove={handleThumbPointerMove}
|
||||
onPointerUp={handleThumbPointerUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridScrollArea }
|
||||
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
createContext,
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type Modifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { GripHorizontalIcon } from "lucide-react"
|
||||
|
||||
// Context to share sortable listeners from row to handle
|
||||
type SortableContextValue = ReturnType<typeof useSortable>
|
||||
const SortableRowContext = createContext<Pick<
|
||||
SortableContextValue,
|
||||
"attributes" | "listeners"
|
||||
> | null>(null)
|
||||
|
||||
function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
const context = useContext(SortableRowContext)
|
||||
|
||||
if (!context) {
|
||||
// Fallback if context is not available (shouldn't happen in normal usage)
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
||||
const {
|
||||
transform,
|
||||
transition,
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
} = useSortable({
|
||||
id: row.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
position: "relative",
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||
<DataGridTableBodyRow
|
||||
row={row}
|
||||
dndRef={setNodeRef}
|
||||
dndStyle={style}
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
</SortableRowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRows<TData>({
|
||||
handleDragEnd,
|
||||
dataIds,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
dataIds: UniqueIdentifier[]
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingRow) return
|
||||
|
||||
const { body, documentElement } = document
|
||||
const previousBodyCursor = body.style.cursor
|
||||
const previousDocumentCursor = documentElement.style.cursor
|
||||
|
||||
body.style.cursor = "grabbing"
|
||||
documentElement.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
body.style.cursor = previousBodyCursor
|
||||
documentElement.style.cursor = previousDocumentCursor
|
||||
}
|
||||
}, [isDraggingRow])
|
||||
|
||||
const modifiers = useMemo(() => {
|
||||
const restrictToTableContainer: Modifier = ({
|
||||
transform,
|
||||
draggingNodeRect,
|
||||
}) => {
|
||||
if (!tableContainerRef.current || !draggingNodeRect) {
|
||||
return transform
|
||||
}
|
||||
|
||||
const containerRect = tableContainerRef.current.getBoundingClientRect()
|
||||
const { x, y } = transform
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left
|
||||
const maxX = containerRect.right - draggingNodeRect.right
|
||||
const minY = containerRect.top - draggingNodeRect.top
|
||||
const maxY = containerRect.bottom - draggingNodeRect.bottom
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.max(minX, Math.min(maxX, x)),
|
||||
y: Math.max(minY, Math.min(maxY, y)),
|
||||
}
|
||||
}
|
||||
|
||||
return [restrictToVerticalAxis, restrictToTableContainer]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id={useId()}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingRow(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingRow(false)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingRow(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={tableContainerRef}
|
||||
className={
|
||||
isDraggingRow
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return <DataGridTableDndRow row={row} key={row.id} />
|
||||
})}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
Fragment,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
Modifier,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
Cell,
|
||||
flexRender,
|
||||
Header,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
|
||||
function DataGridTableDndHeader<TData>({
|
||||
header,
|
||||
}: {
|
||||
header: Header<TData, unknown>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const { column } = header
|
||||
|
||||
// Check if column ordering is enabled for this column
|
||||
const canOrder =
|
||||
(column.columnDef as { enableColumnOrdering?: boolean })
|
||||
.enableColumnOrdering !== false
|
||||
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: header.column.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
position: "relative",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
whiteSpace: "nowrap",
|
||||
width: props.tableLayout?.columnsResizable
|
||||
? `calc(var(--header-${header.id}-size) * 1px)`
|
||||
: header.column.getSize(),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell
|
||||
header={header}
|
||||
dndStyle={style}
|
||||
dndRef={setNodeRef}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-0.5">
|
||||
{canOrder && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<span className="grow truncate">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</div>
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
||||
const { props } = useDataGrid()
|
||||
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
||||
id: cell.column.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
position: "relative",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
width: props.tableLayout?.columnsResizable
|
||||
? `calc(var(--col-${cell.column.id}-size) * 1px)`
|
||||
: cell.column.getSize(),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDnd<TData>({
|
||||
handleDragEnd,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingColumn) return
|
||||
|
||||
const { body, documentElement } = document
|
||||
const previousBodyCursor = body.style.cursor
|
||||
const previousDocumentCursor = documentElement.style.cursor
|
||||
|
||||
body.style.cursor = "grabbing"
|
||||
documentElement.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
body.style.cursor = previousBodyCursor
|
||||
documentElement.style.cursor = previousDocumentCursor
|
||||
}
|
||||
}, [isDraggingColumn])
|
||||
|
||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
||||
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const edgeOffset = 0
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||
const maxX =
|
||||
containerRect.right -
|
||||
draggingNodeRect.left -
|
||||
draggingNodeRect.width +
|
||||
edgeOffset
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: 0, // Lock vertical movement
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
id={useId()}
|
||||
modifiers={[restrictToTableBounds]}
|
||||
onDragCancel={() => setIsDraggingColumn(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingColumn(false)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingColumn(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={containerRef}
|
||||
className={
|
||||
isDraggingColumn
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
key={header.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<SortableContext
|
||||
key={cell.id}
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<DataGridTableDndCell cell={cell} />
|
||||
</SortableContext>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && (
|
||||
<DataGridTableBodyRowExpandded row={row} />
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDnd }
|
||||
@@ -0,0 +1,490 @@
|
||||
import {
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRenderedRow,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridTableRowSections,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
|
||||
import {
|
||||
useVirtualizer,
|
||||
VirtualItem,
|
||||
Virtualizer,
|
||||
VirtualizerOptions,
|
||||
} from "@tanstack/react-virtual"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Spinner } from "@evobgp/ui/components/spinner"
|
||||
|
||||
type DataGridTableVirtualScrollElements = {
|
||||
containerElement: HTMLDivElement | null
|
||||
scrollElement: HTMLElement | null
|
||||
}
|
||||
|
||||
type DataGridTableVirtualizerInstance = Virtualizer<
|
||||
HTMLElement,
|
||||
HTMLTableRowElement
|
||||
>
|
||||
|
||||
type DataGridTableVirtualizerOptions<TData> = Omit<
|
||||
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
||||
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
||||
> & {
|
||||
estimateSize?: (index: number, row: Row<TData>) => number
|
||||
getItemKey?: (index: number, row: Row<TData>) => string | number
|
||||
getScrollElement?: (
|
||||
elements: DataGridTableVirtualScrollElements
|
||||
) => HTMLElement | null
|
||||
}
|
||||
|
||||
interface DataGridTableVirtualProps<TData> {
|
||||
height?: number | string
|
||||
estimateSize?: number
|
||||
overscan?: number
|
||||
footerContent?: ReactNode
|
||||
renderHeader?: boolean
|
||||
onFetchMore?: () => void
|
||||
isFetchingMore?: boolean
|
||||
hasMore?: boolean
|
||||
fetchMoreOffset?: number
|
||||
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
||||
}
|
||||
|
||||
interface VirtualBodyProps<TData> {
|
||||
table: Table<TData>
|
||||
columnCount: number
|
||||
topRows: Row<TData>[]
|
||||
centerRows: Row<TData>[]
|
||||
bottomRows: Row<TData>[]
|
||||
virtualItems: VirtualItem[]
|
||||
totalSize: number
|
||||
isVirtualizationEnabled: boolean
|
||||
isInfiniteMode: boolean
|
||||
isFetchingMore: boolean
|
||||
hasMore?: boolean
|
||||
loadingMoreMessage: ReactNode
|
||||
allRowsLoadedMessage: ReactNode
|
||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||
}
|
||||
|
||||
function DataGridTableVirtualSpacer({
|
||||
columnCount,
|
||||
height,
|
||||
}: {
|
||||
columnCount: number
|
||||
height: number
|
||||
}) {
|
||||
if (height <= 0) return null
|
||||
|
||||
return (
|
||||
<tr aria-hidden="true">
|
||||
<td colSpan={columnCount} style={{ height, padding: 0 }} />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualStatusRow({
|
||||
children,
|
||||
className,
|
||||
columnCount,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
columnCount: number
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columnCount}
|
||||
className={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualBody<TData>({
|
||||
table,
|
||||
columnCount,
|
||||
topRows,
|
||||
centerRows,
|
||||
bottomRows,
|
||||
virtualItems,
|
||||
totalSize,
|
||||
isVirtualizationEnabled,
|
||||
isInfiniteMode,
|
||||
isFetchingMore,
|
||||
hasMore,
|
||||
loadingMoreMessage,
|
||||
allRowsLoadedMessage,
|
||||
measureRowRef,
|
||||
}: VirtualBodyProps<TData>) {
|
||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||
|
||||
if (!totalRows) return <DataGridTableEmpty />
|
||||
|
||||
const hasCenterRows = centerRows.length > 0
|
||||
const showFetchingRow = isInfiniteMode && isFetchingMore
|
||||
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
|
||||
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
|
||||
const leadingSpacerHeight =
|
||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
||||
? (virtualItems[0]?.start ?? 0)
|
||||
: 0
|
||||
const trailingSpacerHeight =
|
||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
||||
? Math.max(
|
||||
0,
|
||||
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
||||
)
|
||||
: 0
|
||||
|
||||
const renderedRows: ReactNode[] = []
|
||||
|
||||
topRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pinnedBoundary={
|
||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (isVirtualizationEnabled) {
|
||||
if (leadingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-start"
|
||||
columnCount={columnCount}
|
||||
height={leadingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
virtualItems.forEach((virtualRow) => {
|
||||
const row = centerRows[virtualRow.index]
|
||||
|
||||
if (!row) return
|
||||
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowRef={measureRowRef}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (trailingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-end"
|
||||
columnCount={columnCount}
|
||||
height={trailingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
centerRows.forEach((row) => {
|
||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
||||
})
|
||||
}
|
||||
|
||||
if (showFetchingRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-loading"
|
||||
columnCount={columnCount}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-4 opacity-60" />
|
||||
{loadingMoreMessage}
|
||||
</div>
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
if (showCompleteRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-complete"
|
||||
columnCount={columnCount}
|
||||
className="py-3 text-xs"
|
||||
>
|
||||
{allRowsLoadedMessage}
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
bottomRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pinnedBoundary={
|
||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
||||
? "bottom"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return <>{renderedRows}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized virtual body: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> element,
|
||||
* so the browser handles width changes without React re-renders.
|
||||
*/
|
||||
const MemoizedVirtualBody = memo(
|
||||
DataGridTableVirtualBody,
|
||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
||||
) as typeof DataGridTableVirtualBody
|
||||
|
||||
function DataGridTableVirtual<TData>({
|
||||
height,
|
||||
estimateSize = 48,
|
||||
overscan = 10,
|
||||
footerContent,
|
||||
renderHeader = true,
|
||||
onFetchMore,
|
||||
isFetchingMore = false,
|
||||
hasMore,
|
||||
fetchMoreOffset = 0,
|
||||
virtualizerOptions,
|
||||
}: DataGridTableVirtualProps<TData>) {
|
||||
const { table, props } = useDataGrid()
|
||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||
table,
|
||||
props.tableLayout?.rowsPinnable
|
||||
)
|
||||
const columnCount =
|
||||
table.getVisibleFlatColumns().length +
|
||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
||||
const isInfiniteMode = typeof onFetchMore === "function"
|
||||
const [viewportElements, setViewportElements] =
|
||||
useState<DataGridTableVirtualScrollElements>({
|
||||
containerElement: null,
|
||||
scrollElement: null,
|
||||
})
|
||||
|
||||
const {
|
||||
estimateSize: customEstimateSize,
|
||||
getItemKey: customGetItemKey,
|
||||
getScrollElement: customGetScrollElement,
|
||||
measureElement: customMeasureElement,
|
||||
overscan: customOverscan,
|
||||
...virtualizerOptionsRest
|
||||
} = virtualizerOptions ?? {}
|
||||
|
||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||
const loadingMoreMessage =
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||
const allRowsLoadedMessage =
|
||||
props.allRowsLoadedMessage || "All records loaded"
|
||||
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
containerElement: node,
|
||||
scrollElement:
|
||||
(node?.closest(
|
||||
'[data-slot="scroll-area-viewport"]'
|
||||
) as HTMLElement | null) ?? node,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const usesExternalScrollArea =
|
||||
viewportElements.scrollElement !== null &&
|
||||
viewportElements.scrollElement !== viewportElements.containerElement
|
||||
|
||||
const resolveScrollElement = useCallback(() => {
|
||||
if (customGetScrollElement) {
|
||||
return customGetScrollElement(viewportElements)
|
||||
}
|
||||
|
||||
return viewportElements.scrollElement
|
||||
}, [customGetScrollElement, viewportElements])
|
||||
|
||||
const resolveItemKey = useCallback(
|
||||
(index: number) => {
|
||||
const row = centerRows[index]
|
||||
|
||||
if (!row) return index
|
||||
|
||||
return customGetItemKey?.(index, row) ?? row.id ?? index
|
||||
},
|
||||
[centerRows, customGetItemKey]
|
||||
)
|
||||
|
||||
const resolveEstimateSize = useCallback(
|
||||
(index: number) => {
|
||||
const row = centerRows[index]
|
||||
|
||||
return row
|
||||
? (customEstimateSize?.(index, row) ?? estimateSize)
|
||||
: estimateSize
|
||||
},
|
||||
[centerRows, customEstimateSize, estimateSize]
|
||||
)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: centerRows.length,
|
||||
getScrollElement: resolveScrollElement,
|
||||
getItemKey: resolveItemKey,
|
||||
estimateSize: resolveEstimateSize,
|
||||
overscan: customOverscan ?? overscan,
|
||||
measureElement: customMeasureElement,
|
||||
...virtualizerOptionsRest,
|
||||
}) as DataGridTableVirtualizerInstance
|
||||
|
||||
const virtualItems = isVirtualizationEnabled
|
||||
? virtualizer.getVirtualItems()
|
||||
: []
|
||||
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
|
||||
const measureRowRef =
|
||||
isVirtualizationEnabled && customMeasureElement
|
||||
? virtualizer.measureElement
|
||||
: undefined
|
||||
const resolvedFetchMoreOffset = useMemo(
|
||||
() => Math.max(0, fetchMoreOffset),
|
||||
[fetchMoreOffset]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isVirtualizationEnabled ||
|
||||
!isInfiniteMode ||
|
||||
hasMore === false ||
|
||||
isFetchingMore
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastItem = virtualItems[virtualItems.length - 1]
|
||||
if (!lastItem) return
|
||||
|
||||
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
||||
onFetchMore?.()
|
||||
}
|
||||
}, [
|
||||
centerRows.length,
|
||||
hasMore,
|
||||
isFetchingMore,
|
||||
isInfiniteMode,
|
||||
isVirtualizationEnabled,
|
||||
onFetchMore,
|
||||
resolvedFetchMoreOffset,
|
||||
virtualItems,
|
||||
])
|
||||
|
||||
return (
|
||||
<DataGridTableViewport
|
||||
viewportRef={handleViewportRef}
|
||||
className={!usesExternalScrollArea ? "block" : undefined}
|
||||
style={
|
||||
usesExternalScrollArea
|
||||
? undefined
|
||||
: { height, overflow: "auto", position: "relative" }
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, hIndex) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={hIndex}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
</DataGridTableHead>
|
||||
)}
|
||||
|
||||
{renderHeader &&
|
||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
<MemoizedVirtualBody
|
||||
table={table}
|
||||
columnCount={columnCount}
|
||||
topRows={topRows}
|
||||
centerRows={centerRows}
|
||||
bottomRows={bottomRows}
|
||||
virtualItems={virtualItems}
|
||||
totalSize={totalSize}
|
||||
isVirtualizationEnabled={isVirtualizationEnabled}
|
||||
isInfiniteMode={isInfiniteMode}
|
||||
isFetchingMore={isFetchingMore}
|
||||
hasMore={hasMore}
|
||||
loadingMoreMessage={loadingMoreMessage}
|
||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
||||
measureRowRef={measureRowRef}
|
||||
/>
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableVirtual }
|
||||
export type {
|
||||
DataGridTableVirtualProps,
|
||||
DataGridTableVirtualScrollElements,
|
||||
DataGridTableVirtualizerOptions,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react"
|
||||
import {
|
||||
Column,
|
||||
ColumnFiltersState,
|
||||
RowData,
|
||||
SortingState,
|
||||
Table,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
headerTitle?: string
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
skeleton?: ReactNode
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
}
|
||||
}
|
||||
|
||||
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
|
||||
export function getColumnHeaderLabel<TData, TValue>(
|
||||
column: Column<TData, TValue>
|
||||
): string {
|
||||
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
|
||||
if (typeof meta?.headerTitle === "string") return meta.headerTitle
|
||||
const defHeader = column.columnDef.header
|
||||
if (typeof defHeader === "string") return defHeader
|
||||
return String(column.id)
|
||||
}
|
||||
|
||||
export type DataGridApiFetchParams = {
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
sorting?: SortingState
|
||||
filters?: ColumnFiltersState
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export type DataGridApiResponse<T> = {
|
||||
data: T[]
|
||||
empty: boolean
|
||||
pagination: {
|
||||
total: number
|
||||
page: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DataGridContextProps<TData extends object> {
|
||||
props: DataGridProps<TData>
|
||||
table: Table<TData>
|
||||
recordCount: number
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export type DataGridRequestParams = {
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
sorting?: SortingState
|
||||
columnFilters?: ColumnFiltersState
|
||||
}
|
||||
|
||||
export interface DataGridProps<TData extends object> {
|
||||
className?: string
|
||||
table?: Table<TData>
|
||||
recordCount: number
|
||||
children?: ReactNode
|
||||
onRowClick?: (row: TData) => void
|
||||
isLoading?: boolean
|
||||
loadingMode?: "skeleton" | "spinner"
|
||||
loadingMessage?: ReactNode | string
|
||||
fetchingMoreMessage?: ReactNode | string
|
||||
allRowsLoadedMessage?: ReactNode | string
|
||||
emptyMessage?: ReactNode | string
|
||||
tableLayout?: {
|
||||
dense?: boolean
|
||||
cellBorder?: boolean
|
||||
rowBorder?: boolean
|
||||
rowRounded?: boolean
|
||||
stripped?: boolean
|
||||
headerBackground?: boolean
|
||||
headerBorder?: boolean
|
||||
headerSticky?: boolean
|
||||
width?: "auto" | "fixed"
|
||||
columnsVisibility?: boolean
|
||||
columnsResizable?: boolean
|
||||
columnsResizeMode?: "onChange" | "onEnd"
|
||||
columnsPinnable?: boolean
|
||||
columnsMovable?: boolean
|
||||
columnsDraggable?: boolean
|
||||
rowsDraggable?: boolean
|
||||
rowsPinnable?: boolean
|
||||
}
|
||||
tableClassNames?: {
|
||||
base?: string
|
||||
header?: string
|
||||
headerRow?: string
|
||||
headerSticky?: string
|
||||
body?: string
|
||||
bodyRow?: string
|
||||
footer?: string
|
||||
edgeCell?: string
|
||||
}
|
||||
}
|
||||
|
||||
const DataGridContext = createContext<
|
||||
|
||||
DataGridContextProps<any> | undefined
|
||||
>(undefined)
|
||||
|
||||
function useDataGrid() {
|
||||
const context = useContext(DataGridContext)
|
||||
if (!context) {
|
||||
throw new Error("useDataGrid must be used within a DataGridProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function DataGridProvider<TData extends object>({
|
||||
children,
|
||||
table,
|
||||
...props
|
||||
}: DataGridProps<TData> & { table: Table<TData> }) {
|
||||
const tableState = table.getState()
|
||||
const resolvedColumnsResizeMode =
|
||||
props.tableLayout?.columnsResizeMode ?? "onEnd"
|
||||
|
||||
// Keep resize mode aligned with the DataGrid contract every render so
|
||||
// consumer-level useReactTable options cannot flip it back between drags.
|
||||
if (props.tableLayout?.columnsResizable) {
|
||||
table.options.columnResizeMode = resolvedColumnsResizeMode
|
||||
}
|
||||
|
||||
// Memoize context value so consumers don't re-render during column resize.
|
||||
// Column sizing state is intentionally excluded from deps -- CSS variables
|
||||
// on the <table> element handle width updates without React re-renders.
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
props,
|
||||
table,
|
||||
recordCount: props.recordCount,
|
||||
isLoading: props.isLoading || false,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
table,
|
||||
props.recordCount,
|
||||
props.isLoading,
|
||||
props.loadingMode,
|
||||
props.loadingMessage,
|
||||
props.fetchingMoreMessage,
|
||||
props.allRowsLoadedMessage,
|
||||
props.emptyMessage,
|
||||
props.onRowClick,
|
||||
props.className,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(props.tableLayout),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(props.tableClassNames),
|
||||
tableState.sorting,
|
||||
tableState.pagination,
|
||||
tableState.columnFilters,
|
||||
tableState.rowSelection,
|
||||
tableState.expanded,
|
||||
tableState.columnVisibility,
|
||||
tableState.columnOrder,
|
||||
tableState.columnPinning,
|
||||
tableState.globalFilter,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<DataGridContext.Provider value={value}>
|
||||
{children}
|
||||
</DataGridContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGrid<TData extends object>({
|
||||
children,
|
||||
table,
|
||||
...props
|
||||
}: DataGridProps<TData>) {
|
||||
const defaultProps: Partial<DataGridProps<TData>> = {
|
||||
loadingMode: "skeleton",
|
||||
tableLayout: {
|
||||
dense: false,
|
||||
cellBorder: false,
|
||||
rowBorder: true,
|
||||
rowRounded: false,
|
||||
stripped: false,
|
||||
headerSticky: false,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
width: "fixed",
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsResizeMode: "onEnd",
|
||||
columnsPinnable: false,
|
||||
columnsMovable: false,
|
||||
columnsDraggable: false,
|
||||
rowsDraggable: false,
|
||||
rowsPinnable: false,
|
||||
},
|
||||
tableClassNames: {
|
||||
base: "",
|
||||
header: "",
|
||||
headerRow: "",
|
||||
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
|
||||
body: "",
|
||||
bodyRow: "",
|
||||
footer: "",
|
||||
edgeCell: "",
|
||||
},
|
||||
}
|
||||
|
||||
const mergedProps: DataGridProps<TData> = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
tableLayout: {
|
||||
...defaultProps.tableLayout,
|
||||
...(props.tableLayout || {}),
|
||||
},
|
||||
tableClassNames: {
|
||||
...defaultProps.tableClassNames,
|
||||
...(props.tableClassNames || {}),
|
||||
},
|
||||
}
|
||||
|
||||
// Ensure table is provided
|
||||
if (!table) {
|
||||
throw new Error('DataGrid requires a "table" prop')
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridProvider table={table} {...mergedProps}>
|
||||
{children}
|
||||
</DataGridProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridContainer({
|
||||
children,
|
||||
className,
|
||||
border = true,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
border?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid"
|
||||
className={cn(
|
||||
"w-full overflow-hidden",
|
||||
border &&
|
||||
"border-border rounded-lg border",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
import { createContext, ReactNode, useContext, useId } from "react"
|
||||
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Label } from "@evobgp/ui/components/label"
|
||||
import { MinusIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
const NumberFieldContext = createContext<{
|
||||
fieldId: string
|
||||
size: "sm" | "default" | "lg"
|
||||
} | null>(null)
|
||||
|
||||
const numberFieldGroupVariants = cva(
|
||||
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-7 text-sm",
|
||||
default:
|
||||
"h-8 text-sm",
|
||||
lg: "h-9 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const numberFieldButtonVariants = cva(
|
||||
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
default:
|
||||
"px-2 [&_svg:not([class*='size-'])]:size-4",
|
||||
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const numberFieldInputVariants = cva(
|
||||
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "px-2 py-0.5",
|
||||
default:
|
||||
"px-2.5 py-1",
|
||||
lg: "px-2.5 py-1.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function NumberField({
|
||||
id,
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: NumberFieldPrimitive.Root.Props &
|
||||
VariantProps<typeof numberFieldGroupVariants>) {
|
||||
const generatedId = useId()
|
||||
const fieldId = id ?? generatedId
|
||||
const sizeValue = size ?? "default"
|
||||
|
||||
return (
|
||||
<NumberFieldContext.Provider value={{ fieldId, size: sizeValue }}>
|
||||
<NumberFieldPrimitive.Root
|
||||
className={cn("flex w-full flex-col items-start gap-2", className)}
|
||||
data-size={sizeValue}
|
||||
data-slot="number-field"
|
||||
id={fieldId}
|
||||
{...props}
|
||||
/>
|
||||
</NumberFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldGroup({
|
||||
className,
|
||||
size: sizeProp,
|
||||
...props
|
||||
}: NumberFieldPrimitive.Group.Props &
|
||||
Partial<VariantProps<typeof numberFieldGroupVariants>>) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldGroup must be used within a NumberField component."
|
||||
)
|
||||
}
|
||||
const size = sizeProp ?? context.size
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.Group
|
||||
className={cn(numberFieldGroupVariants({ size }), className)}
|
||||
data-slot="number-field-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldDecrement({
|
||||
className,
|
||||
size: sizeProp,
|
||||
children,
|
||||
...props
|
||||
}: NumberFieldPrimitive.Decrement.Props &
|
||||
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldDecrement must be used within a NumberField component."
|
||||
)
|
||||
}
|
||||
const size = sizeProp ?? context.size
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.Decrement
|
||||
className={cn(
|
||||
numberFieldButtonVariants({ size }),
|
||||
"rounded-s-lg border-e-0",
|
||||
className
|
||||
)}
|
||||
data-slot="number-field-decrement"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<MinusIcon
|
||||
/>
|
||||
)}
|
||||
</NumberFieldPrimitive.Decrement>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldIncrement({
|
||||
className,
|
||||
size: sizeProp,
|
||||
children,
|
||||
...props
|
||||
}: NumberFieldPrimitive.Increment.Props &
|
||||
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldIncrement must be used within a NumberField component."
|
||||
)
|
||||
}
|
||||
const size = sizeProp ?? context.size
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.Increment
|
||||
className={cn(
|
||||
numberFieldButtonVariants({ size }),
|
||||
"rounded-e-lg border-s-0",
|
||||
className
|
||||
)}
|
||||
data-slot="number-field-increment"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<PlusIcon
|
||||
/>
|
||||
)}
|
||||
</NumberFieldPrimitive.Increment>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldInput({
|
||||
className,
|
||||
size: sizeProp,
|
||||
...props
|
||||
}: NumberFieldPrimitive.Input.Props &
|
||||
Partial<VariantProps<typeof numberFieldInputVariants>>) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldInput must be used within a NumberField component."
|
||||
)
|
||||
}
|
||||
const size = sizeProp ?? context.size
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.Input
|
||||
className={cn(numberFieldInputVariants({ size }), className)}
|
||||
data-slot="number-field-input"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldScrubArea({
|
||||
className,
|
||||
label,
|
||||
...props
|
||||
}: NumberFieldPrimitive.ScrubArea.Props & {
|
||||
label: string
|
||||
}) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.ScrubArea
|
||||
className={cn("flex cursor-ew-resize", className)}
|
||||
data-slot="number-field-scrub-area"
|
||||
{...props}
|
||||
>
|
||||
<Label className="cursor-ew-resize" htmlFor={context.fieldId}>
|
||||
{label}
|
||||
</Label>
|
||||
<NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
|
||||
<CursorGrowIcon />
|
||||
</NumberFieldPrimitive.ScrubAreaCursor>
|
||||
</NumberFieldPrimitive.ScrubArea>
|
||||
)
|
||||
}
|
||||
|
||||
function CursorGrowIcon(props: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
fill="black"
|
||||
height="14"
|
||||
stroke="white"
|
||||
viewBox="0 0 24 14"
|
||||
width="26"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NumberField,
|
||||
NumberFieldScrubArea,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldInput,
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
|
||||
export interface SectionCardItem {
|
||||
label: ReactNode
|
||||
value: string | number | ReactElement
|
||||
hint?: ReactNode
|
||||
icon?: ReactNode
|
||||
badge?: ReactNode
|
||||
variant?: 'default' | 'warning' | 'destructive'
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||
default: '',
|
||||
warning: 'border-warning/50',
|
||||
destructive: 'border-destructive/50',
|
||||
}
|
||||
|
||||
function sectionGridClass(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'sm:grid-cols-2'
|
||||
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
|
||||
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4'
|
||||
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6'
|
||||
return 'sm:grid-cols-2 lg:grid-cols-3'
|
||||
}
|
||||
|
||||
const VALUE_VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||
default: '',
|
||||
warning: 'text-warning-foreground',
|
||||
destructive: 'text-destructive',
|
||||
}
|
||||
|
||||
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
||||
return (
|
||||
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
|
||||
{items.map((item, idx) => {
|
||||
const clickable = Boolean(item.onClick)
|
||||
const content = (
|
||||
<CardContent className="flex items-start gap-2.5 px-3 py-2.5">
|
||||
{item.icon ? (
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
||||
{item.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{typeof item.label === 'string' ? (
|
||||
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
|
||||
) : (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
|
||||
)}
|
||||
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-lg font-semibold tabular-nums',
|
||||
VALUE_VARIANT_CLASS[item.variant ?? 'default'],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
{item.hint ? (
|
||||
typeof item.hint === 'string' ? (
|
||||
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
|
||||
) : (
|
||||
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)
|
||||
return (
|
||||
<Card
|
||||
key={typeof item.label === 'string' ? item.label : idx}
|
||||
className={cn(
|
||||
'gap-0',
|
||||
VARIANT_CLASS[item.variant ?? 'default'],
|
||||
item.active && 'border-primary ring-1 ring-primary/30',
|
||||
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onKeyDown={
|
||||
clickable
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
item.onClick?.()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SectionCards } from './section-cards'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
|
||||
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<SectionCards
|
||||
items={Array.from({ length: count }, (_, i) => ({
|
||||
icon: <Skeleton className="size-4 rounded-sm" key={`icon-${i}`} />,
|
||||
label: <Skeleton className="h-3 w-20" key={`label-${i}`} />,
|
||||
value: <Skeleton className="h-5 w-16" key={`value-${i}`} />,
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<Card className="gap-0">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex gap-2 border-b p-3">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div className="flex gap-2 border-b p-3" key={`r-${r}`}>
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
active: 'success',
|
||||
ok: 'success',
|
||||
paid: 'success',
|
||||
established: 'success',
|
||||
succeeded: 'success',
|
||||
healthy: 'success',
|
||||
paused: 'secondary',
|
||||
disabled: 'secondary',
|
||||
archived: 'outline',
|
||||
error: 'destructive',
|
||||
failed: 'destructive',
|
||||
running: 'info',
|
||||
queued: 'info',
|
||||
overdue: 'warning',
|
||||
stale: 'warning',
|
||||
warning: 'warning',
|
||||
mismatch: 'warning',
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
const variant = STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
|
||||
return <Badge variant={variant}>{label ?? status}</Badge>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey="evobgp-theme"
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@evobgp/ui/components/tooltip'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
interface TruncatedTextProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
as?: 'span' | 'p' | 'div'
|
||||
/** Явный текст подсказки, если children — не строка. */
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export function TruncatedText({ children, className, as: Tag = 'span', tooltip }: TruncatedTextProps) {
|
||||
const tip =
|
||||
tooltip ??
|
||||
(typeof children === 'string' || typeof children === 'number' ? String(children) : null)
|
||||
|
||||
if (!tip) {
|
||||
return <Tag className={cn('truncate', className)}>{children}</Tag>
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Tag className={cn('truncate', className)} />}>{children}</TooltipTrigger>
|
||||
<TooltipContent>{tip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import type {
|
||||
AsEntriesResponse,
|
||||
CdnSourcesResponse,
|
||||
DomainEntriesResponse,
|
||||
IpRangeEntriesResponse,
|
||||
JobRow,
|
||||
ModuleType,
|
||||
RevisionPrefix,
|
||||
RevisionPrefixesResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
|
||||
|
||||
export type Problem = {
|
||||
type?: string
|
||||
title?: string
|
||||
status?: number
|
||||
detail?: string
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string | null): void {
|
||||
if (typeof window === 'undefined') return
|
||||
if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
function mergeHeaders(init?: RequestInit, extraHeaders?: Record<string, string>): Headers {
|
||||
const h = new Headers(init?.headers)
|
||||
if (!h.has('Accept')) h.set('Accept', 'application/json')
|
||||
const t = getToken()
|
||||
if (t && !h.has('Authorization')) h.set('Authorization', `Bearer ${t}`)
|
||||
if (extraHeaders) {
|
||||
for (const [k, v] of Object.entries(extraHeaders)) {
|
||||
if (!h.has(k)) h.set(k, v)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotency keys: `crypto.randomUUID()` exists only in secure contexts (HTTPS / localhost).
|
||||
* Over plain HTTP to a LAN IP it is often undefined — use getRandomValues or a fallback.
|
||||
*/
|
||||
function newIdempotencyKey(): string {
|
||||
const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined
|
||||
if (c?.randomUUID) return c.randomUUID()
|
||||
if (c?.getRandomValues) {
|
||||
const buf = new Uint8Array(16)
|
||||
c.getRandomValues(buf)
|
||||
buf[6] = (buf[6]! & 0x0f) | 0x40
|
||||
buf[8] = (buf[8]! & 0x3f) | 0x80
|
||||
const hex = [...buf].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
public readonly problem?: Problem,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
if (typeof window === 'undefined') throw new Error('API is only available in the browser')
|
||||
return fetch(path, { ...init, headers: mergeHeaders(init) })
|
||||
}
|
||||
|
||||
/** GET / DELETE без тела */
|
||||
export async function apiJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await apiFetch(path, init)
|
||||
return parseResponse<T>(res)
|
||||
}
|
||||
|
||||
/** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */
|
||||
export async function apiMutate<T = void>(
|
||||
path: string,
|
||||
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
||||
body?: unknown,
|
||||
opts?: { idempotent?: boolean },
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (opts?.idempotent !== false) {
|
||||
headers['Idempotency-Key'] = newIdempotencyKey()
|
||||
}
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: mergeHeaders({ headers }, headers),
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
return parseResponse<T>(res)
|
||||
}
|
||||
|
||||
async function parseResponse<T>(res: Response): Promise<T> {
|
||||
if (res.status === 204 || res.status === 205) return undefined as T
|
||||
const text = await res.text()
|
||||
if (!res.ok) {
|
||||
let problem: Problem | undefined
|
||||
let detail = `HTTP ${res.status}`
|
||||
try {
|
||||
problem = JSON.parse(text) as Problem
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
if (text) detail = text
|
||||
}
|
||||
throw new ApiError(res.status, detail, problem)
|
||||
}
|
||||
if (!text) return undefined as T
|
||||
return JSON.parse(text) as T
|
||||
}
|
||||
|
||||
const terminalJobStatuses = new Set(['succeeded', 'failed', 'cancelled'])
|
||||
|
||||
/** Ожидает завершения фоновой задачи (poll GET /v1/jobs/{id}). */
|
||||
export async function waitForJob(
|
||||
jobId: string,
|
||||
opts?: { pollMs?: number; timeoutMs?: number },
|
||||
): Promise<JobRow> {
|
||||
const pollMs = opts?.pollMs ?? 400
|
||||
const timeoutMs = opts?.timeoutMs ?? 120000
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const j = await apiJSON<JobRow>(`/v1/jobs/${jobId}`)
|
||||
if (terminalJobStatuses.has(j.status)) return j
|
||||
await new Promise((r) => setTimeout(r, pollMs))
|
||||
}
|
||||
throw new Error(`Таймаут ожидания задачи ${jobId}`)
|
||||
}
|
||||
|
||||
export async function apiPageAll<T>(path: string, limit = 500): Promise<T[]> {
|
||||
const items: T[] = []
|
||||
let cursor: string | null = null
|
||||
while (true) {
|
||||
const [basePath, rawQuery = ''] = path.split('?')
|
||||
const query = new URLSearchParams(rawQuery)
|
||||
if (!query.has('limit')) query.set('limit', String(limit))
|
||||
if (cursor) query.set('cursor', cursor)
|
||||
else query.delete('cursor')
|
||||
const page = await apiJSON<{ items?: T[]; next_cursor?: string | null; has_more?: boolean }>(
|
||||
`${basePath}?${query.toString()}`,
|
||||
)
|
||||
items.push(...(page.items ?? []))
|
||||
if (!page.has_more || !page.next_cursor) break
|
||||
cursor = page.next_cursor
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export async function fetchRevisionPrefixesAll(revisionId: string): Promise<RevisionPrefix[]> {
|
||||
const items = await apiPageAll<RevisionPrefix>(`/v1/revisions/${revisionId}/prefixes`)
|
||||
return items
|
||||
}
|
||||
|
||||
export async function fetchRevisionPrefixesResponse(
|
||||
revisionId: string,
|
||||
cursor?: string | null,
|
||||
limit = 500,
|
||||
): Promise<RevisionPrefixesResponse> {
|
||||
const query = new URLSearchParams({ limit: String(limit) })
|
||||
if (cursor) query.set('cursor', cursor)
|
||||
return apiJSON<RevisionPrefixesResponse>(`/v1/revisions/${revisionId}/prefixes?${query.toString()}`)
|
||||
}
|
||||
|
||||
export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) {
|
||||
if (moduleType === 'DOMAINS') {
|
||||
const entries = await apiPageAll<DomainEntriesResponse['items'][number]>(
|
||||
`/v1/modules/${moduleId}/domain-entries`,
|
||||
)
|
||||
return { domains: entries }
|
||||
}
|
||||
if (moduleType === 'AS_PREFIXES') {
|
||||
const entries = await apiPageAll<AsEntriesResponse['items'][number]>(
|
||||
`/v1/modules/${moduleId}/as-entries`,
|
||||
)
|
||||
return { asns: entries }
|
||||
}
|
||||
if (moduleType === 'CDN_CIDRS') {
|
||||
const entries = await apiPageAll<CdnSourcesResponse['items'][number]>(
|
||||
`/v1/modules/${moduleId}/cdn-sources`,
|
||||
)
|
||||
return { cdnSources: entries }
|
||||
}
|
||||
if (moduleType === 'IP_RANGES') {
|
||||
const entries = await apiPageAll<IpRangeEntriesResponse['items'][number]>(
|
||||
`/v1/modules/${moduleId}/ip-range-entries`,
|
||||
)
|
||||
return { ipRanges: entries }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
createRouter as tanstackCreateRouter,
|
||||
rootRouteId,
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
import { routeTree } from '../routeTree.gen'
|
||||
import { queryClient } from './queryClient'
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof createRouter>
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouter(opts?: { context?: { queryClient: QueryClient } }) {
|
||||
return tanstackCreateRouter({
|
||||
routeTree,
|
||||
context: opts?.context ?? { queryClient },
|
||||
defaultPreload: 'intent',
|
||||
scrollRestoration: true,
|
||||
})
|
||||
}
|
||||
|
||||
export { rootRouteId }
|
||||
@@ -0,0 +1,27 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { Toaster } from '@evobgp/ui/components/sonner'
|
||||
|
||||
import '@evobgp/ui/globals.css'
|
||||
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import { createRouter } from '@/lib/router'
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
|
||||
const router = createRouter({ context: { queryClient } })
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (!rootEl) throw new Error('Root element #root not found')
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster richColors position="top-right" />
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type { ApiKey, ApiKeysResponse } from '@/types/api'
|
||||
|
||||
export const apiKeysKeys = {
|
||||
all: ['api-keys'] as const,
|
||||
list: () => [...apiKeysKeys.all, 'list'] as const,
|
||||
}
|
||||
|
||||
export function apiKeysQueryOptions() {
|
||||
return queryOptions<ApiKey[]>({
|
||||
queryKey: apiKeysKeys.list(),
|
||||
queryFn: async () => {
|
||||
const page = await apiJSON<ApiKeysResponse>('/v1/api-keys?limit=500')
|
||||
return page.items ?? []
|
||||
},
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type { AuthSession } from '@/types/api'
|
||||
|
||||
export const authKeys = {
|
||||
all: ['auth'] as const,
|
||||
session: () => [...authKeys.all, 'session'] as const,
|
||||
}
|
||||
|
||||
export function authSessionQueryOptions() {
|
||||
return queryOptions<AuthSession>({
|
||||
queryKey: authKeys.session(),
|
||||
queryFn: () => apiJSON<AuthSession>('/v1/auth/session'),
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type { CommunitiesResponse, DohProfilesResponse } from '@/types/api'
|
||||
|
||||
export const directoriesKeys = {
|
||||
all: ['directories'] as const,
|
||||
communities: () => [...directoriesKeys.all, 'communities'] as const,
|
||||
doh: () => [...directoriesKeys.all, 'doh'] as const,
|
||||
}
|
||||
|
||||
export function directoriesCommunitiesQueryOptions() {
|
||||
return queryOptions<CommunitiesResponse>({
|
||||
queryKey: directoriesKeys.communities(),
|
||||
queryFn: () => apiJSON<CommunitiesResponse>('/v1/communities?limit=200'),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function directoriesDohQueryOptions() {
|
||||
return queryOptions<DohProfilesResponse>({
|
||||
queryKey: directoriesKeys.doh(),
|
||||
queryFn: () => apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200'),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type {
|
||||
ModuleRow,
|
||||
ModulesResponse,
|
||||
Page,
|
||||
} from '@/types/api'
|
||||
|
||||
export const modulesKeys = {
|
||||
all: ['modules'] as const,
|
||||
list: () => [...modulesKeys.all, 'list'] as const,
|
||||
detail: (id: string) => [...modulesKeys.all, 'detail', id] as const,
|
||||
domainEntries: (id: string) => [...modulesKeys.all, 'domain-entries', id] as const,
|
||||
asEntries: (id: string) => [...modulesKeys.all, 'as-entries', id] as const,
|
||||
cdnSources: (id: string) => [...modulesKeys.all, 'cdn-sources', id] as const,
|
||||
ipRangeEntries: (id: string) => [...modulesKeys.all, 'ip-range-entries', id] as const,
|
||||
}
|
||||
|
||||
export function modulesListQueryOptions() {
|
||||
return queryOptions<ModulesResponse>({
|
||||
queryKey: modulesKeys.list(),
|
||||
queryFn: () => apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
||||
})
|
||||
}
|
||||
|
||||
export function moduleDetailQueryOptions(id: string) {
|
||||
return queryOptions<ModuleRow>({
|
||||
queryKey: modulesKeys.detail(id),
|
||||
queryFn: () => apiJSON<ModuleRow>(`/v1/modules/${id}`),
|
||||
})
|
||||
}
|
||||
|
||||
export type ModuleEntriesPage = Page<Record<string, unknown>>
|
||||
|
||||
export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
|
||||
const pathByType: Record<ModuleRow['type'], string> = {
|
||||
DOMAINS: `/v1/modules/${id}/domain-entries?limit=500`,
|
||||
AS_PREFIXES: `/v1/modules/${id}/as-entries?limit=500`,
|
||||
CDN_CIDRS: `/v1/modules/${id}/cdn-sources?limit=500`,
|
||||
IP_RANGES: `/v1/modules/${id}/ip-range-entries?limit=500`,
|
||||
}
|
||||
const path = pathByType[type]
|
||||
const keyByType: Record<ModuleRow['type'], readonly string[]> = {
|
||||
DOMAINS: modulesKeys.domainEntries(id),
|
||||
AS_PREFIXES: modulesKeys.asEntries(id),
|
||||
CDN_CIDRS: modulesKeys.cdnSources(id),
|
||||
IP_RANGES: modulesKeys.ipRangeEntries(id),
|
||||
}
|
||||
return queryOptions<ModuleEntriesPage>({
|
||||
queryKey: keyByType[type],
|
||||
queryFn: () => apiJSON<ModuleEntriesPage>(path),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiFetch, apiJSON, apiMutate } from '@/lib/api-client'
|
||||
|
||||
export interface HealthStatus {
|
||||
ok: boolean
|
||||
status?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ReadyStatus {
|
||||
status?: string
|
||||
checks?: Record<string, boolean | { ok?: boolean; error?: string }>
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
version?: string
|
||||
app?: string
|
||||
git_sha?: string
|
||||
build_time?: string
|
||||
}
|
||||
|
||||
export const monitoringKeys = {
|
||||
all: ['monitoring'] as const,
|
||||
health: () => [...monitoringKeys.all, 'health'] as const,
|
||||
ready: () => [...monitoringKeys.all, 'ready'] as const,
|
||||
version: () => [...monitoringKeys.all, 'version'] as const,
|
||||
}
|
||||
|
||||
async function fetchHealth(): Promise<HealthStatus> {
|
||||
const res = await apiFetch('/v1/health', { method: 'GET' })
|
||||
let body: { status?: string } = {}
|
||||
try {
|
||||
body = (await res.json()) as { status?: string }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { ok: res.ok, status: body.status, error: res.ok ? undefined : `HTTP ${res.status}` }
|
||||
}
|
||||
|
||||
export function monitoringHealthQueryOptions() {
|
||||
return queryOptions<HealthStatus>({
|
||||
queryKey: monitoringKeys.health(),
|
||||
queryFn: fetchHealth,
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function monitoringReadyQueryOptions() {
|
||||
return queryOptions<ReadyStatus>({
|
||||
queryKey: monitoringKeys.ready(),
|
||||
queryFn: () => apiJSON<ReadyStatus>('/v1/ready'),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function monitoringVersionQueryOptions() {
|
||||
return queryOptions<VersionInfo>({
|
||||
queryKey: monitoringKeys.version(),
|
||||
queryFn: () => apiJSON<VersionInfo>('/v1/version'),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchLog(endpoint: string): Promise<string> {
|
||||
const res = await apiFetch(endpoint, { method: 'GET' })
|
||||
return await res.text()
|
||||
}
|
||||
|
||||
export async function clearLog(endpoint: string): Promise<void> {
|
||||
await apiMutate(endpoint, 'DELETE', {})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type { BirdStatus, PeersResponse, SpeakersResponse } from '@/types/api'
|
||||
|
||||
export const NETWORK_AUTO_REFRESH_MS = 30_000
|
||||
|
||||
export const networkKeys = {
|
||||
all: ['network'] as const,
|
||||
peers: () => [...networkKeys.all, 'peers'] as const,
|
||||
speakers: () => [...networkKeys.all, 'speakers'] as const,
|
||||
bird: () => [...networkKeys.all, 'bird'] as const,
|
||||
}
|
||||
|
||||
export function networkPeersQueryOptions() {
|
||||
return queryOptions<PeersResponse>({
|
||||
queryKey: networkKeys.peers(),
|
||||
queryFn: () => apiJSON<PeersResponse>('/v1/peers?limit=200&live=1'),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function networkSpeakersQueryOptions() {
|
||||
return queryOptions<SpeakersResponse>({
|
||||
queryKey: networkKeys.speakers(),
|
||||
queryFn: () => apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1'),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function networkBirdQueryOptions() {
|
||||
return queryOptions<BirdStatus>({
|
||||
queryKey: networkKeys.bird(),
|
||||
queryFn: () => apiJSON<BirdStatus>('/v1/bird/status'),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type { JobsResponse, RevisionsResponse, RevisionDiff } from '@/types/api'
|
||||
|
||||
export const operationsKeys = {
|
||||
all: ['operations'] as const,
|
||||
revisions: () => [...operationsKeys.all, 'revisions'] as const,
|
||||
jobs: (params?: { status?: string; kind?: string }) =>
|
||||
[...operationsKeys.all, 'jobs', params ?? {}] as const,
|
||||
diff: (a: string, b: string) => [...operationsKeys.all, 'diff', a, b] as const,
|
||||
}
|
||||
|
||||
export function operationsRevisionsQueryOptions() {
|
||||
return queryOptions<RevisionsResponse>({
|
||||
queryKey: operationsKeys.revisions(),
|
||||
queryFn: () => apiJSON<RevisionsResponse>('/v1/revisions?limit=100'),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function operationsJobsQueryOptions(params?: { status?: string; kind?: string }) {
|
||||
return queryOptions<JobsResponse>({
|
||||
queryKey: operationsKeys.jobs(params),
|
||||
queryFn: () => {
|
||||
const sp = new URLSearchParams({ limit: '200' })
|
||||
if (params?.status) sp.set('status', params.status)
|
||||
if (params?.kind) sp.set('kind', params.kind)
|
||||
return apiJSON<JobsResponse>(`/v1/jobs?${sp.toString()}`)
|
||||
},
|
||||
staleTime: 10_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function operationsDiffQueryOptions(a: string, b: string) {
|
||||
return queryOptions<RevisionDiff>({
|
||||
queryKey: operationsKeys.diff(a, b),
|
||||
queryFn: () => apiJSON<RevisionDiff>(`/v1/revisions/${a}/diff/${b}`),
|
||||
enabled: Boolean(a) && Boolean(b),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type {
|
||||
JobRow,
|
||||
JobsResponse,
|
||||
ModuleRow,
|
||||
ModulesResponse,
|
||||
PeerRow,
|
||||
PeersResponse,
|
||||
RevisionRow,
|
||||
RevisionsResponse,
|
||||
SpeakerRow,
|
||||
SpeakersResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
export const overviewKeys = {
|
||||
all: ['overview'] as const,
|
||||
modules: () => [...overviewKeys.all, 'modules'] as const,
|
||||
peers: () => [...overviewKeys.all, 'peers'] as const,
|
||||
speakers: () => [...overviewKeys.all, 'speakers'] as const,
|
||||
revisions: () => [...overviewKeys.all, 'revisions'] as const,
|
||||
jobs: () => [...overviewKeys.all, 'jobs'] as const,
|
||||
health: () => [...overviewKeys.all, 'health'] as const,
|
||||
}
|
||||
|
||||
export function overviewModulesQueryOptions() {
|
||||
return queryOptions<ModulesResponse>({
|
||||
queryKey: overviewKeys.modules(),
|
||||
queryFn: () => apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function overviewPeersQueryOptions() {
|
||||
return queryOptions<PeersResponse>({
|
||||
queryKey: overviewKeys.peers(),
|
||||
queryFn: () => apiJSON<PeersResponse>('/v1/peers?limit=200&live=1'),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function overviewSpeakersQueryOptions() {
|
||||
return queryOptions<SpeakersResponse>({
|
||||
queryKey: overviewKeys.speakers(),
|
||||
queryFn: () => apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1'),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function overviewRevisionsQueryOptions() {
|
||||
return queryOptions<RevisionsResponse>({
|
||||
queryKey: overviewKeys.revisions(),
|
||||
queryFn: () => apiJSON<RevisionsResponse>('/v1/revisions?limit=10'),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function overviewJobsQueryOptions() {
|
||||
return queryOptions<JobsResponse>({
|
||||
queryKey: overviewKeys.jobs(),
|
||||
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=10'),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function overviewHealthQueryOptions() {
|
||||
return queryOptions<boolean>({
|
||||
queryKey: overviewKeys.health(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/v1/health')
|
||||
return res.ok
|
||||
},
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
// Selectors / helpers
|
||||
export type NetworkMetrics = {
|
||||
peersTotal: number
|
||||
peersEnabled: number
|
||||
peersEstablished: number
|
||||
peersMismatch: number
|
||||
speakersTotal: number
|
||||
speakersOnline: number
|
||||
}
|
||||
|
||||
export function aggregateNetworkMetrics(
|
||||
peers: PeerRow[],
|
||||
speakers: SpeakerRow[],
|
||||
): NetworkMetrics {
|
||||
const peersEnabled = peers.filter((p) => p.enabled !== false).length
|
||||
const peersEstablished = peers.filter((p) => p.session_state === 'Established').length
|
||||
const peersMismatch = peers.filter((p) => p.session_mismatch).length
|
||||
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
|
||||
return {
|
||||
peersTotal: peers.length,
|
||||
peersEnabled,
|
||||
peersEstablished,
|
||||
peersMismatch,
|
||||
speakersTotal: speakers.length,
|
||||
speakersOnline,
|
||||
}
|
||||
}
|
||||
|
||||
export function runningJobCount(jobs: JobRow[]): number {
|
||||
return jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
}
|
||||
|
||||
export function moduleNameById(modules: ModuleRow[]): Map<string, string> {
|
||||
return new Map(modules.map((m) => [m.id, m.name]))
|
||||
}
|
||||
|
||||
export function recentRevisions(revisions: RevisionRow[], n = 10): RevisionRow[] {
|
||||
return revisions.slice(0, n)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
|
||||
export type AppSettings = Record<string, unknown>
|
||||
|
||||
export const BIRD_SETTING_KEYS = [
|
||||
'bird_router_id',
|
||||
'bird_local_ipv4',
|
||||
'bird_local_ipv6',
|
||||
'bird_local_asn',
|
||||
'bird_bgp_source_ipv4',
|
||||
'bird_bgp_source_ipv6',
|
||||
] as const
|
||||
|
||||
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const
|
||||
|
||||
export const RUNTIME_LOGS_SETTING_KEYS = [
|
||||
'runtime_logs_auto_enabled',
|
||||
'runtime_logs_max_file_mb',
|
||||
'runtime_logs_auto_schedule',
|
||||
'runtime_logs_auto_mode',
|
||||
] as const
|
||||
|
||||
export const KNOWN_SETTING_KEYS = [
|
||||
...BIRD_SETTING_KEYS,
|
||||
...REVISION_SETTING_KEYS,
|
||||
...RUNTIME_LOGS_SETTING_KEYS,
|
||||
] as const
|
||||
|
||||
export type KnownSettingKey = (typeof KNOWN_SETTING_KEYS)[number]
|
||||
export type BirdSettingKey = (typeof BIRD_SETTING_KEYS)[number]
|
||||
export type RevisionSettingKey = (typeof REVISION_SETTING_KEYS)[number]
|
||||
export type RuntimeLogsSettingKey = (typeof RUNTIME_LOGS_SETTING_KEYS)[number]
|
||||
|
||||
export const NUMERIC_SETTING_KEYS = new Set<KnownSettingKey>([
|
||||
'bird_local_asn',
|
||||
'revision_retention_minutes',
|
||||
'runtime_logs_max_file_mb',
|
||||
])
|
||||
|
||||
export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>(['runtime_logs_auto_enabled'])
|
||||
|
||||
export const settingsKeys = {
|
||||
all: ['settings'] as const,
|
||||
}
|
||||
|
||||
export function settingsQueryOptions() {
|
||||
return queryOptions<AppSettings>({
|
||||
queryKey: settingsKeys.all,
|
||||
queryFn: () => apiJSON<AppSettings>('/v1/settings'),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function parseKnownValue(key: KnownSettingKey, value: unknown): string {
|
||||
if (NUMERIC_SETTING_KEYS.has(key)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
if (typeof value === 'string') return value
|
||||
return ''
|
||||
}
|
||||
if (typeof value === 'string') return value
|
||||
return ''
|
||||
}
|
||||
|
||||
export interface PartitionedSettings {
|
||||
bird: Partial<Record<BirdSettingKey, string>>
|
||||
revision: Partial<Record<RevisionSettingKey, string>>
|
||||
runtimeLogs: Partial<Record<RuntimeLogsSettingKey, string>>
|
||||
additional: { id: number; key: string; value: string }[]
|
||||
}
|
||||
|
||||
export function partitionSettings(settings: AppSettings): PartitionedSettings {
|
||||
const bird: Partial<Record<BirdSettingKey, string>> = {}
|
||||
const revision: Partial<Record<RevisionSettingKey, string>> = {}
|
||||
const runtimeLogs: Partial<Record<RuntimeLogsSettingKey, string>> = {}
|
||||
const additional: { id: number; key: string; value: string }[] = []
|
||||
let id = 1
|
||||
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if ((BIRD_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||
bird[key as BirdSettingKey] = parseKnownValue(key as KnownSettingKey, value)
|
||||
} else if (key === 'revision_retention_minutes') {
|
||||
revision.revision_retention_minutes = parseKnownValue(
|
||||
key as RevisionSettingKey,
|
||||
value,
|
||||
)
|
||||
} else if ((RUNTIME_LOGS_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||
const rk = key as RuntimeLogsSettingKey
|
||||
if (rk === 'runtime_logs_auto_enabled') {
|
||||
runtimeLogs.runtime_logs_auto_enabled =
|
||||
value === true || value === 1 || value === 'true' || value === '1'
|
||||
? 'true'
|
||||
: 'false'
|
||||
} else if (rk === 'runtime_logs_auto_mode') {
|
||||
const m = String(value ?? '').trim()
|
||||
runtimeLogs.runtime_logs_auto_mode = m === 'delete' ? 'delete' : m === 'truncate' ? 'truncate' : ''
|
||||
} else {
|
||||
runtimeLogs[rk] = parseKnownValue(rk, value)
|
||||
}
|
||||
} else {
|
||||
additional.push({
|
||||
id: id++,
|
||||
key,
|
||||
value: typeof value === 'string' ? value : String(value),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { bird, revision, runtimeLogs, additional }
|
||||
}
|
||||
|
||||
export function buildPayload(
|
||||
keys: readonly KnownSettingKey[],
|
||||
form: Record<string, string>,
|
||||
): Record<string, string | number | boolean> {
|
||||
const payload: Record<string, string | number | boolean> = {}
|
||||
for (const key of keys) {
|
||||
const value = String(form[key] ?? '').trim()
|
||||
if (BOOLEAN_SETTING_KEYS.has(key)) {
|
||||
payload[key] = value === 'true'
|
||||
continue
|
||||
}
|
||||
if (!value) continue
|
||||
if (NUMERIC_SETTING_KEYS.has(key)) payload[key] = Number(value)
|
||||
else payload[key] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||
import { AppShell } from '@/components/layout/app-shell'
|
||||
|
||||
interface RouterContext {
|
||||
queryClient: import('@tanstack/react-query').QueryClient
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth')({
|
||||
beforeLoad: () => {
|
||||
const token =
|
||||
typeof window !== 'undefined' ? window.localStorage.getItem('evobgp_api_token') : null
|
||||
if (!token) {
|
||||
throw redirect({ to: '/settings' })
|
||||
}
|
||||
},
|
||||
component: AuthLayout,
|
||||
})
|
||||
|
||||
function AuthLayout() {
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { authSessionQueryOptions } from '@/queries/auth'
|
||||
import { apiKeysQueryOptions } from '@/queries/api-keys'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import type { ApiKeyCreated } from '@/types/api'
|
||||
import { useState } from 'react'
|
||||
import { Copy } from 'lucide-react'
|
||||
|
||||
export const Route = createFileRoute('/_auth/access')({
|
||||
component: AccessComponent,
|
||||
})
|
||||
|
||||
function AccessComponent() {
|
||||
const sessionQuery = useQuery(authSessionQueryOptions())
|
||||
const session = sessionQuery.data ?? null
|
||||
const isOperator = session?.role === 'operator'
|
||||
|
||||
const keysQuery = useQuery({
|
||||
...apiKeysQueryOptions(),
|
||||
enabled: isOperator,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Права доступа"
|
||||
description="API-ключи control plane и текущая сессия Bearer-токена."
|
||||
/>
|
||||
|
||||
{session ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Текущая сессия</CardTitle>
|
||||
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Tenant</p>
|
||||
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Роль</p>
|
||||
<p className="font-mono">{session.role}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isOperator ? (
|
||||
<ApiKeysCard
|
||||
items={keysQuery.data ?? []}
|
||||
isLoading={keysQuery.isLoading}
|
||||
isError={keysQuery.isError}
|
||||
error={keysQuery.error}
|
||||
onRetry={() => keysQuery.refetch()}
|
||||
/>
|
||||
) : session ? (
|
||||
<Card>
|
||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
|
||||
<span className="font-mono">{session.role}</span>.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeysCard({
|
||||
items,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
items: import('@/types/api').ApiKey[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
}) {
|
||||
const [revealedToken, setRevealedToken] = useState<string | null>(null)
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }),
|
||||
onSuccess: () => toast.success('Ключ отозван'),
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'),
|
||||
})
|
||||
|
||||
const rotate = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiMutate<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
|
||||
idempotent: false,
|
||||
}),
|
||||
onSuccess: (created) => {
|
||||
toast.success('Ключ ротирован')
|
||||
setRevealedToken(created.token)
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
|
||||
})
|
||||
|
||||
async function copyToken() {
|
||||
if (!revealedToken) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(revealedToken)
|
||||
toast.success('Скопировано')
|
||||
} catch {
|
||||
toast.error('Не удалось скопировать')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base">API-ключи</CardTitle>
|
||||
<CardDescription>
|
||||
Управление ключами tenant. Полный токен показывается только при создании и ротации.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
|
||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет ключей"
|
||||
emptyDescription="Ключи можно создать через API."
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Роль</TableHead>
|
||||
<TableHead>Префикс</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((k) => (
|
||||
<TableRow key={k.id}>
|
||||
<TableCell className="font-medium">{k.name}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{k.role}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{k.prefix}…</TableCell>
|
||||
<TableCell>
|
||||
{k.revoked_at ? (
|
||||
<span className="text-sm text-destructive">отозван</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">активен</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Ротировать"
|
||||
disabled={!!k.revoked_at}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Ротировать ключ?"
|
||||
description="Старый токен перестанет работать сразу."
|
||||
confirmLabel="Ротировать"
|
||||
onConfirm={() => rotate.mutate(k.id)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive"
|
||||
disabled={!!k.revoked_at}
|
||||
title="Отозвать"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Отозвать API-ключ?"
|
||||
description={`${k.name} (${k.prefix}…)`}
|
||||
confirmLabel="Отозвать"
|
||||
destructive
|
||||
onConfirm={() => revoke.mutate(k.id)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
|
||||
{revealedToken ? (
|
||||
<div className="flex flex-col gap-3 border-t p-4">
|
||||
<div className="text-sm font-medium">Новый токен (сохраните сейчас):</div>
|
||||
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||
{revealedToken}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={copyToken}>
|
||||
<Copy /> Копировать
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setRevealedToken(null)}>
|
||||
Готово
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import {
|
||||
Boxes,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
GitBranch,
|
||||
Info,
|
||||
Network,
|
||||
Play,
|
||||
Plus,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Activity,
|
||||
Share2,
|
||||
Tags,
|
||||
Gauge,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
|
||||
import {
|
||||
aggregateNetworkMetrics,
|
||||
moduleNameById,
|
||||
overviewHealthQueryOptions,
|
||||
overviewJobsQueryOptions,
|
||||
overviewModulesQueryOptions,
|
||||
overviewPeersQueryOptions,
|
||||
overviewRevisionsQueryOptions,
|
||||
overviewSpeakersQueryOptions,
|
||||
runningJobCount,
|
||||
} from '@/queries/overview'
|
||||
|
||||
export const Route = createFileRoute('/_auth/dashboard')({
|
||||
component: DashboardComponent,
|
||||
})
|
||||
|
||||
function DashboardComponent() {
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
|
||||
const results = useQueries({
|
||||
queries: [
|
||||
overviewHealthQueryOptions(),
|
||||
overviewModulesQueryOptions(),
|
||||
overviewPeersQueryOptions(),
|
||||
overviewSpeakersQueryOptions(),
|
||||
overviewRevisionsQueryOptions(),
|
||||
overviewJobsQueryOptions(),
|
||||
],
|
||||
})
|
||||
|
||||
const [healthQ, modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
|
||||
const initialLoading =
|
||||
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading
|
||||
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
|
||||
|
||||
if (!lastUpdated && !initialLoading && results.every((r) => r.isSuccess || r.isError)) {
|
||||
setTimeout(() => setLastUpdated(new Date()), 0)
|
||||
}
|
||||
|
||||
function refetchAll() {
|
||||
setLastUpdated(null)
|
||||
results.forEach((r) => r.refetch())
|
||||
}
|
||||
|
||||
const modules = modulesQ.data?.items ?? []
|
||||
const peers = peersQ.data?.items ?? []
|
||||
const speakers = speakersQ.data?.items ?? []
|
||||
const revisions = revisionsQ.data?.items ?? []
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const modulesHasMore = modulesQ.data?.has_more ?? false
|
||||
const peersHasMore = peersQ.data?.has_more ?? false
|
||||
const speakersHasMore = speakersQ.data?.has_more ?? false
|
||||
const revisionsHasMore = revisionsQ.data?.has_more ?? false
|
||||
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
const running = runningJobCount(jobs)
|
||||
const nameById = moduleNameById(modules)
|
||||
|
||||
const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix)
|
||||
|
||||
const items: SectionCardItem[] = [
|
||||
{
|
||||
label: 'Модули',
|
||||
value: initialLoading ? '—' : String(modules.length),
|
||||
icon: <Boxes className="size-4" />,
|
||||
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
|
||||
onClick: () => window.location.assign('/modules'),
|
||||
},
|
||||
{
|
||||
label: 'Пиры',
|
||||
value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
icon: <GitBranch className="size-4" />,
|
||||
hint: countBadge(peers.length, peersHasMore, 'Established / включённых'),
|
||||
badge: net.peersMismatch > 0 ? `mismatch ${net.peersMismatch}` : undefined,
|
||||
variant: net.peersMismatch > 0 ? 'warning' : 'default',
|
||||
onClick: () => window.location.assign('/network?tab=peers'),
|
||||
},
|
||||
{
|
||||
label: 'Спикеры',
|
||||
value: initialLoading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
icon: <Radio className="size-4" />,
|
||||
hint: countBadge(speakers.length, speakersHasMore, 'online / всего'),
|
||||
variant: net.speakersOnline < net.speakersTotal ? 'warning' : 'default',
|
||||
onClick: () => window.location.assign('/network?tab=overview'),
|
||||
},
|
||||
{
|
||||
label: 'Ревизии',
|
||||
value: initialLoading ? '—' : String(revisions.length),
|
||||
icon: <Activity className="size-4" />,
|
||||
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
|
||||
onClick: () => window.location.assign('/operations'),
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: initialLoading ? '—' : String(running),
|
||||
icon: <Clock className="size-4" />,
|
||||
hint: 'queued и running',
|
||||
onClick: () => window.location.assign('/operations?tab=jobs'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Обзор"
|
||||
description={
|
||||
lastUpdated
|
||||
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Состояние панели управления EvoBGP.'
|
||||
}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Панель управления EvoBGP</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сводка по модулям, сети и фоновым задачам. BGP и ноды — «Сеть», префиксы — «Модули»,
|
||||
деплой — «Операции», здоровье API — «Мониторинг».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<HealthAlert
|
||||
loading={healthQ.isLoading}
|
||||
ok={healthQ.data === true}
|
||||
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
|
||||
/>
|
||||
|
||||
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} />}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<RecentJobsCard jobs={jobs} nameById={nameById} loading={refreshing} />
|
||||
<RecentRevisionsCard revisions={revisions} loading={refreshing} />
|
||||
<NetworkStatusCard peers={peers} speakers={speakers} loading={refreshing} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Быстрые действия</CardTitle>
|
||||
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2 p-4">
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
|
||||
<Plus className="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/directories')}>
|
||||
<Tags className="size-4" />
|
||||
Добавить community
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=overview')}>
|
||||
<Network className="size-4" />
|
||||
Сеть
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=peers')}>
|
||||
<Share2 className="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/operations')}>
|
||||
<Play className="size-4" />
|
||||
Деплой (Apply)
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/monitoring')}>
|
||||
<Gauge className="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthAlert({
|
||||
loading,
|
||||
ok,
|
||||
loadError,
|
||||
}: {
|
||||
loading: boolean
|
||||
ok: boolean | undefined
|
||||
loadError: string | null
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Alert>
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<AlertTitle>Проверка API…</AlertTitle>
|
||||
<AlertDescription>
|
||||
Запрос к <code className="text-xs">/v1/health</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
if (ok && !loadError) {
|
||||
return (
|
||||
<Alert className="border-success/30 bg-success/5">
|
||||
<CheckCircle className="text-success" />
|
||||
<AlertTitle>API работает</AlertTitle>
|
||||
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
if (ok && loadError) {
|
||||
return (
|
||||
<Alert className="border-warning/30 bg-warning/5">
|
||||
<Info className="text-warning" />
|
||||
<AlertTitle>API доступен, данные не загружены</AlertTitle>
|
||||
<AlertDescription>{loadError}. Проверьте Bearer-токен в «Настройках».</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Alert variant="destructive" className="border-destructive/30 bg-destructive/5">
|
||||
<XCircle className="text-destructive" />
|
||||
<AlertTitle>API недоступен</AlertTitle>
|
||||
<AlertDescription>
|
||||
Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080) и в dev работает
|
||||
прокси Vite.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentJobsCard({
|
||||
jobs,
|
||||
nameById,
|
||||
loading,
|
||||
}: {
|
||||
jobs: import('@/types/api').JobRow[]
|
||||
nameById: Map<string, string>
|
||||
loading: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Недавние задачи</CardTitle>
|
||||
<CardDescription>Последние фоновые операции</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{loading && jobs.length === 0 ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : jobs.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет задач</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{jobs.slice(0, 8).map((j) => (
|
||||
<li
|
||||
key={j.job_id}
|
||||
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">{j.kind}</span>
|
||||
<span className="truncate text-xs">
|
||||
{j.meta?.module_id ? nameById.get(String(j.meta.module_id)) ?? '' : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
j.status === 'succeeded'
|
||||
? 'text-xs text-success'
|
||||
: j.status === 'failed'
|
||||
? 'text-xs text-destructive'
|
||||
: 'text-xs text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{j.status}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentRevisionsCard({
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
revisions: import('@/types/api').RevisionRow[]
|
||||
loading: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Последние ревизии</CardTitle>
|
||||
<CardDescription>История конфигураций</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{loading && revisions.length === 0 ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : revisions.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет ревизий</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{revisions.slice(0, 8).map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">{r.id.slice(0, 10)}…</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(r.created_at).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function NetworkStatusCard({
|
||||
peers,
|
||||
speakers,
|
||||
loading,
|
||||
}: {
|
||||
peers: import('@/types/api').PeerRow[]
|
||||
speakers: import('@/types/api').SpeakerRow[]
|
||||
loading: boolean
|
||||
}) {
|
||||
const m = aggregateNetworkMetrics(peers, speakers)
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Состояние сети</CardTitle>
|
||||
<CardDescription>BGP-сессии и спикеры</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{loading && peers.length === 0 && speakers.length === 0 ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 px-1 py-1 text-sm">
|
||||
<Row label="Пиры Established" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
|
||||
<Row label="Спикеры online" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
|
||||
{m.peersMismatch > 0 ? (
|
||||
<Row label="Mismatches" value={String(m.peersMismatch)} variant="warning" />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
variant = 'default',
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
variant?: 'default' | 'warning'
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className={variant === 'warning' ? 'font-medium text-warning-foreground' : 'font-medium tabular-nums'}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||
import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories'
|
||||
|
||||
export const Route = createFileRoute('/_auth/directories')({
|
||||
component: DirectoriesComponent,
|
||||
})
|
||||
|
||||
function DirectoriesComponent() {
|
||||
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||
const dohQ = useQuery(directoriesDohQueryOptions())
|
||||
|
||||
const communities = communitiesQ.data?.items ?? []
|
||||
const dohProfiles = dohQ.data?.items ?? []
|
||||
const loading = communitiesQ.isLoading || dohQ.isLoading
|
||||
|
||||
const items: SectionCardItem[] = [
|
||||
{
|
||||
label: 'Сообщества BGP',
|
||||
value: communities.length,
|
||||
icon: <Tags className="size-4" />,
|
||||
hint: 'теги префиксов в AS- и CDN-модулях',
|
||||
},
|
||||
{
|
||||
label: 'DoH профили',
|
||||
value: dohProfiles.length,
|
||||
icon: <Globe className="size-4" />,
|
||||
hint: 'резолвинг доменных модулей',
|
||||
},
|
||||
{
|
||||
label: 'Справочники',
|
||||
value: 'Общие',
|
||||
icon: <BookText className="size-4" />,
|
||||
hint: 'используются всеми модулями tenant',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Справочники"
|
||||
description="Сообщества BGP и DoH-профили для резолвинга доменов"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void communitiesQ.refetch()
|
||||
void dohQ.refetch()
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>О справочниках</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили — в
|
||||
доменных модулях для DNS-over-HTTPS резолвинга.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||
|
||||
<Tabs defaultValue="communities">
|
||||
<TabsList>
|
||||
<TabsTrigger value="communities">Сообщества BGP</TabsTrigger>
|
||||
<TabsTrigger value="doh">DoH профили</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="communities" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Сообщества BGP</CardTitle>
|
||||
<CardDescription>Теги для префиксов в фильтрах BIRD</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={communities}
|
||||
isLoading={communitiesQ.isLoading}
|
||||
isError={communitiesQ.isError}
|
||||
error={communitiesQ.error}
|
||||
empty={communities.length === 0}
|
||||
emptyTitle="Нет сообществ"
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => communitiesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>Значение</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.title}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{c.community}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">community</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="doh" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">DoH профили</CardTitle>
|
||||
<CardDescription>Резолверы DNS-over-HTTPS для доменных модулей</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={dohProfiles}
|
||||
isLoading={dohQ.isLoading}
|
||||
isError={dohQ.isError}
|
||||
error={dohQ.error}
|
||||
empty={dohProfiles.length === 0}
|
||||
emptyTitle="Нет DoH профилей"
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => dohQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>По умолчанию</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name ?? p.url}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.url}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">—</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { moduleDetailQueryOptions, moduleEntriesQueryOptions } from '@/queries/modules'
|
||||
|
||||
export const Route = createFileRoute('/_auth/modules/$moduleId')({
|
||||
component: ModuleDetailComponent,
|
||||
})
|
||||
|
||||
function ModuleDetailComponent() {
|
||||
const { moduleId } = Route.useParams()
|
||||
const detail = useQuery(moduleDetailQueryOptions(moduleId))
|
||||
const mod = detail.data
|
||||
|
||||
const entriesQuery = useQuery({
|
||||
...moduleEntriesQueryOptions(moduleId, mod?.type ?? 'DOMAINS'),
|
||||
enabled: !!mod,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title={mod?.name ?? moduleId}
|
||||
description={mod ? `Тип: ${mod.type}` : 'Загрузка модуля…'}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
|
||||
<ArrowLeft />
|
||||
К списку
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void detail.refetch()
|
||||
void entriesQuery.refetch()
|
||||
}}
|
||||
disabled={detail.isFetching}
|
||||
>
|
||||
<RefreshCw className={detail.isFetching ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<QueryState
|
||||
data={mod}
|
||||
isLoading={detail.isLoading}
|
||||
isError={detail.isError}
|
||||
error={detail.error}
|
||||
skeleton={<TableSkeleton rows={4} cols={2} />}
|
||||
onRetry={() => detail.refetch()}
|
||||
>
|
||||
{(m) => (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Параметры</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
|
||||
<Field label="ID" value={<code className="font-mono text-xs">{m.id}</code>} />
|
||||
<Field label="Тип" value={<Badge variant="outline">{m.type}</Badge>} />
|
||||
<Field label="Приоритет" value={<span className="font-mono">{m.priority}</span>} />
|
||||
<Field
|
||||
label="Состояние"
|
||||
value={
|
||||
m.enabled ? (
|
||||
<StatusBadge status="active" label="включён" />
|
||||
) : (
|
||||
<StatusBadge status="paused" label="выключен" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Интервал"
|
||||
value={
|
||||
m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—'
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Cron"
|
||||
value={m.cron_expr ? <code className="font-mono text-xs">{m.cron_expr}</code> : '—'}
|
||||
/>
|
||||
<Field
|
||||
label="Последний рефреш"
|
||||
value={
|
||||
m.last_refreshed_at
|
||||
? new Date(m.last_refreshed_at).toLocaleString('ru-RU')
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Маршрутные списки</CardTitle>
|
||||
<CardDescription>Источник префиксов для модуля</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={entriesQuery.data?.items}
|
||||
isLoading={entriesQuery.isLoading}
|
||||
isError={entriesQuery.isError}
|
||||
error={entriesQuery.error}
|
||||
empty={(entriesQuery.data?.items?.length ?? 0) === 0}
|
||||
emptyTitle="Записей нет"
|
||||
emptyDescription="Добавьте записи через API или создание ревизии."
|
||||
skeleton={<TableSkeleton rows={5} cols={2} />}
|
||||
onRetry={() => entriesQuery.refetch()}
|
||||
>
|
||||
{(items) => <EntriesTable items={items} moduleType={m.type} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EntriesTable({
|
||||
items,
|
||||
moduleType,
|
||||
}: {
|
||||
items: Record<string, unknown>[]
|
||||
moduleType: string
|
||||
}) {
|
||||
const primary = ENTRY_PRIMARY_KEY[moduleType as keyof typeof ENTRY_PRIMARY_KEY] ?? 'id'
|
||||
const secondary = ENTRY_SECONDARY_KEY[moduleType as keyof typeof ENTRY_SECONDARY_KEY] ?? null
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{primary}</TableHead>
|
||||
{secondary ? <TableHead>{secondary}</TableHead> : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, idx) => {
|
||||
const id = String(item.id ?? idx)
|
||||
const primaryVal = String(item[primary] ?? '—')
|
||||
return (
|
||||
<TableRow key={id}>
|
||||
<TableCell className="font-mono text-sm">{primaryVal}</TableCell>
|
||||
{secondary ? (
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{String(item[secondary] ?? '—')}
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const ENTRY_PRIMARY_KEY = {
|
||||
DOMAINS: 'fqdn',
|
||||
AS_PREFIXES: 'asn',
|
||||
CDN_CIDRS: 'url',
|
||||
IP_RANGES: 'prefix',
|
||||
} as const
|
||||
|
||||
const ENTRY_SECONDARY_KEY = {
|
||||
DOMAINS: 'community_id',
|
||||
AS_PREFIXES: 'community_id',
|
||||
CDN_CIDRS: 'community_id',
|
||||
IP_RANGES: 'community_id',
|
||||
} as const
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Link, createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Boxes, Plus, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { modulesListQueryOptions } from '@/queries/modules'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
|
||||
export const Route = createFileRoute('/_auth/modules/')({
|
||||
component: ModulesListComponent,
|
||||
})
|
||||
|
||||
function ModulesListComponent() {
|
||||
const query = useQuery(modulesListQueryOptions())
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Модули"
|
||||
description="Маршрутные списки: AS, CDN, домены, IP-диапазоны"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw className={query.isFetching ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b py-3">
|
||||
<CardTitle className="text-base">Все модули</CardTitle>
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={query.data?.items}
|
||||
isLoading={query.isLoading}
|
||||
isError={query.isError}
|
||||
error={query.error}
|
||||
empty={query.data?.items?.length === 0}
|
||||
emptyTitle="Нет модулей"
|
||||
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
|
||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||
onRetry={() => query.refetch()}
|
||||
>
|
||||
{(items) => <ModulesTable items={items} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModulesTable({ items }: { items: ModuleRow[] }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Приоритет</TableHead>
|
||||
<TableHead>Состояние</TableHead>
|
||||
<TableHead>Обновлено</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((m) => (
|
||||
<TableRow
|
||||
key={m.id}
|
||||
className="cursor-pointer hover:bg-muted/40"
|
||||
onClick={() => (window.location.href = `/modules/${m.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes className="size-4 text-muted-foreground" />
|
||||
<TruncatedText className="max-w-[280px]">{m.name}</TruncatedText>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{m.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm tabular-nums">{m.priority}</TableCell>
|
||||
<TableCell>
|
||||
{m.enabled ? (
|
||||
<Badge variant="success">включён</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">выключен</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
|
||||
export const Route = createFileRoute('/_auth/modules/new')({
|
||||
component: NewModuleComponent,
|
||||
})
|
||||
|
||||
function NewModuleComponent() {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Новый модуль"
|
||||
description="Создание модуля — через API или будущая форма"
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3 py-6 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Форма создания модуля будет добавлена позже. Сейчас модули можно создать через API:
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||
{`POST /v1/modules
|
||||
{ "type": "DOMAINS", "name": "Мой список" }`}
|
||||
</pre>
|
||||
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
|
||||
Назад к списку
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Activity, AlertTriangle, Bird, Database, Gauge, HardDrive, HeartPulse, Info, ListTodo, RefreshCw, ShieldCheck } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
|
||||
import {
|
||||
monitoringHealthQueryOptions,
|
||||
monitoringReadyQueryOptions,
|
||||
monitoringVersionQueryOptions,
|
||||
type ReadyStatus,
|
||||
type VersionInfo,
|
||||
} from '@/queries/monitoring'
|
||||
import { networkBirdQueryOptions } from '@/queries/network'
|
||||
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||
|
||||
export const Route = createFileRoute('/_auth/monitoring')({
|
||||
component: MonitoringComponent,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab === 'postgres' || search.tab === 'runtime-logs' ? search.tab : 'system') as
|
||||
| 'system'
|
||||
| 'postgres'
|
||||
| 'runtime-logs',
|
||||
}),
|
||||
})
|
||||
|
||||
function MonitoringComponent() {
|
||||
const search = useSearch({ from: '/_auth/monitoring' })
|
||||
const healthQ = useQuery(monitoringHealthQueryOptions())
|
||||
const readyQ = useQuery(monitoringReadyQueryOptions())
|
||||
const versionQ = useQuery(monitoringVersionQueryOptions())
|
||||
const birdQ = useQuery(networkBirdQueryOptions())
|
||||
const jobsQ = useQuery(operationsJobsQueryOptions())
|
||||
|
||||
const refreshing =
|
||||
healthQ.isFetching ||
|
||||
readyQ.isFetching ||
|
||||
versionQ.isFetching ||
|
||||
birdQ.isFetching ||
|
||||
jobsQ.isFetching
|
||||
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
const failed = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
|
||||
const versionText = formatVersion(versionQ.data)
|
||||
|
||||
const items: SectionCardItem[] = [
|
||||
{
|
||||
label: 'Общий статус',
|
||||
value: overallStatusLabel({ health: healthQ.data, ready: readyQ.data, jobsFailed: failed }),
|
||||
icon: <Gauge className="size-4" />,
|
||||
hint: overallHint({ health: healthQ.data, jobsFailed: failed }),
|
||||
},
|
||||
{
|
||||
label: 'BGP сессии',
|
||||
value: birdQ.data
|
||||
? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}`
|
||||
: '—',
|
||||
icon: <Bird className="size-4" />,
|
||||
hint: birdQ.data?.birdc_configured
|
||||
? 'Established / total на API-хосте'
|
||||
: 'birdc не настроен',
|
||||
},
|
||||
{
|
||||
label: 'Задачи',
|
||||
value: running,
|
||||
icon: <Activity className="size-4" />,
|
||||
hint: `активных из ${jobs.length}`,
|
||||
variant: failed > 0 ? 'warning' : 'default',
|
||||
},
|
||||
{
|
||||
label: 'Версия',
|
||||
value: versionText,
|
||||
icon: <Gauge className="size-4" />,
|
||||
hint: versionQ.data?.git_sha ?? versionQ.data?.build_time ?? 'GET /v1/version',
|
||||
},
|
||||
]
|
||||
|
||||
function refetchAll() {
|
||||
void healthQ.refetch()
|
||||
void readyQ.refetch()
|
||||
void versionQ.refetch()
|
||||
void birdQ.refetch()
|
||||
void jobsQ.refetch()
|
||||
}
|
||||
|
||||
const failedJobs = jobs
|
||||
.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||
.slice(0, 5)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Мониторинг"
|
||||
description="Состояние API, BGP и задач для диагностики инцидентов"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue={search.tab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="system">Система</TabsTrigger>
|
||||
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
|
||||
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="system" className="mt-4 flex flex-col gap-6">
|
||||
{refreshing ? <SectionCardsSkeleton count={4} /> : <SectionCards items={items} />}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Доступность и готовность</CardTitle>
|
||||
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<QueryState
|
||||
data={readyQ.data}
|
||||
isLoading={readyQ.isLoading}
|
||||
isError={readyQ.isError}
|
||||
error={readyQ.error}
|
||||
skeleton={<div className="h-40" />}
|
||||
onRetry={() => readyQ.refetch()}
|
||||
>
|
||||
{(ready) => <ReadyTable health={healthQ.data} ready={ready} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bird className="size-4" />
|
||||
BGP на API-хосте
|
||||
</CardTitle>
|
||||
<CardDescription>GET /v1/bird/status</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<QueryState
|
||||
data={birdQ.data}
|
||||
isLoading={birdQ.isLoading}
|
||||
isError={birdQ.isError}
|
||||
error={birdQ.error}
|
||||
skeleton={<div className="h-40" />}
|
||||
onRetry={() => birdQ.refetch()}
|
||||
>
|
||||
{(bird) => <BirdSummary bird={bird} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Activity className="size-4" />
|
||||
Задачи
|
||||
</CardTitle>
|
||||
<CardDescription>Последние 100 задач · GET /v1/jobs</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<Metric label="Активных" value={running} />
|
||||
<Metric
|
||||
label="С ошибками"
|
||||
value={failed}
|
||||
valueClass={failed > 0 ? 'text-warning' : 'text-success'}
|
||||
/>
|
||||
<Metric label="В выборке" value={jobs.length} />
|
||||
</div>
|
||||
<Separator />
|
||||
{failedJobs.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">Последние ошибки</p>
|
||||
<ul className="space-y-2">
|
||||
{failedJobs.map((job) => (
|
||||
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-medium">{job.kind}</p>
|
||||
<Badge variant="destructive">{job.status}</Badge>
|
||||
</div>
|
||||
{job.error ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{job.error.slice(0, 120)}
|
||||
{job.error.length > 120 ? '…' : ''}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Критичных сбоев в последних 100 задачах нет.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="size-4 text-muted-foreground" />
|
||||
Что проверять при деградации
|
||||
</CardTitle>
|
||||
<CardDescription>Короткая шпаргалка для triage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Alert>
|
||||
<HeartPulse className="size-4" />
|
||||
<AlertTitle>API недоступен</AlertTitle>
|
||||
<AlertDescription>
|
||||
Если <code className="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс
|
||||
API и его логи.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert>
|
||||
<Database className="size-4" />
|
||||
<AlertTitle>Readiness не «Готов»</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сначала <code className="text-xs">postgres</code>, затем{' '}
|
||||
<code className="text-xs">store</code> и <code className="text-xs">jobs</code> в checks.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert>
|
||||
<Bird className="size-4" />
|
||||
<AlertTitle>Низкий ratio BGP</AlertTitle>
|
||||
<AlertDescription>
|
||||
Проверьте <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert>
|
||||
<ListTodo className="size-4" />
|
||||
<AlertTitle>Ошибки задач</AlertTitle>
|
||||
<AlertDescription>
|
||||
Откройте Операции и проверьте последние неуспешные jobs.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="postgres" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">PostgreSQL</CardTitle>
|
||||
<CardDescription>Статус соединения и пул</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert>
|
||||
<Database className="size-4" />
|
||||
<AlertTitle>Статус готовности</AlertTitle>
|
||||
<AlertDescription>
|
||||
PostgreSQL-соединение отображается в readiness-проверке на вкладке «Система» (check{' '}
|
||||
<code className="text-xs">postgres</code>).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runtime-logs" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Файловые логи</CardTitle>
|
||||
<CardDescription>Логи API и pipeline</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert>
|
||||
<Info className="size-4" />
|
||||
<AlertTitle>Логи на сервере</AlertTitle>
|
||||
<AlertDescription>
|
||||
Файловые логи настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
|
||||
управляются tenant-settings на странице «Настройки BIRD».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value, valueClass }: { label: string; value: number | string; valueClass?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-muted-foreground">{label}</p>
|
||||
<p className={`text-2xl font-bold tabular-nums ${valueClass ?? ''}`}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatVersion(version?: VersionInfo | null): string {
|
||||
if (!version) return '—'
|
||||
return version.version ?? version.app ?? '—'
|
||||
}
|
||||
|
||||
interface OverallInput {
|
||||
health?: { ok?: boolean } | null
|
||||
ready?: ReadyStatus | null
|
||||
jobsFailed: number
|
||||
}
|
||||
|
||||
function overallStatusLabel(input: OverallInput): string {
|
||||
if (!input.health?.ok) return 'Ошибка'
|
||||
if (input.jobsFailed > 0) return 'Внимание'
|
||||
if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание'
|
||||
return 'В норме'
|
||||
}
|
||||
|
||||
function overallHint(input: OverallInput): string {
|
||||
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
|
||||
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
|
||||
return 'Все системы работают в штатном режиме'
|
||||
}
|
||||
|
||||
function ReadyTable({
|
||||
health,
|
||||
ready,
|
||||
}: {
|
||||
health?: { ok?: boolean; status?: string; error?: string } | null
|
||||
ready: ReadyStatus
|
||||
}) {
|
||||
const checks = ready.checks ?? {}
|
||||
const iconByKey: Record<string, typeof Database> = {
|
||||
postgres: Database,
|
||||
store: HardDrive,
|
||||
jobs: ListTodo,
|
||||
}
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[55%]">Проверка</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<HeartPulse className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Liveness</p>
|
||||
<p className="text-xs text-muted-foreground">/v1/health</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={health?.ok ? 'default' : 'destructive'}>
|
||||
{health?.ok ? 'OK' : 'Ошибка'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Readiness</p>
|
||||
<p className="text-xs text-muted-foreground">/v1/ready</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ready.status === 'ok' ? 'default' : 'secondary'}>
|
||||
{ready.status ?? '—'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{Object.entries(checks).map(([key, value]) => {
|
||||
const ok = typeof value === 'boolean' ? value : value?.ok !== false
|
||||
const Icon = iconByKey[key] ?? ListTodo
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{key}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ok ? 'default' : 'destructive'}>{ok ? 'OK' : 'Ошибка'}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||
if (!bird.birdc_configured) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{bird.message ?? 'birdc не настроен на API-хосте (EVOBGP_BIRDC_SOCKET).'}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const ratio =
|
||||
bird.bgp_sessions_total > 0
|
||||
? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100)
|
||||
: null
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Established / total</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{bird.bgp_established} / {bird.bgp_sessions_total}
|
||||
{ratio !== null ? <span className="text-muted-foreground"> ({ratio}%)</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
{ratio !== null ? (
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
ratio >= 100 ? 'bg-success' : ratio >= 50 ? 'bg-warning' : 'bg-destructive'
|
||||
}`}
|
||||
style={{ width: `${ratio}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Info, RefreshCw } from 'lucide-react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
|
||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||
|
||||
export const Route = createFileRoute('/_auth/network')({
|
||||
component: NetworkComponent,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab === 'peers' || search.tab === 'speakers' || search.tab === 'control-plane'
|
||||
? search.tab
|
||||
: 'overview') as 'overview' | 'peers' | 'speakers' | 'control-plane',
|
||||
}),
|
||||
})
|
||||
|
||||
function NetworkComponent() {
|
||||
const search = useSearch({ from: '/_auth/network' })
|
||||
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
||||
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||
|
||||
const refreshing = peersQ.isFetching || speakersQ.isFetching
|
||||
const peers = peersQ.data?.items ?? []
|
||||
const speakers = speakersQ.data?.items ?? []
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
|
||||
function refetchAll() {
|
||||
void peersQ.refetch()
|
||||
void speakersQ.refetch()
|
||||
void birdQ.refetch()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Сеть"
|
||||
description="BGP-пиры, спикеры и live-метрики нод"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>О сетевой конфигурации</AlertTitle>
|
||||
<AlertDescription>
|
||||
Вкладка «Обзор» — live-статус agent и BGP на CP и репликах. Apply и ревизии — на странице «Операции».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue={search.tab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Обзор</TabsTrigger>
|
||||
<TabsTrigger value="peers">Пиры ({peers.length})</TabsTrigger>
|
||||
<TabsTrigger value="speakers">Спикеры ({speakers.length})</TabsTrigger>
|
||||
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Сводка сети</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
|
||||
<Field label="Пиры всего" value={String(net.peersTotal)} />
|
||||
<Field label="Established" value={`${net.peersEstablished} / ${net.peersEnabled}`} />
|
||||
<Field label="Спикеры всего" value={String(net.speakersTotal)} />
|
||||
<Field label="Online" value={`${net.speakersOnline} / ${net.speakersTotal}`} />
|
||||
{net.peersMismatch > 0 ? (
|
||||
<Field label="Mismatches" value={String(net.peersMismatch)} />
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
|
||||
<CardDescription>Статус birdc на хосте API</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<QueryState
|
||||
data={birdQ.data}
|
||||
isLoading={birdQ.isLoading}
|
||||
isError={birdQ.isError}
|
||||
error={birdQ.error}
|
||||
skeleton={<TableSkeleton rows={3} cols={2} />}
|
||||
onRetry={() => birdQ.refetch()}
|
||||
>
|
||||
{(bird) => <BirdSummary bird={bird} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="peers" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Пиры</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={peers}
|
||||
isLoading={peersQ.isLoading}
|
||||
isError={peersQ.isError}
|
||||
error={peersQ.error}
|
||||
empty={peers.length === 0}
|
||||
emptyTitle="Нет пиров"
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={() => peersQ.refetch()}
|
||||
>
|
||||
{(items) => <PeersTable items={items} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="speakers" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Спикеры</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={speakers}
|
||||
isLoading={speakersQ.isLoading}
|
||||
isError={speakersQ.isError}
|
||||
error={speakersQ.error}
|
||||
empty={speakers.length === 0}
|
||||
emptyTitle="Нет спикеров"
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={() => speakersQ.refetch()}
|
||||
>
|
||||
{(items) => <SpeakersTable items={items} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="control-plane" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle>
|
||||
<CardDescription>Конфигурация tenant-level — в разделе «Настройки BIRD»</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 text-sm text-muted-foreground">
|
||||
См. раздел «Настройки BIRD».
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<Field
|
||||
label="Состояние"
|
||||
value={bird.healthy === true ? 'В норме' : bird.healthy === false ? 'Проблема' : 'Н/Д'}
|
||||
/>
|
||||
<Field label="Сессий BGP" value={`${bird.bgp_established} / ${bird.bgp_sessions_total}`} />
|
||||
{bird.message ? <p className="text-xs text-muted-foreground">{bird.message}</p> : null}
|
||||
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PeersTable({ items }: { items: import('@/types/api').PeerRow[] }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Neighbor</TableHead>
|
||||
<TableHead>ASN</TableHead>
|
||||
<TableHead>Состояние</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name ?? p.neighbor}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.neighbor}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.remote_asn ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={p.session_state} />
|
||||
{p.session_mismatch ? (
|
||||
<Badge variant="warning" className="ml-1">
|
||||
mismatch
|
||||
</Badge>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function SpeakersTable({ items }: { items: import('@/types/api').SpeakerRow[] }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Endpoint</TableHead>
|
||||
<TableHead>Роль</TableHead>
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead>BGP</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-mono text-xs">{s.endpoint}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{s.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{s.live?.agent_ok === true ? (
|
||||
<StatusBadge status="ok" label="online" />
|
||||
) : s.live?.agent_ok === false ? (
|
||||
<StatusBadge status="error" label="offline" />
|
||||
) : (
|
||||
<Badge variant="outline">—</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{s.live ? (
|
||||
<span className="text-xs">
|
||||
{s.live.bgp_established ?? 0} / {s.live.bgp_sessions_total ?? 0}
|
||||
</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
|
||||
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
|
||||
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
|
||||
import { apiMutate, waitForJob } from '@/lib/api-client'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
export const Route = createFileRoute('/_auth/operations')({
|
||||
component: OperationsComponent,
|
||||
validateSearch: (search: Record<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))
|
||||
|
||||
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 value={a} onValueChange={(v) => v && setA(v)}>
|
||||
<SelectTrigger>{a ? a.slice(0, 12) + '…' : 'Выберите'}</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 value={b} onValueChange={(v) => v && setB(v)}>
|
||||
<SelectTrigger>{b ? b.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{revisions.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.id.slice(0, 12)}…
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
||||
Сравнить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<QueryState
|
||||
data={diffQ.data}
|
||||
isLoading={diffQ.isFetching}
|
||||
isError={diffQ.isError}
|
||||
error={diffQ.error}
|
||||
empty={!diffQ.data}
|
||||
emptyTitle="Выберите две ревизии"
|
||||
onRetry={() => diffQ.refetch()}
|
||||
>
|
||||
{(diff) => <DiffView diff={diff} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffView({ diff }: { diff: import('@/types/api').RevisionDiff }) {
|
||||
const added = diff.prefixes?.added ?? (diff.added as string[]) ?? []
|
||||
const removed = diff.prefixes?.removed ?? (diff.removed as string[]) ?? []
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-success">Добавлено: {added.length}</p>
|
||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||
{added.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-destructive">Удалено: {removed.length}</p>
|
||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||
{removed.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, Clock, Info, ListTodo, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
|
||||
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||
import { modulesListQueryOptions } from '@/queries/modules'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
export const Route = createFileRoute('/_auth/schedule')({
|
||||
component: ScheduleComponent,
|
||||
})
|
||||
|
||||
function ScheduleComponent() {
|
||||
const modulesQ = useQuery(modulesListQueryOptions())
|
||||
const jobsQ = useQuery(operationsJobsQueryOptions())
|
||||
const qc = useQueryClient()
|
||||
const [refreshing, setRefreshing] = useState<Record<string, boolean>>({})
|
||||
|
||||
const modules = modulesQ.data?.items ?? []
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const loading = modulesQ.isLoading || jobsQ.isLoading
|
||||
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
const failed = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
|
||||
const items: SectionCardItem[] = [
|
||||
{ label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' },
|
||||
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'queued и running' },
|
||||
{
|
||||
label: 'С ошибкой',
|
||||
value: failed,
|
||||
icon: <AlertTriangle className="size-4" />,
|
||||
hint: failed > 0 ? 'требуют внимания' : 'без ошибок',
|
||||
variant: failed > 0 ? 'warning' : 'default',
|
||||
},
|
||||
]
|
||||
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: async (id: string) => apiMutate<{ job_id?: string }>(`/v1/modules/${id}/refresh`, 'POST'),
|
||||
onMutate: (id) => setRefreshing((s) => ({ ...s, [id]: true })),
|
||||
onSuccess: (data, id) => {
|
||||
if (data === undefined) toast.message('Обновление не требуется (тип IP_RANGES)')
|
||||
else toast.success('Задача поставлена в очередь')
|
||||
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||
void qc.invalidateQueries({ queryKey: ['modules'] })
|
||||
setRefreshing((s) => ({ ...s, [id]: false }))
|
||||
},
|
||||
onError: (e, id) => {
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось запустить')
|
||||
setRefreshing((s) => ({ ...s, [id]: false }))
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Расписание и задачи"
|
||||
description="Интервалы обновления модулей и ручной запуск"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void modulesQ.refetch()
|
||||
void jobsQ.refetch()
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Как работает расписание</AlertTitle>
|
||||
<AlertDescription>
|
||||
Планировщик использует <code className="text-xs">refresh_interval_sec</code> и опционально{' '}
|
||||
<code className="text-xs">cron_expr</code>. Ручной запуск —{' '}
|
||||
<code className="text-xs">POST /v1/modules/{id}/refresh</code>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Модули</CardTitle>
|
||||
<CardDescription>Расписание обновления и ручной запуск ingest</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={modules}
|
||||
isLoading={modulesQ.isLoading}
|
||||
isError={modulesQ.isError}
|
||||
error={modulesQ.error}
|
||||
empty={modules.length === 0}
|
||||
emptyTitle="Нет модулей"
|
||||
onRetry={() => modulesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Модуль</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Расписание</TableHead>
|
||||
<TableHead>Обновлено</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead className="w-32 text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((m) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell className="font-medium">{m.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{m.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{m.cron_expr ?? (m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—')}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{m.enabled ? (
|
||||
<Badge variant="default">Вкл</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Выкл</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={!!refreshing[m.id]}
|
||||
onClick={() => refreshMutation.mutate(m.id)}
|
||||
>
|
||||
<RefreshCw />
|
||||
Обновить
|
||||
</LoadingButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Задачи</CardTitle>
|
||||
<CardDescription>Последние задачи из API</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
|
||||
const refresh = jobs.filter((j) => j.kind === 'module_refresh')
|
||||
const failed = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="all">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
|
||||
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger>
|
||||
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="all" className="mt-0">
|
||||
<JobsTable items={jobs} loading={loading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="refresh" className="mt-0">
|
||||
<JobsTable items={refresh} loading={loading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="failed" className="mt-0">
|
||||
<JobsTable items={failed} loading={loading} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
function JobsTable({ items, loading }: { items: JobRow[]; loading: boolean }) {
|
||||
if (loading) return <div className="p-6 text-center text-sm text-muted-foreground">Загрузка…</div>
|
||||
if (items.length === 0)
|
||||
return <div className="p-6 text-center text-sm text-muted-foreground">Нет задач</div>
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Вид</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Завершена</TableHead>
|
||||
<TableHead>Ошибка</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((j) => (
|
||||
<TableRow key={j.job_id}>
|
||||
<TableCell className="font-medium">{j.kind}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
j.status === 'succeeded'
|
||||
? 'default'
|
||||
: j.status === 'failed'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{j.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-xs text-destructive">
|
||||
{j.error ?? ''}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@evobgp/ui/components/select'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
import { authSessionQueryOptions } from '@/queries/auth'
|
||||
import { toast } from 'sonner'
|
||||
import { Save } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings')({
|
||||
component: SettingsComponent,
|
||||
})
|
||||
|
||||
function SettingsComponent() {
|
||||
const { data: session } = useQuery(authSessionQueryOptions())
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [token, setTokenValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''
|
||||
setTokenValue(t)
|
||||
}, [])
|
||||
|
||||
function saveTokenHandler() {
|
||||
const t = token.trim()
|
||||
setToken(t || null)
|
||||
toast.success('Токен сохранён')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Настройки"
|
||||
description="Параметры интерфейса и подключения браузера к API."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Подключение к API</CardTitle>
|
||||
<CardDescription>
|
||||
Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant — в
|
||||
разделе «Права доступа».
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="token">Токен для запросов</Label>
|
||||
<Input
|
||||
id="token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
placeholder="Bearer …"
|
||||
/>
|
||||
</div>
|
||||
<LoadingButton onClick={saveTokenHandler}>
|
||||
<Save />
|
||||
Сохранить токен
|
||||
</LoadingButton>
|
||||
{session ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
|
||||
<code className="font-mono">{session.role}</code>.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Оформление</CardTitle>
|
||||
<CardDescription>
|
||||
Тема интерфейса. Быстрый переключатель также доступен в боковой панели.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Label htmlFor="theme-select">Тема</Label>
|
||||
<Select value={theme ?? 'system'} onValueChange={(v) => v && setTheme(v)}>
|
||||
<SelectTrigger id="theme-select" className="w-full max-w-xs" />
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Светлая</SelectItem>
|
||||
<SelectItem value="dark">Тёмная</SelectItem>
|
||||
<SelectItem value="system">Как в системе</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Info, Save } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
|
||||
import {
|
||||
BIRD_SETTING_KEYS,
|
||||
REVISION_SETTING_KEYS,
|
||||
RUNTIME_LOGS_SETTING_KEYS,
|
||||
buildPayload,
|
||||
partitionSettings,
|
||||
settingsQueryOptions,
|
||||
type BirdSettingKey,
|
||||
} from '@/queries/settings'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
|
||||
export const Route = createFileRoute('/_auth/tenant-settings')({
|
||||
component: TenantSettingsComponent,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
|
||||
? search.tab
|
||||
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
|
||||
}),
|
||||
})
|
||||
|
||||
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||
bird_router_id: 'Router ID',
|
||||
bird_local_ipv4: 'Локальный IPv4',
|
||||
bird_local_ipv6: 'Локальный IPv6',
|
||||
bird_local_asn: 'Локальный ASN',
|
||||
bird_bgp_source_ipv4: 'BGP source IPv4',
|
||||
bird_bgp_source_ipv6: 'BGP source IPv6',
|
||||
}
|
||||
|
||||
function TenantSettingsComponent() {
|
||||
const search = useSearch({ from: '/_auth/tenant-settings' })
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const qc = useQueryClient()
|
||||
|
||||
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
||||
|
||||
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
||||
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
||||
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (partitioned) {
|
||||
setBirdForm({ ...partitioned.bird })
|
||||
setRevisionForm({ ...partitioned.revision })
|
||||
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsQ.data])
|
||||
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
||||
apiMutate('/v1/settings', 'PATCH', payload),
|
||||
onSuccess: () => {
|
||||
toast.success('Параметры сохранены')
|
||||
void qc.invalidateQueries({ queryKey: ['settings'] })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
function saveBird() {
|
||||
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
||||
}
|
||||
function saveRevision() {
|
||||
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
||||
}
|
||||
function saveRuntimeLogs() {
|
||||
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Параметры tenant"
|
||||
description="Параметры control plane для текущего tenant (API /v1/settings)"
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Operator-only</AlertTitle>
|
||||
<AlertDescription>
|
||||
Изменение значений через <code className="text-xs">PATCH /v1/settings</code> требует роли
|
||||
operator. При отсутствии прав API вернёт 403.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue={search.tab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="bird">BIRD</TabsTrigger>
|
||||
<TabsTrigger value="revision">Ревизии</TabsTrigger>
|
||||
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
||||
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="bird" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>BIRD control plane</CardTitle>
|
||||
<CardDescription>
|
||||
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
|
||||
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Подстановка в конфиг</AlertTitle>
|
||||
<AlertDescription>
|
||||
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса).
|
||||
Пиры и спикеры настраиваются в разделе «Сеть».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-64" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{BIRD_SETTING_KEYS.map((key) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={key}>{BIRD_LABELS[key]}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
value={birdForm[key] ?? ''}
|
||||
onChange={(e) => setBirdForm((s) => ({ ...s, [key]: e.target.value }))}
|
||||
placeholder={BIRD_LABELS[key]}
|
||||
/>
|
||||
<p className="font-mono text-xs text-muted-foreground">{key}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="md:col-span-2">
|
||||
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="revision" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ревизии</CardTitle>
|
||||
<CardDescription>Время хранения ревизий в БД</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<div className="flex max-w-sm flex-col gap-1.5">
|
||||
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label>
|
||||
<Input
|
||||
id="revision_retention_minutes"
|
||||
type="number"
|
||||
value={revisionForm.revision_retention_minutes ?? ''}
|
||||
onChange={(e) =>
|
||||
setRevisionForm((s) => ({
|
||||
...s,
|
||||
revision_retention_minutes: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
revision_retention_minutes
|
||||
</p>
|
||||
<LoadingButton
|
||||
className="mt-2 w-fit"
|
||||
onClick={saveRevision}
|
||||
loading={patchMutation.isPending}
|
||||
>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runtime-logs" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Файловые логи</CardTitle>
|
||||
<CardDescription>Автоматическая очистка логов</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-48" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Авто-очистка включена</Label>
|
||||
<Select
|
||||
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_enabled: v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{runtimeLogsForm.runtime_logs_auto_enabled === 'true' ? 'Вкл' : 'Выкл'}
|
||||
</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
|
||||
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_mode: v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{runtimeLogsForm.runtime_logs_auto_mode || 'Выберите'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="delete">Delete</SelectItem>
|
||||
<SelectItem value="truncate">Truncate</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
runtime_logs_auto_mode
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="additional" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Дополнительные параметры</CardTitle>
|
||||
<CardDescription>
|
||||
Параметры вне стандартных групп (readonly — изменяются только через API)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={partitioned?.additional ?? []}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
empty={(partitioned?.additional ?? []).length === 0}
|
||||
emptyTitle="Нет дополнительных параметров"
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ключ</TableHead>
|
||||
<TableHead>Значение</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.key}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.value}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/dashboard' })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,345 @@
|
||||
// ---- Pagination ----
|
||||
export type Page<T> = {
|
||||
items: T[]
|
||||
next_cursor: string | null
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
// ---- Modules ----
|
||||
export type ModuleType = 'AS_PREFIXES' | 'CDN_CIDRS' | 'DOMAINS' | 'IP_RANGES'
|
||||
|
||||
export type DohResolverPolicy = 'primary_only' | 'failover' | 'union'
|
||||
|
||||
export type ModuleRow = {
|
||||
id: string
|
||||
type: ModuleType
|
||||
name: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
refresh_interval_sec: number | null
|
||||
cron_expr: string | null
|
||||
default_community_id: string | null
|
||||
/** @deprecated use doh_profile_ids */
|
||||
doh_profile_id: string | null
|
||||
doh_profile_ids: string[]
|
||||
doh_resolver_policy: DohResolverPolicy
|
||||
last_refreshed_at: string | null
|
||||
}
|
||||
export type ModulesResponse = Page<ModuleRow>
|
||||
|
||||
export type RouterListsCatalogResponse = {
|
||||
modules: { items: ModuleRow[] }
|
||||
domains: { items: { module_id: string; entry: DomainEntry }[] }
|
||||
asns: { items: { module_id: string; entry: AsEntry }[] }
|
||||
ip_ranges: { items: { module_id: string; entry: IpRangeEntry }[] }
|
||||
communities: { items: BgpCommunity[] }
|
||||
}
|
||||
|
||||
export type ModuleCreate = {
|
||||
type: ModuleType
|
||||
name: string
|
||||
enabled?: boolean
|
||||
priority?: number
|
||||
doh_profile_id?: string | null
|
||||
doh_profile_ids?: string[]
|
||||
doh_resolver_policy?: DohResolverPolicy
|
||||
refresh_interval_sec?: number | null
|
||||
cron_expr?: string | null
|
||||
default_community_id?: string | null
|
||||
}
|
||||
export type ModulePatch = Partial<Omit<ModuleCreate, 'type'>>
|
||||
|
||||
// ---- AS Entries ----
|
||||
export type AsEntry = {
|
||||
id: string
|
||||
asn: number
|
||||
community_id: string | null
|
||||
/** Имя/держатель AS (RIPEstat), после успешного обновления модуля */
|
||||
asn_name?: string | null
|
||||
/** Число объявленных префиксов на момент последнего резолва */
|
||||
prefix_count?: number | null
|
||||
/** ISO-время последнего успешного резолва ASN */
|
||||
asn_resolved_at?: string | null
|
||||
}
|
||||
export type AsEntryCreate = {
|
||||
asn: number
|
||||
community_id?: string | null
|
||||
}
|
||||
export type AsEntryPatch = {
|
||||
asn?: number
|
||||
community_id?: string | null
|
||||
}
|
||||
export type AsEntriesResponse = Page<AsEntry>
|
||||
|
||||
// ---- CDN Sources ----
|
||||
export type CdnSource = {
|
||||
id: string
|
||||
url: string
|
||||
source_kind: string
|
||||
prefix_path: string
|
||||
community_id: string | null
|
||||
refresh_interval_sec: number | null
|
||||
last_refreshed_at: string | null
|
||||
}
|
||||
export type CdnSourceCreate = {
|
||||
url: string
|
||||
source_kind: string
|
||||
prefix_path?: string
|
||||
community_id?: string | null
|
||||
}
|
||||
export type CdnSourcePatch = Partial<CdnSourceCreate> & { refresh_interval_sec?: number | null }
|
||||
export type CdnSourcesResponse = Page<CdnSource>
|
||||
|
||||
export type CdnPreviewResponse = {
|
||||
items: string[]
|
||||
total: number
|
||||
truncated: boolean
|
||||
source_url: string
|
||||
}
|
||||
|
||||
// ---- Domain Entries ----
|
||||
export type DomainEntry = {
|
||||
id: string
|
||||
fqdn: string
|
||||
community_id: string | null
|
||||
}
|
||||
export type DomainEntryCreate = {
|
||||
fqdn: string
|
||||
community_id?: string | null
|
||||
}
|
||||
export type DomainEntriesResponse = Page<DomainEntry>
|
||||
|
||||
// ---- IP Range Entries ----
|
||||
export type IpRangeEntry = {
|
||||
id: string
|
||||
prefix: string
|
||||
community_id: string
|
||||
}
|
||||
export type IpRangeEntryCreate = {
|
||||
prefix: string
|
||||
community_id: string
|
||||
}
|
||||
export type IpRangeEntriesResponse = Page<IpRangeEntry>
|
||||
|
||||
// ---- DoH Profiles ----
|
||||
export type DohProfile = {
|
||||
id: string
|
||||
name?: string
|
||||
url: string
|
||||
timeout_ms: number | null
|
||||
vault_secret_ref: string | null
|
||||
}
|
||||
export type DohProfileCreate = {
|
||||
name?: string
|
||||
url: string
|
||||
timeout_ms?: number | null
|
||||
vault_secret_ref?: string | null
|
||||
}
|
||||
export type DohProfilePatch = Partial<DohProfileCreate>
|
||||
export type DohProfilesResponse = Page<DohProfile>
|
||||
|
||||
// ---- Communities ----
|
||||
export type BgpCommunity = {
|
||||
id: string
|
||||
community: string
|
||||
title: string
|
||||
}
|
||||
export type BgpCommunityCreate = {
|
||||
community: string
|
||||
title?: string
|
||||
}
|
||||
export type BgpCommunityPatch = Partial<BgpCommunityCreate>
|
||||
export type CommunitiesResponse = Page<BgpCommunity>
|
||||
|
||||
// ---- Peers ----
|
||||
export type PeerSessionOnSpeaker = {
|
||||
speaker_id: string
|
||||
label: string
|
||||
state: string
|
||||
poll_error?: string
|
||||
}
|
||||
|
||||
export type PeerRow = {
|
||||
id: string
|
||||
name?: string
|
||||
neighbor: string
|
||||
remote_asn?: number
|
||||
enabled?: boolean
|
||||
session_state: string
|
||||
bgp_speaker_id: string | null
|
||||
connected_speaker_id?: string | null
|
||||
connected_speaker_label?: string
|
||||
session_on_speakers?: PeerSessionOnSpeaker[]
|
||||
established_on_speakers?: PeerSessionOnSpeaker[]
|
||||
session_mismatch?: boolean
|
||||
}
|
||||
export type LiveSpeakerPoll = {
|
||||
speaker_id: string
|
||||
label: string
|
||||
ok: boolean
|
||||
session_count: number
|
||||
poll_error?: string
|
||||
}
|
||||
export type PeersResponse = Page<PeerRow> & { live_speaker_poll?: LiveSpeakerPoll[] }
|
||||
export type BgpPeerCreate = {
|
||||
name?: string
|
||||
neighbor: string
|
||||
remote_asn: number
|
||||
bgp_speaker_id?: string | null
|
||||
enabled?: boolean
|
||||
}
|
||||
export type BgpPeerPatch = Partial<BgpPeerCreate>
|
||||
|
||||
// ---- Speakers ----
|
||||
export type BgpSessionLive = {
|
||||
name: string
|
||||
neighbor?: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export type SpeakerLiveStatus = {
|
||||
label?: string
|
||||
agent_ok?: boolean
|
||||
agent_error?: string
|
||||
agent_last_sync_at?: string
|
||||
agent_last_applied_revision_id?: string
|
||||
bgp_poll_ok?: boolean
|
||||
bgp_poll_error?: string
|
||||
bgp_sessions_total?: number
|
||||
bgp_established?: number
|
||||
sessions?: BgpSessionLive[]
|
||||
}
|
||||
|
||||
export type SpeakerRow = {
|
||||
id: string
|
||||
role: string
|
||||
endpoint: string
|
||||
last_applied_revision_id: string | null
|
||||
published_revision_id?: string | null
|
||||
published_at?: string | null
|
||||
agent_domain?: string
|
||||
node_ipv4?: string
|
||||
bird_bgp_source_ipv4?: string
|
||||
dispatch_status?: string
|
||||
sync_status?: string
|
||||
last_dispatch_at?: string | null
|
||||
last_dispatch_error?: string | null
|
||||
meta_json?: Record<string, unknown>
|
||||
agent_secret?: string
|
||||
live?: SpeakerLiveStatus
|
||||
}
|
||||
export type SpeakersResponse = Page<SpeakerRow>
|
||||
export type BgpSpeakerCreate = {
|
||||
endpoint: string
|
||||
role?: string
|
||||
meta_json?: string
|
||||
}
|
||||
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>
|
||||
|
||||
export type BundleSigningPublicKey = {
|
||||
public_key_base64: string
|
||||
}
|
||||
|
||||
// ---- Revisions ----
|
||||
export type RevisionRow = {
|
||||
id: string
|
||||
content_hash: string
|
||||
created_at: string
|
||||
parent_revision_id: string | null
|
||||
materialized_prefix_count: number
|
||||
module_id: string | null
|
||||
}
|
||||
export type RevisionsResponse = Page<RevisionRow>
|
||||
|
||||
export type RevisionPrefix = {
|
||||
/** Обычно CIDR; для AS-модуля в снимке ревизии — строка вида `as:<номер_asn>`. */
|
||||
prefix: string
|
||||
/** Источник материализации (например, domain:<fqdn>, as:<asn>, cdn:<source_id>, ip_range). */
|
||||
source?: string
|
||||
community_id?: string | null
|
||||
}
|
||||
export type RevisionPrefixesResponse = Page<RevisionPrefix>
|
||||
|
||||
export type RevisionPreview = {
|
||||
id: string
|
||||
prefixes?: RevisionPrefix[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Ответ GET /v1/revisions/{a}/diff/{b}: префиксы в `prefixes` (источник правды в бэкенде). */
|
||||
export type RevisionDiff = {
|
||||
revision_a?: string
|
||||
revision_b?: string
|
||||
prefixes?: {
|
||||
added: string[]
|
||||
removed: string[]
|
||||
unchanged_count?: number
|
||||
}
|
||||
/** Устаревший/нормализованный вид — см. нормализацию в UI */
|
||||
added?: (string | RevisionPrefix)[]
|
||||
removed?: (string | RevisionPrefix)[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ---- BIRD (локальный birdc на хосте с API, если задан EVOBGP_BIRDC_SOCKET) ----
|
||||
export type BirdStatus = {
|
||||
birdc_configured: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
protocols_excerpt?: string
|
||||
bgp_sessions_total: number
|
||||
bgp_established: number
|
||||
healthy: boolean | null
|
||||
}
|
||||
|
||||
// ---- Jobs ----
|
||||
export type JobRow = {
|
||||
job_id: string
|
||||
kind: string
|
||||
status: string
|
||||
idempotency_key?: string | null
|
||||
created_at?: string
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
error?: string | null
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
export type JobsResponse = Page<JobRow>
|
||||
|
||||
// ---- Settings ----
|
||||
export type AppSettings = Record<string, unknown>
|
||||
|
||||
// ---- Auth / API keys ----
|
||||
export type AuthSession = {
|
||||
tenant_id: string
|
||||
role: 'viewer' | 'editor' | 'operator' | 'node'
|
||||
}
|
||||
|
||||
export type ApiKeyRole = AuthSession['role']
|
||||
|
||||
export type ApiKey = {
|
||||
id: string
|
||||
name: string
|
||||
role: ApiKeyRole
|
||||
prefix: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
expires_at: string | null
|
||||
revoked_at: string | null
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
export type ApiKeysResponse = Page<ApiKey>
|
||||
|
||||
export type ApiKeyCreate = {
|
||||
name: string
|
||||
role: ApiKeyRole
|
||||
expires_at?: string | null
|
||||
}
|
||||
|
||||
export type ApiKeyCreated = ApiKey & { token: string }
|
||||
|
||||
// ---- AsyncJobAccepted (RFC 9457 companion: 202 with job_id) ----
|
||||
export type AsyncJobAccepted = {
|
||||
job_id: string
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/hooks/use-mobile": ["../../packages/ui/src/hooks/use-mobile.ts"],
|
||||
"@evobgp/ui/components/*": ["../../packages/ui/src/components/*"],
|
||||
"@evobgp/ui/hooks/*": ["../../packages/ui/src/hooks/*"],
|
||||
"@evobgp/ui/lib/utils": ["../../packages/ui/src/lib/utils.ts"],
|
||||
"@evobgp/ui/globals.css": ["../../packages/ui/src/styles/globals.css"]
|
||||
},
|
||||
"types": ["vite/client"],
|
||||
"verbatimModuleSyntax": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/layout/app-shell.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { TanStackRouterVite as TanStackRouterPlugin } from '@tanstack/router-plugin/vite'
|
||||
import path from 'node:path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterPlugin({ target: 'react', autoCodeSplitting: true }),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: [
|
||||
// shadcn components inside packages/ui import '@/hooks/use-mobile' (their own alias).
|
||||
// Vite doesn't read tsconfig paths from packages/ui; expose a fallback so those imports
|
||||
// resolve to packages/ui/src/hooks instead of apps/web/src/hooks.
|
||||
{
|
||||
find: '@/hooks/use-mobile',
|
||||
replacement: path.resolve(__dirname, '../../packages/ui/src/hooks/use-mobile.ts'),
|
||||
},
|
||||
{ find: '@', replacement: path.resolve(__dirname, './src') },
|
||||
{ find: '@evobgp/ui/components', replacement: path.resolve(__dirname, '../../packages/ui/src/components') },
|
||||
{ find: '@evobgp/ui/hooks', replacement: path.resolve(__dirname, '../../packages/ui/src/hooks') },
|
||||
{ find: '@evobgp/ui/lib/utils', replacement: path.resolve(__dirname, '../../packages/ui/src/lib/utils.ts') },
|
||||
],
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/v1': { target: 'http://127.0.0.1:8080', changeOrigin: true },
|
||||
'/metrics': { target: 'http://127.0.0.1:8080', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
@@ -30,12 +30,13 @@ func main() {
|
||||
}
|
||||
cfg := config.Load()
|
||||
opts := httpapi.Options{
|
||||
APIKeys: os.Getenv("EVOBGP_API_KEYS"),
|
||||
DatabaseURL: cfg.DatabaseURL,
|
||||
InsecureDev: os.Getenv("EVOBGP_DEV_INSECURE") == "1",
|
||||
SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0",
|
||||
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
|
||||
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
|
||||
APIKeys: os.Getenv("EVOBGP_API_KEYS"),
|
||||
DatabaseURL: cfg.DatabaseURL,
|
||||
InsecureDev: os.Getenv("EVOBGP_DEV_INSECURE") == "1",
|
||||
SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0",
|
||||
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
|
||||
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
|
||||
RuntimeLogsPolicyTenant: cfg.RuntimeLogsPolicyTenant,
|
||||
}
|
||||
srv, err := httpapi.New(opts)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -110,7 +110,11 @@ EVOBGP_SERVICE=evobgp-all
|
||||
EVOBGP_RUNTIME_LOGS_DIR=/opt/evobgp/runtime-logs
|
||||
```
|
||||
|
||||
В dev-профиле compose каталог на хосте обычно `./runtime-logs`, в контейнере — mount на `/opt/evobgp/runtime-logs`. Если каталог не задан или роль процесса не `evobgp-all`, эндпоинты `/v1/runtime-logs/*` отвечают **503** (`runtime_logs_unavailable`). Очистка файлов — роль **operator+**; операции пишутся в таблицу `runtime_log_cleanup_audit`.
|
||||
В dev-профиле compose каталог на хосте обычно `./runtime-logs`, в контейнере — mount на `/opt/evobgp/runtime-logs`. Если каталог не задан или роль процесса не `evobgp-all`, FS-эндпоинты (`/files`, `/auto-*`) отвечают **503** (`runtime_logs_unavailable`). `GET /v1/runtime-logs/cleanup-audit` доступен без volume.
|
||||
|
||||
Очистка файлов — роль **operator+**; операции пишутся в `runtime_log_cleanup_audit`. Автоочистка настраивается в tenant settings (`runtime_logs_auto_enabled`, `runtime_logs_max_file_mb`, `runtime_logs_auto_schedule`, `runtime_logs_auto_mode`); scheduler — только в `evobgp-all`. Опционально: `EVOBGP_RUNTIME_LOGS_POLICY_TENANT` — tenant, чьи settings читает scheduler (иначе первый tenant с включённой автоочисткой).
|
||||
|
||||
Retention строк audit: пресет maintenance policy `runtime_log_cleanup_audit` (90d) в Monitoring → PostgreSQL → Политики.
|
||||
|
||||
## CORS для веб-интерфейса
|
||||
|
||||
|
||||
+4
-2
@@ -90,7 +90,7 @@
|
||||
|
||||
### Settings
|
||||
|
||||
- `GET /v1/settings`, `PATCH /v1/settings` — tenant KV (`global_settings`): BIRD, `revision_retention_minutes`, произвольные ключи. Чтение — viewer+; `PATCH` — operator+.
|
||||
- `GET /v1/settings`, `PATCH /v1/settings` — tenant KV (`global_settings`): BIRD, `revision_retention_minutes`, `runtime_logs_*` (автоочистка FS), произвольные ключи. Чтение — viewer+; `PATCH` — operator+.
|
||||
|
||||
### RuntimeLogs
|
||||
|
||||
@@ -101,7 +101,9 @@
|
||||
| `GET` | `/v1/runtime-logs/files` | viewer+ | Список `*.log` (имя, размер, mtime) |
|
||||
| `GET` | `/v1/runtime-logs/files/{filename}` | viewer+ | Хвост файла (`?lines=`, `?bytes=`, `?grep=`) |
|
||||
| `DELETE` | `/v1/runtime-logs/files/{filename}` | operator+ | Синхронная очистка (`?mode=truncate\|delete`, default truncate); max 512 MiB |
|
||||
| `GET` | `/v1/runtime-logs/cleanup-audit` | viewer+ | Пагинированный audit очистки (`cursor`, `limit`) |
|
||||
| `GET` | `/v1/runtime-logs/cleanup-audit` | viewer+ | Пагинированный audit очистки (`cursor`, `limit`); **без FS volume** |
|
||||
| `GET` | `/v1/runtime-logs/auto-estimate` | operator+ | Файлы выше порога из tenant settings |
|
||||
| `POST` | `/v1/runtime-logs/auto-run` | operator+ | Немедленный прогон (`?dry_run=true`); audit с `actor_prefix=auto:scheduler` |
|
||||
|
||||
`{filename}` — только basename, паттерн `^[a-z0-9][a-z0-9_.-]*\.log$`. Очистка пишет строку в таблицу `runtime_log_cleanup_audit` (миграция `000026`).
|
||||
|
||||
|
||||
+4
-3
@@ -112,15 +112,16 @@ EvoBGP управляет генерацией и применением BGP-к
|
||||
| Маршрут | Назначение |
|
||||
|---------|------------|
|
||||
| `/settings` | Только браузер: API-токен, тема (localStorage). Tenant KV здесь **не** редактируются. |
|
||||
| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии** (`revision_retention_minutes`), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. |
|
||||
| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии**, **Файловые логи** (автоочистка FS), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. |
|
||||
| `/network` → Control plane | Краткая сводка BIRD + ссылка на `/tenant-settings?tab=bird`. |
|
||||
| `/operations` | Ревизии, diff, jobs; вкладка «Система» перенесена в `/tenant-settings`. |
|
||||
|
||||
### Web UI: файловые runtime-логи
|
||||
|
||||
- **Мониторинг** → вкладка **«Файловые логи»** (`/monitoring?tab=runtime-logs`).
|
||||
- Подвкладки: **Файлы** (список, preview хвоста, очистка operator) и **Audit очистки**.
|
||||
- При **503** на списке файлов: FS API недоступен (не `evobgp-all` или нет volume); audit из БД может отображаться отдельно.
|
||||
- Подвкладки: **Файлы** (список, preview хвоста, очистка operator) и **Audit очистки** (история из БД, в т.ч. `auto:scheduler`).
|
||||
- Автоочистка: **Параметры** → **Файловые логи** — порог MiB, UTC cron, режим truncate/delete; scheduler в `evobgp-all`.
|
||||
- При **503** на списке файлов: FS API недоступен (не `evobgp-all` или нет volume); `GET /v1/runtime-logs/cleanup-audit` работает без volume.
|
||||
- Deploy: `EVOBGP_RUNTIME_LOGS_DIR`, bind-mount на `evobgp-all`, sidecar `stack-runtime-logs` — [quickstart.md](quickstart.md#файловые-runtime-логи-api-v1runtime-logs), [access.md](access.md).
|
||||
|
||||
## 7. Эксплуатация и runbook
|
||||
|
||||
@@ -1168,6 +1168,54 @@ components:
|
||||
has_more:
|
||||
type: boolean
|
||||
|
||||
RuntimeLogAutoPolicy:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
max_file_bytes:
|
||||
type: integer
|
||||
format: int64
|
||||
schedule:
|
||||
type: string
|
||||
description: UTC cron (minute hour dom month dow).
|
||||
mode:
|
||||
$ref: "#/components/schemas/RuntimeLogCleanupMode"
|
||||
|
||||
RuntimeLogAutoEstimateItem:
|
||||
type: object
|
||||
required: [filename, size_bytes, would_cleanup]
|
||||
properties:
|
||||
filename:
|
||||
type: string
|
||||
size_bytes:
|
||||
type: integer
|
||||
format: int64
|
||||
would_cleanup:
|
||||
type: boolean
|
||||
skip_reason:
|
||||
type: string
|
||||
description: under_threshold, too_large, или текст ошибки.
|
||||
|
||||
RuntimeLogAutoEstimate:
|
||||
type: object
|
||||
properties:
|
||||
policy:
|
||||
$ref: "#/components/schemas/RuntimeLogAutoPolicy"
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/RuntimeLogAutoEstimateItem"
|
||||
would_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
||||
RuntimeLogAutoRunResult:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: |
|
||||
dry_run, trigger, policy, cleaned[], skipped[], cleaned_count, skipped_count.
|
||||
|
||||
BirdLocalStatus:
|
||||
type: object
|
||||
description: Статус локального BIRD на хосте API (GET /v1/bird/status).
|
||||
@@ -1388,6 +1436,23 @@ components:
|
||||
bird_bgp_source_ipv6:
|
||||
type: string
|
||||
description: Зарезервировано; в текущей генерации BGP не используется.
|
||||
revision_retention_minutes:
|
||||
type: integer
|
||||
minimum: 15
|
||||
maximum: 43200
|
||||
runtime_logs_auto_enabled:
|
||||
type: boolean
|
||||
description: Автоочистка *.log на evobgp-all по расписанию (только при FS volume).
|
||||
runtime_logs_max_file_mb:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 512
|
||||
description: Truncate/delete файлов строго больше порога (MiB).
|
||||
runtime_logs_auto_schedule:
|
||||
type: string
|
||||
description: UTC cron для автоочистки (по умолчанию `0 */6 * * *`).
|
||||
runtime_logs_auto_mode:
|
||||
$ref: "#/components/schemas/RuntimeLogCleanupMode"
|
||||
additionalProperties: true
|
||||
|
||||
RevisionDiff:
|
||||
@@ -4116,6 +4181,63 @@ paths:
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/runtime-logs/auto-estimate:
|
||||
get:
|
||||
tags: [RuntimeLogs]
|
||||
summary: Оценка автоочистки runtime log-файлов
|
||||
description: |
|
||||
Список файлов, которые будут затронуты текущей политикой tenant settings.
|
||||
Требует evobgp-all с примонтированным каталогом runtime-logs.
|
||||
operationId: estimateRuntimeLogAutoCleanup
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TenantId"
|
||||
responses:
|
||||
"200":
|
||||
description: Успешно.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RuntimeLogAutoEstimate"
|
||||
"503":
|
||||
description: FS API недоступен (не evobgp-all или нет volume).
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/runtime-logs/auto-run:
|
||||
post:
|
||||
tags: [RuntimeLogs]
|
||||
summary: Запустить автоочистку runtime log-файлов
|
||||
description: |
|
||||
Немедленный прогон политики из tenant settings. `dry_run=true` — только оценка без FS-изменений.
|
||||
Записи audit с `actor_prefix=auto:scheduler`.
|
||||
operationId: runRuntimeLogAutoCleanup
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TenantId"
|
||||
- name: dry_run
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
"200":
|
||||
description: Успешно.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RuntimeLogAutoRunResult"
|
||||
"503":
|
||||
description: FS API недоступен.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/runtime-logs/cleanup-audit:
|
||||
get:
|
||||
tags: [RuntimeLogs]
|
||||
|
||||
@@ -18,6 +18,8 @@ type Env struct {
|
||||
BrokerURL string
|
||||
// RuntimeLogsDir is the absolute host path to Docker runtime log files (evobgp-all only).
|
||||
RuntimeLogsDir string
|
||||
// RuntimeLogsPolicyTenant overrides which tenant global_settings drive auto-cleanup (optional).
|
||||
RuntimeLogsPolicyTenant string
|
||||
}
|
||||
|
||||
// Load reads EVOBGP_* environment variables with safe defaults.
|
||||
@@ -34,6 +36,7 @@ func Load() Env {
|
||||
e.GitSHA = strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))
|
||||
e.DatabaseURL = strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL"))
|
||||
e.BrokerURL = strings.TrimSpace(os.Getenv("EVOBGP_BROKER_URL"))
|
||||
e.RuntimeLogsPolicyTenant = strings.TrimSpace(os.Getenv("EVOBGP_RUNTIME_LOGS_POLICY_TENANT"))
|
||||
if v := strings.TrimSpace(os.Getenv("EVOBGP_RUNTIME_LOGS_DIR")); v != "" {
|
||||
if abs, err := filepath.Abs(v); err == nil {
|
||||
e.RuntimeLogsDir = abs
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"evobgp/internal/importer"
|
||||
"evobgp/internal/pipeline"
|
||||
"evobgp/internal/runtimelogs"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
@@ -1117,6 +1118,10 @@ func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
body["revision_retention_minutes"] = v
|
||||
}
|
||||
if !runtimelogs.ValidateRuntimeLogsSettingsPatch(body) {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid runtime_logs_* settings")
|
||||
return
|
||||
}
|
||||
if err := s.store.PatchGlobalSettings(a.TenantID, body); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
|
||||
@@ -14,6 +14,8 @@ func (s *Server) registerRuntimeLogsRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /runtime-logs/files/{filename}", s.handleGetRuntimeLogTail)
|
||||
m.HandleFunc("DELETE /runtime-logs/files/{filename}", s.handleDeleteRuntimeLogFile)
|
||||
m.HandleFunc("GET /runtime-logs/cleanup-audit", s.handleListRuntimeLogCleanupAudit)
|
||||
m.HandleFunc("GET /runtime-logs/auto-estimate", s.handleRuntimeLogAutoEstimate)
|
||||
m.HandleFunc("POST /runtime-logs/auto-run", s.handleRuntimeLogAutoRun)
|
||||
}
|
||||
|
||||
func (s *Server) requireRuntimeLogs(w http.ResponseWriter) bool {
|
||||
@@ -144,6 +146,79 @@ func (s *Server) handleDeleteRuntimeLogFile(w http.ResponseWriter, r *http.Reque
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) runtimeLogAutoPolicy(w http.ResponseWriter, r *http.Request, tenantID string) (runtimelogs.AutoPolicy, bool) {
|
||||
settings, err := s.store.ListGlobalSettings(tenantID)
|
||||
if err != nil {
|
||||
writeInternalError(w, "runtime_logs_policy", err)
|
||||
return runtimelogs.AutoPolicy{}, false
|
||||
}
|
||||
return runtimelogs.PolicyFromSettings(settings), true
|
||||
}
|
||||
|
||||
func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
|
||||
return
|
||||
}
|
||||
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := runtimelogs.EstimateAutoCleanup(s.runtimeLogs, policy)
|
||||
if err != nil {
|
||||
writeRuntimeLogsErr(w, "runtime_logs_auto_estimate", err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
var wouldCount int
|
||||
for _, it := range items {
|
||||
if it.WouldCleanup {
|
||||
wouldCount++
|
||||
}
|
||||
row := map[string]any{
|
||||
"filename": it.Filename,
|
||||
"size_bytes": it.SizeBytes,
|
||||
"would_cleanup": it.WouldCleanup,
|
||||
}
|
||||
if it.SkipReason != "" {
|
||||
row["skip_reason"] = it.SkipReason
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"policy": map[string]any{
|
||||
"enabled": policy.Enabled,
|
||||
"max_file_bytes": policy.MaxFileBytes,
|
||||
"schedule": policy.Schedule,
|
||||
"mode": policy.Mode,
|
||||
},
|
||||
"items": out,
|
||||
"would_count": wouldCount,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
|
||||
return
|
||||
}
|
||||
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
dryRun := r.URL.Query().Get("dry_run") == "true"
|
||||
result, err := runtimelogs.RunAutoCleanup(r.Context(), runtimelogs.AutoCleanupDeps{
|
||||
Service: s.runtimeLogs,
|
||||
Store: s.store,
|
||||
TenantID: a.TenantID,
|
||||
}, policy, dryRun, "manual")
|
||||
if err != nil {
|
||||
writeRuntimeLogsErr(w, "runtime_logs_auto_run", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleListRuntimeLogCleanupAudit(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestRuntimeLogsHappyPath(t *testing.T) {
|
||||
ServiceName: runtimelogs.ServiceNameAll,
|
||||
})
|
||||
handler := srv.Handler()
|
||||
tenant := "00000000-0000-0000-0000-000000000001"
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer,opkey|"+tenant+"|operator")
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
@@ -146,4 +146,46 @@ func TestRuntimeLogsHappyPath(t *testing.T) {
|
||||
t.Fatalf("expected audit entry, body=%s", auditRec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auto estimate and run", func(t *testing.T) {
|
||||
bigPath := filepath.Join(dir, "big.log")
|
||||
if err := os.WriteFile(bigPath, make([]byte, 2*1024*1024), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := srv.store.PatchGlobalSettings(tenant, map[string]any{
|
||||
runtimelogs.KeyMaxFileMB: 1,
|
||||
runtimelogs.KeyAutoMode: "truncate",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
estReq := httptest.NewRequest(http.MethodGet, "/v1/runtime-logs/auto-estimate", nil)
|
||||
estReq.Header.Set("Authorization", "Bearer opkey")
|
||||
estRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(estRec, estReq)
|
||||
if estRec.Code != http.StatusOK {
|
||||
t.Fatalf("estimate status=%d body=%s", estRec.Code, estRec.Body.String())
|
||||
}
|
||||
if !strings.Contains(estRec.Body.String(), `"would_count":1`) {
|
||||
t.Fatalf("expected would_count=1, body=%s", estRec.Body.String())
|
||||
}
|
||||
|
||||
runReq := httptest.NewRequest(http.MethodPost, "/v1/runtime-logs/auto-run", nil)
|
||||
runReq.Header.Set("Authorization", "Bearer opkey")
|
||||
runRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(runRec, runReq)
|
||||
if runRec.Code != http.StatusOK {
|
||||
t.Fatalf("auto-run status=%d body=%s", runRec.Code, runRec.Body.String())
|
||||
}
|
||||
if !strings.Contains(runRec.Body.String(), "auto:scheduler") && !strings.Contains(runRec.Body.String(), `"cleaned_count":1`) {
|
||||
t.Fatalf("unexpected auto-run body=%s", runRec.Body.String())
|
||||
}
|
||||
st, err := os.Stat(bigPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Size() != 0 {
|
||||
t.Fatalf("expected truncated big.log, size=%d", st.Size())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+34
-23
@@ -21,18 +21,19 @@ import (
|
||||
|
||||
// Server implements EvoBGP control-plane HTTP API.
|
||||
type Server struct {
|
||||
store store.Backend
|
||||
pgPool *pgxpool.Pool
|
||||
pgMonitor *pgmonitor.Service
|
||||
maintConfig *maintenance.ConfigProvider
|
||||
maintStats *maintenance.DBStatsProvider
|
||||
jobs *jobs.Registry
|
||||
bundlePriv ed25519.PrivateKey
|
||||
keyResolver *apiKeyResolver
|
||||
corsOrigins []string
|
||||
cdnHTTP *http.Client
|
||||
runtimeLogs *runtimelogs.Service
|
||||
mux *http.ServeMux
|
||||
store store.Backend
|
||||
pgPool *pgxpool.Pool
|
||||
pgMonitor *pgmonitor.Service
|
||||
maintConfig *maintenance.ConfigProvider
|
||||
maintStats *maintenance.DBStatsProvider
|
||||
jobs *jobs.Registry
|
||||
bundlePriv ed25519.PrivateKey
|
||||
keyResolver *apiKeyResolver
|
||||
corsOrigins []string
|
||||
cdnHTTP *http.Client
|
||||
runtimeLogs *runtimelogs.Service
|
||||
runtimeLogsPolicyTenant string
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// Options configures the API server.
|
||||
@@ -44,6 +45,8 @@ type Options struct {
|
||||
SeedDemo bool
|
||||
BundleSeedHex string
|
||||
CORSAllowedOrigins string
|
||||
// RuntimeLogsPolicyTenant overrides tenant for auto-cleanup scheduler settings (optional).
|
||||
RuntimeLogsPolicyTenant string
|
||||
}
|
||||
|
||||
// New constructs Server and wiring for async jobs.
|
||||
@@ -81,17 +84,18 @@ func New(opts Options) (*Server, error) {
|
||||
maintStats = maintenance.NewDBStatsProvider(pgMon)
|
||||
}
|
||||
s := &Server{
|
||||
store: backend,
|
||||
pgPool: pool,
|
||||
pgMonitor: pgMon,
|
||||
maintConfig: maintCfg,
|
||||
maintStats: maintStats,
|
||||
jobs: reg,
|
||||
bundlePriv: priv,
|
||||
keyResolver: resolver,
|
||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||
cdnHTTP: NewCDNHTTPClient(),
|
||||
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
|
||||
store: backend,
|
||||
pgPool: pool,
|
||||
pgMonitor: pgMon,
|
||||
maintConfig: maintCfg,
|
||||
maintStats: maintStats,
|
||||
jobs: reg,
|
||||
bundlePriv: priv,
|
||||
keyResolver: resolver,
|
||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||
cdnHTTP: NewCDNHTTPClient(),
|
||||
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
|
||||
runtimeLogsPolicyTenant: strings.TrimSpace(opts.RuntimeLogsPolicyTenant),
|
||||
}
|
||||
s.mux = http.NewServeMux()
|
||||
s.registerRoutes()
|
||||
@@ -126,4 +130,11 @@ func (s *Server) StartBackground(ctx context.Context) {
|
||||
})
|
||||
}, 30*time.Second)
|
||||
}
|
||||
if s != nil && s.runtimeLogs != nil && s.store != nil {
|
||||
runtimelogs.StartAutoCleanupScheduler(ctx, runtimelogs.SchedulerDeps{
|
||||
Service: s.runtimeLogs,
|
||||
Store: s.store,
|
||||
PolicyTenant: s.runtimeLogsPolicyTenant,
|
||||
}, 30*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func (p *Postgres) ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int
|
||||
var sizeAfter *int64
|
||||
if err := rows.Scan(&r.ID, &r.TenantID, &r.ActorPrefix, &r.Filename, &r.Action,
|
||||
&r.SizeBefore, &sizeAfter, &detailRaw, &r.CreatedAt); err != nil {
|
||||
continue
|
||||
return nil, "", false, err
|
||||
}
|
||||
r.SizeAfter = sizeAfter
|
||||
if len(detailRaw) > 0 {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package runtimelogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
const autoSchedulerActor = "auto:scheduler"
|
||||
|
||||
// FileEstimate describes whether a log file would be cleaned by auto policy.
|
||||
type FileEstimate struct {
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
WouldCleanup bool `json:"would_cleanup"`
|
||||
SkipReason string `json:"skip_reason,omitempty"`
|
||||
}
|
||||
|
||||
// AutoCleanupDeps wires FS cleanup with audit persistence.
|
||||
type AutoCleanupDeps struct {
|
||||
Service *Service
|
||||
Store store.Backend
|
||||
TenantID string
|
||||
}
|
||||
|
||||
// EstimateAutoCleanup lists files that exceed the configured size threshold.
|
||||
func EstimateAutoCleanup(svc *Service, policy AutoPolicy) ([]FileEstimate, error) {
|
||||
if svc == nil || !svc.Available() {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
files, err := svc.ListFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FileEstimate, 0, len(files))
|
||||
for _, f := range files {
|
||||
est := FileEstimate{
|
||||
Filename: f.Name,
|
||||
SizeBytes: f.SizeBytes,
|
||||
}
|
||||
if f.SizeBytes <= policy.MaxFileBytes {
|
||||
est.SkipReason = "under_threshold"
|
||||
} else if f.SizeBytes > MaxCleanupBytes {
|
||||
est.SkipReason = "too_large"
|
||||
} else {
|
||||
est.WouldCleanup = true
|
||||
}
|
||||
out = append(out, est)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RunAutoCleanup applies auto policy to eligible files and writes audit rows.
|
||||
func RunAutoCleanup(ctx context.Context, deps AutoCleanupDeps, policy AutoPolicy, dryRun bool, trigger string) (map[string]any, error) {
|
||||
if deps.Service == nil || !deps.Service.Available() {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if deps.Store == nil || deps.TenantID == "" {
|
||||
return nil, fmt.Errorf("runtimelogs: store tenant required for auto cleanup")
|
||||
}
|
||||
_ = ctx
|
||||
|
||||
estimates, err := EstimateAutoCleanup(deps.Service, policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
detailBase := map[string]any{
|
||||
"trigger": trigger,
|
||||
"dry_run": dryRun,
|
||||
"max_file_bytes": policy.MaxFileBytes,
|
||||
"mode": policy.Mode,
|
||||
"files_processed": 0,
|
||||
}
|
||||
cleaned := make([]map[string]any, 0)
|
||||
skipped := make([]map[string]any, 0)
|
||||
|
||||
for _, est := range estimates {
|
||||
if !est.WouldCleanup {
|
||||
if est.SkipReason != "" {
|
||||
skipped = append(skipped, map[string]any{
|
||||
"filename": est.Filename,
|
||||
"reason": est.SkipReason,
|
||||
"size": est.SizeBytes,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
cleaned = append(cleaned, map[string]any{
|
||||
"filename": est.Filename,
|
||||
"size": est.SizeBytes,
|
||||
"dry_run": true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
sizeBefore, sizeAfter, err := deps.Service.Cleanup(est.Filename, policy.Mode)
|
||||
if err != nil {
|
||||
skipped = append(skipped, map[string]any{
|
||||
"filename": est.Filename,
|
||||
"reason": err.Error(),
|
||||
"size": est.SizeBytes,
|
||||
})
|
||||
continue
|
||||
}
|
||||
auditDetail := map[string]any{
|
||||
"trigger": trigger,
|
||||
"mode": policy.Mode,
|
||||
}
|
||||
auditID, err := deps.Store.AppendRuntimeLogCleanupAudit(
|
||||
deps.TenantID, autoSchedulerActor, est.Filename, policy.Mode, sizeBefore, sizeAfter, auditDetail)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("runtimelogs: audit: %w", err)
|
||||
}
|
||||
entry := map[string]any{
|
||||
"filename": est.Filename,
|
||||
"audit_id": auditID,
|
||||
"size_before": sizeBefore,
|
||||
"action": policy.Mode,
|
||||
}
|
||||
if sizeAfter != nil {
|
||||
entry["size_after"] = *sizeAfter
|
||||
}
|
||||
cleaned = append(cleaned, entry)
|
||||
}
|
||||
|
||||
detailBase["files_processed"] = len(cleaned)
|
||||
return map[string]any{
|
||||
"dry_run": dryRun,
|
||||
"trigger": trigger,
|
||||
"policy": policySnapshot(policy),
|
||||
"cleaned": cleaned,
|
||||
"skipped": skipped,
|
||||
"cleaned_count": len(cleaned),
|
||||
"skipped_count": len(skipped),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func policySnapshot(p AutoPolicy) map[string]any {
|
||||
return map[string]any{
|
||||
"enabled": p.Enabled,
|
||||
"max_file_bytes": p.MaxFileBytes,
|
||||
"schedule": p.Schedule,
|
||||
"mode": p.Mode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package runtimelogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestEstimateAndRunAutoCleanup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
small := filepath.Join(dir, "small.log")
|
||||
large := filepath.Join(dir, "large.log")
|
||||
if err := os.WriteFile(small, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := make([]byte, 2*1024*1024)
|
||||
if err := os.WriteFile(large, payload, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewService(Config{RootDir: dir, ServiceName: ServiceNameAll})
|
||||
policy := AutoPolicy{
|
||||
Enabled: true,
|
||||
MaxFileBytes: 1024 * 1024,
|
||||
Mode: store.RuntimeLogCleanupTruncate,
|
||||
}
|
||||
|
||||
est, err := EstimateAutoCleanup(svc, policy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(est) != 2 {
|
||||
t.Fatalf("estimates=%d", len(est))
|
||||
}
|
||||
var would int
|
||||
for _, e := range est {
|
||||
if e.WouldCleanup {
|
||||
would++
|
||||
if e.Filename != "large.log" {
|
||||
t.Fatalf("unexpected cleanup target %q", e.Filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
if would != 1 {
|
||||
t.Fatalf("would=%d", would)
|
||||
}
|
||||
|
||||
mem := store.NewMemory()
|
||||
tenant := "tenant-a"
|
||||
result, err := RunAutoCleanup(context.Background(), AutoCleanupDeps{
|
||||
Service: svc,
|
||||
Store: mem,
|
||||
TenantID: tenant,
|
||||
}, policy, false, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["cleaned_count"] != 1 {
|
||||
t.Fatalf("cleaned=%v", result["cleaned_count"])
|
||||
}
|
||||
st, err := os.Stat(large)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Size() != 0 {
|
||||
t.Fatalf("expected truncated large.log, size=%d", st.Size())
|
||||
}
|
||||
items, _, _, err := mem.ListRuntimeLogCleanupAudit(tenant, "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("audit rows=%d", len(items))
|
||||
}
|
||||
if items[0].ActorPrefix != autoSchedulerActor {
|
||||
t.Fatalf("actor=%q", items[0].ActorPrefix)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package runtimelogs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/store"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// global_settings keys for runtime log auto-cleanup.
|
||||
const (
|
||||
KeyAutoEnabled = "runtime_logs_auto_enabled"
|
||||
KeyMaxFileMB = "runtime_logs_max_file_mb"
|
||||
KeyAutoSchedule = "runtime_logs_auto_schedule"
|
||||
KeyAutoMode = "runtime_logs_auto_mode"
|
||||
DefaultMaxFileMB = 128
|
||||
DefaultSchedule = "0 */6 * * *"
|
||||
)
|
||||
|
||||
// AutoPolicy is tenant KV configuration for scheduled FS log cleanup.
|
||||
type AutoPolicy struct {
|
||||
Enabled bool
|
||||
MaxFileBytes int64
|
||||
Schedule string
|
||||
Mode string
|
||||
}
|
||||
|
||||
// DefaultAutoPolicy returns policy defaults when settings are unset.
|
||||
func DefaultAutoPolicy() AutoPolicy {
|
||||
return AutoPolicy{
|
||||
Enabled: false,
|
||||
MaxFileBytes: int64(DefaultMaxFileMB) * 1024 * 1024,
|
||||
Schedule: DefaultSchedule,
|
||||
Mode: store.RuntimeLogCleanupTruncate,
|
||||
}
|
||||
}
|
||||
|
||||
// PolicyFromSettings reads auto-cleanup policy from tenant global_settings KV.
|
||||
func PolicyFromSettings(settings map[string]any) AutoPolicy {
|
||||
p := DefaultAutoPolicy()
|
||||
if settings == nil {
|
||||
return p
|
||||
}
|
||||
if v, ok := settings[KeyAutoEnabled]; ok {
|
||||
p.Enabled = parseBoolSetting(v)
|
||||
}
|
||||
if mb := parseIntSetting(settings[KeyMaxFileMB]); mb > 0 {
|
||||
p.MaxFileBytes = int64(ClampMaxFileMB(mb)) * 1024 * 1024
|
||||
}
|
||||
if s := parseStringSetting(settings[KeyAutoSchedule]); s != "" {
|
||||
p.Schedule = s
|
||||
}
|
||||
if m := parseStringSetting(settings[KeyAutoMode]); store.ValidRuntimeLogCleanupAction(m) {
|
||||
p.Mode = m
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ClampMaxFileMB normalizes max file size to 1..512 MiB.
|
||||
func ClampMaxFileMB(mb int) int {
|
||||
if mb <= 0 {
|
||||
return DefaultMaxFileMB
|
||||
}
|
||||
if mb < 1 {
|
||||
return 1
|
||||
}
|
||||
if mb > 512 {
|
||||
return 512
|
||||
}
|
||||
return mb
|
||||
}
|
||||
|
||||
// ValidAutoSchedule reports whether schedule is a valid 5-field UTC cron expression.
|
||||
func ValidAutoSchedule(schedule string) bool {
|
||||
schedule = strings.TrimSpace(schedule)
|
||||
if schedule == "" {
|
||||
return false
|
||||
}
|
||||
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
_, err := parser.Parse(schedule)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ValidateRuntimeLogsSettingsPatch normalizes and validates runtime log settings in a PATCH body.
|
||||
// Returns false if any present key is invalid.
|
||||
func ValidateRuntimeLogsSettingsPatch(body map[string]any) bool {
|
||||
if raw, ok := body[KeyAutoEnabled]; ok && raw != nil {
|
||||
v, ok := normalizeBoolSetting(raw)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
body[KeyAutoEnabled] = v
|
||||
}
|
||||
if raw, ok := body[KeyMaxFileMB]; ok && raw != nil {
|
||||
mb, ok := parsePatchInt(raw)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
mb = ClampMaxFileMB(mb)
|
||||
body[KeyMaxFileMB] = mb
|
||||
}
|
||||
if raw, ok := body[KeyAutoSchedule]; ok && raw != nil {
|
||||
s := parseStringSetting(raw)
|
||||
if !ValidAutoSchedule(s) {
|
||||
return false
|
||||
}
|
||||
body[KeyAutoSchedule] = s
|
||||
}
|
||||
if raw, ok := body[KeyAutoMode]; ok && raw != nil {
|
||||
m := parseStringSetting(raw)
|
||||
if !store.ValidRuntimeLogCleanupAction(m) {
|
||||
return false
|
||||
}
|
||||
body[KeyAutoMode] = m
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseBoolSetting(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
s := strings.TrimSpace(strings.ToLower(x))
|
||||
return s == "1" || s == "true" || s == "yes"
|
||||
case float64:
|
||||
return x != 0
|
||||
case int:
|
||||
return x != 0
|
||||
case int64:
|
||||
return x != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeBoolSetting(v any) (bool, bool) {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x, true
|
||||
case string:
|
||||
s := strings.TrimSpace(strings.ToLower(x))
|
||||
switch s {
|
||||
case "1", "true", "yes":
|
||||
return true, true
|
||||
case "0", "false", "no":
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
case float64:
|
||||
if x != 0 && x != 1 {
|
||||
return false, false
|
||||
}
|
||||
return x != 0, true
|
||||
case int:
|
||||
if x != 0 && x != 1 {
|
||||
return false, false
|
||||
}
|
||||
return x != 0, true
|
||||
case int64:
|
||||
if x != 0 && x != 1 {
|
||||
return false, false
|
||||
}
|
||||
return x != 0, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseIntSetting(v any) int {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int(x)
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(x))
|
||||
if err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parsePatchInt(v any) (int, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
if x != float64(int(x)) {
|
||||
return 0, false
|
||||
}
|
||||
n := int(x)
|
||||
return n, n >= 1 && n <= 512
|
||||
case int:
|
||||
return x, x >= 1 && x <= 512
|
||||
case int64:
|
||||
n := int(x)
|
||||
return n, n >= 1 && n <= 512
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(x))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, n >= 1 && n <= 512
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseStringSetting(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(x)
|
||||
default:
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package runtimelogs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestPolicyFromSettingsDefaults(t *testing.T) {
|
||||
p := PolicyFromSettings(nil)
|
||||
if p.Enabled {
|
||||
t.Fatal("expected disabled by default")
|
||||
}
|
||||
if p.MaxFileBytes != int64(DefaultMaxFileMB)*1024*1024 {
|
||||
t.Fatalf("max bytes=%d", p.MaxFileBytes)
|
||||
}
|
||||
if p.Schedule != DefaultSchedule {
|
||||
t.Fatalf("schedule=%q", p.Schedule)
|
||||
}
|
||||
if p.Mode != store.RuntimeLogCleanupTruncate {
|
||||
t.Fatalf("mode=%q", p.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampMaxFileMB(t *testing.T) {
|
||||
if ClampMaxFileMB(0) != DefaultMaxFileMB {
|
||||
t.Fatal("zero should default")
|
||||
}
|
||||
if ClampMaxFileMB(999) != 512 {
|
||||
t.Fatal("cap 512")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRuntimeLogsSettingsPatch(t *testing.T) {
|
||||
body := map[string]any{
|
||||
KeyAutoEnabled: true,
|
||||
KeyMaxFileMB: 64,
|
||||
KeyAutoSchedule: "0 3 * * *",
|
||||
KeyAutoMode: "truncate",
|
||||
}
|
||||
if !ValidateRuntimeLogsSettingsPatch(body) {
|
||||
t.Fatal("expected valid patch")
|
||||
}
|
||||
if body[KeyMaxFileMB] != 64 {
|
||||
t.Fatalf("max mb=%v", body[KeyMaxFileMB])
|
||||
}
|
||||
|
||||
bad := map[string]any{KeyAutoSchedule: "not a cron"}
|
||||
if ValidateRuntimeLogsSettingsPatch(bad) {
|
||||
t.Fatal("expected invalid cron")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidAutoSchedule(t *testing.T) {
|
||||
if !ValidAutoSchedule("0 */6 * * *") {
|
||||
t.Fatal("expected valid schedule")
|
||||
}
|
||||
if ValidAutoSchedule("invalid") {
|
||||
t.Fatal("expected invalid schedule")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package runtimelogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/store"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// SchedulerDeps configures the runtime log auto-cleanup background scheduler.
|
||||
type SchedulerDeps struct {
|
||||
Service *Service
|
||||
Store store.Backend
|
||||
PolicyTenant string
|
||||
}
|
||||
|
||||
// ResolvePolicyTenant picks the tenant whose settings drive auto-cleanup.
|
||||
func ResolvePolicyTenant(st store.Backend, explicit string) (string, error) {
|
||||
explicit = strings.TrimSpace(explicit)
|
||||
if explicit != "" {
|
||||
return explicit, nil
|
||||
}
|
||||
if st == nil {
|
||||
return "", fmt.Errorf("runtimelogs: store required")
|
||||
}
|
||||
ids, err := st.ListTenantIDs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, id := range ids {
|
||||
settings, err := st.ListGlobalSettings(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if PolicyFromSettings(settings).Enabled {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
return ids[0], nil
|
||||
}
|
||||
return "", fmt.Errorf("runtimelogs: no tenants configured")
|
||||
}
|
||||
|
||||
// StartAutoCleanupScheduler runs periodic auto-cleanup on evobgp-all when FS is available.
|
||||
func StartAutoCleanupScheduler(ctx context.Context, deps SchedulerDeps, tick time.Duration) {
|
||||
if deps.Service == nil || !deps.Service.Available() {
|
||||
log.Printf("runtimelogs: auto-cleanup scheduler disabled (FS unavailable)")
|
||||
return
|
||||
}
|
||||
if deps.Store == nil {
|
||||
log.Printf("runtimelogs: auto-cleanup scheduler disabled (no store)")
|
||||
return
|
||||
}
|
||||
if tick <= 0 {
|
||||
tick = 30 * time.Second
|
||||
}
|
||||
|
||||
go func() {
|
||||
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
var mu sync.Mutex
|
||||
lastFired := map[string]time.Time{}
|
||||
|
||||
t := time.NewTicker(tick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
tenantID, err := ResolvePolicyTenant(deps.Store, deps.PolicyTenant)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
settings, err := deps.Store.ListGlobalSettings(tenantID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
policy := PolicyFromSettings(settings)
|
||||
if !policy.Enabled {
|
||||
continue
|
||||
}
|
||||
sched, err := parser.Parse(policy.Schedule)
|
||||
if err != nil {
|
||||
log.Printf("runtimelogs: invalid cron %q: %v", policy.Schedule, err)
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
mu.Lock()
|
||||
prev := lastFired[policy.Schedule]
|
||||
if prev.IsZero() {
|
||||
prev = now.Add(-time.Minute)
|
||||
}
|
||||
next := sched.Next(prev)
|
||||
if next.After(now) {
|
||||
mu.Unlock()
|
||||
continue
|
||||
}
|
||||
slot := next.Unix() / 60
|
||||
if lf, ok := lastFired[policy.Schedule]; ok && lf.Unix()/60 == slot {
|
||||
mu.Unlock()
|
||||
continue
|
||||
}
|
||||
lastFired[policy.Schedule] = next
|
||||
mu.Unlock()
|
||||
|
||||
_, err = RunAutoCleanup(ctx, AutoCleanupDeps{
|
||||
Service: deps.Service,
|
||||
Store: deps.Store,
|
||||
TenantID: tenantID,
|
||||
}, policy, false, "scheduler")
|
||||
if err != nil {
|
||||
log.Printf("runtimelogs: auto-cleanup run failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("runtimelogs: auto-cleanup scheduler started (tick=%s)", tick)
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"name": "evobgp-release",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter @evobgp/web dev",
|
||||
"build": "pnpm --filter @evobgp/web build",
|
||||
"typecheck": "pnpm --filter @evobgp/web typecheck",
|
||||
"lint": "pnpm --filter @evobgp/web lint",
|
||||
"test": "pnpm --filter @evobgp/web test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.8.1",
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"registries": {
|
||||
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@evobgp/ui/components",
|
||||
"utils": "@evobgp/ui/lib/utils",
|
||||
"hooks": "@evobgp/ui/hooks",
|
||||
"lib": "@evobgp/ui/lib",
|
||||
"ui": "@evobgp/ui/components"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@evobgp/ui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./components/*": "./src/components/*",
|
||||
"./hooks/*": "./src/hooks/*",
|
||||
"./lib/utils": "./src/lib/utils.ts",
|
||||
"./globals.css": "./src/styles/globals.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"recharts": "3.8.0",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user