Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6e319a275 | ||
|
|
869b13cb57 | ||
|
|
e190785d4f | ||
|
|
3fd05ff833 | ||
|
|
e7f24f0be4 | ||
|
|
1bfe460e4b | ||
|
|
e55d2c5aba | ||
|
|
f63e9b5fd0 | ||
|
|
0148d4ca37 | ||
|
|
e0ecabb22f | ||
|
|
da301b1a94 | ||
|
|
653bc6cc91 | ||
|
|
e0d695f2a4 | ||
|
|
dfc33dcb99 | ||
|
|
44f70443c3 | ||
|
|
357ed4ce7b | ||
|
|
35e262b5ae | ||
|
|
8267141136 | ||
|
|
34a29ad337 | ||
|
|
b04b27c215 | ||
|
|
6a25a3d137 |
@@ -28,7 +28,7 @@ alwaysApply: true
|
|||||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||||
|------------|-------------|------------------|-------|
|
|------------|-------------|------------------|-------|
|
||||||
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
||||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `npx @redocly/cli lint` |
|
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `pnpm exec redocly lint` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ alwaysApply: true
|
|||||||
## Dependency Management
|
## Dependency Management
|
||||||
|
|
||||||
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
|
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
|
||||||
*Проверка:* `go.mod`, `.gitea/workflows/ci.yaml`.
|
*Проверка:* `go.mod`, `.gitea/workflows/quality.yaml`.
|
||||||
|
|
||||||
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
|
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
|
||||||
*Проверка:* review.
|
*Проверка:* review.
|
||||||
@@ -123,10 +123,10 @@ alwaysApply: true
|
|||||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||||
*Проверка:* CI job `bird2`.
|
*Проверка:* CI job `bird2`.
|
||||||
|
|
||||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`.
|
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/quality.yaml`.
|
||||||
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
||||||
|
|
||||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
**TEST-05** | MUST | Изменения OpenAPI — `pnpm exec redocly lint docs/openapi.yaml`.
|
||||||
*Проверка:* CI job `openapi`.
|
*Проверка:* CI job `openapi`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -229,7 +229,7 @@ alwaysApply: true
|
|||||||
```powershell
|
```powershell
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./... -race -count=1
|
go test ./... -race -count=1
|
||||||
npx @redocly/cli lint docs/openapi.yaml
|
pnpm exec redocly lint docs/openapi.yaml
|
||||||
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
||||||
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
|
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# KPI hybrid — только kit (stats-12 DNA)
|
# KPI hybrid — только kit (stats-12 + IconTile)
|
||||||
|
|
||||||
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
||||||
|
|
||||||
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc).
|
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc).
|
||||||
|
|
||||||
@@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma
|
|||||||
| Зона | Компонент / DNA |
|
| Зона | Компонент / DNA |
|
||||||
|------|-----------------|
|
|------|-----------------|
|
||||||
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
||||||
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
|
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
|
||||||
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
|
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
|
||||||
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
|
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
|
||||||
|
|
||||||
Импорты UI: `@evobgp/ui/components/*`.
|
Импорты UI: `@evobgp/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
|
||||||
|
|
||||||
## NEVER
|
## NEVER
|
||||||
|
|
||||||
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||||
- Другой size / radius / solid brand fill вместо `bg-muted`
|
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
|
||||||
|
- Другой size / radius / solid brand fill вместо `elevated`
|
||||||
- `card-35` как замена stats-12 hybrid KPI
|
- `card-35` как замена stats-12 hybrid KPI
|
||||||
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
||||||
- Голый lucide `size-4` в name-cell без hybrid tile
|
- Голый lucide `size-4` в name-cell без IconTile
|
||||||
|
|||||||
@@ -75,9 +75,9 @@ alwaysApply: true
|
|||||||
|
|
||||||
## Установленные ReUI (apps/web)
|
## Установленные ReUI (apps/web)
|
||||||
|
|
||||||
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
|
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
|
||||||
|
|
||||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
|
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
|
||||||
|
|
||||||
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
|
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
|
||||||
|
|
||||||
@@ -110,6 +110,7 @@ REUI_LICENSE_KEY=
|
|||||||
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
|
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
|
||||||
- Смешивать Card и Frame на ops-экране
|
- Смешивать Card и Frame на ops-экране
|
||||||
- Ставить shadcn/ui blocks выше ReUI PRO
|
- Ставить shadcn/ui blocks выше ReUI PRO
|
||||||
|
- **settings-7** как target (`surface: card`) — только [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16)
|
||||||
|
|
||||||
## Чеклист
|
## Чеклист
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ pnpm --filter @evobgp/web run build
|
|||||||
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
|
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
|
||||||
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
|
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
|
||||||
|
|
||||||
|
**WEB-23** | NEVER | **settings-7** как target ([preview](https://reui.io/preview/base/settings-7) — `surface: card`). Новые settings-экраны — [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16) (`surface: frame`). Существующий `apps/web/src/components/settings/*` на DNA settings-7 **не** копировать как эталон; миграция — отдельный CHANGE.
|
||||||
|
*Проверка:* review MCP `get_block` / cite; нет новых экранов с settings-7.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation Sync (Web)
|
## Documentation Sync (Web)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Build context = repository root (deploy/docker/docker-bake.hcl).
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.cursor
|
||||||
|
.claude
|
||||||
|
.codegraph
|
||||||
|
.agents
|
||||||
|
memory-bank
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
**/.DS_Store
|
||||||
|
**/Thumbs.db
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
!**/.env.*.example
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
apps/web/src/routeTree.gen.ts
|
||||||
|
apps/web/playwright-report
|
||||||
|
apps/web/test-results
|
||||||
|
|
||||||
|
*.md
|
||||||
|
AGENTS.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
LICENSE
|
||||||
|
docs
|
||||||
|
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
.golangci.yml
|
||||||
|
.releaserc.json
|
||||||
|
.commitlintrc.*
|
||||||
|
redocly.yaml
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
data
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
coverage
|
||||||
|
.coverage
|
||||||
|
*.out
|
||||||
|
.release-version
|
||||||
|
CHANGELOG.md
|
||||||
|
deploy/docker/docker-bake.override.hcl
|
||||||
|
deploy/compose/runtime-logs
|
||||||
+48
-21
@@ -1,55 +1,82 @@
|
|||||||
# Gitea Actions
|
# Gitea Actions
|
||||||
|
|
||||||
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
| Workflow | Когда | Что |
|
||||||
|
|----------|--------|-----|
|
||||||
| Job | PR | push в main |
|
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||||
|-----|-----|-------------|
|
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
|
||||||
| changes, openapi, web, go, bird2 | quality gates | quality gates |
|
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, go, bird2, commitlint, docker-check |
|
||||||
| commitlint | да | — |
|
|
||||||
| **release** | — | semantic-release + docker push (один run) |
|
|
||||||
|
|
||||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||||
|
|
||||||
## CI (quality gates)
|
## CI (quality gates)
|
||||||
|
|
||||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` → `go` / `openapi` и т.д. (см. `ci.yaml`).
|
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/api.md` / `docs/access.md` / `docs/openapi.yaml`) quality jobs не запускают.
|
||||||
|
|
||||||
На **pull request** — **commitlint** (Conventional Commits).
|
На **pull request** — **commitlint**. При изменении `deploy/docker/**` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||||
|
|
||||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
|
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`, `go env GOMODCACHE` / `GOCACHE`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||||
|
|
||||||
## Release (job в ci.yaml)
|
Кэшируется целиком: pnpm store + `node_modules` + corepack; Go modules + GOCACHE + `golangci-lint` в `GOBIN`. При hit: `pnpm install --offline`, `go mod download` без сети. `setup-go cache:` и `golangci-lint-action` не используем — они завязаны на `hashFiles`.
|
||||||
|
|
||||||
После успешных quality gates на **push в main** job **release**:
|
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||||
|
|
||||||
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR) и **publish** (CD).
|
||||||
|
|
||||||
|
## CD (job publish)
|
||||||
|
|
||||||
|
После успешных quality gates на **push в main** job **publish**:
|
||||||
|
|
||||||
|
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||||
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||||
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
|
3. Зеркало base-образов в `evobgp-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||||
|
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evobgp` (`cleanup: false`).
|
||||||
|
|
||||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||||
|
|
||||||
|
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||||
|
|
||||||
### Секреты
|
### Секреты
|
||||||
|
|
||||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
|
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||||
|
|
||||||
### Теги образов
|
### Теги образов
|
||||||
|
|
||||||
```text
|
```text
|
||||||
git.shts.su/<owner>/<имя>:latest
|
git.shx.one/<owner>/<имя>:latest
|
||||||
git.shts.su/<owner>/<имя>:v1.2.3
|
git.shx.one/<owner>/<имя>:v1.2.3
|
||||||
git.shts.su/<owner>/<имя>:1.2.3
|
git.shx.one/<owner>/<имя>:1.2.3
|
||||||
git.shts.su/<owner>/<имя>:<short-sha>
|
git.shx.one/<owner>/<имя>:<short-sha>
|
||||||
git.shts.su/<owner>/<имя>:sha-<full-sha>
|
git.shx.one/<owner>/<имя>:sha-<full-sha>
|
||||||
```
|
```
|
||||||
|
|
||||||
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
||||||
|
|
||||||
|
Кэш сборки: `evobgp-buildcache:{go,web,birdc}-buildcache` и `evobgp-buildcache:base-*`.
|
||||||
|
|
||||||
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull git.shts.su/myuser/evobgp-api:1.2.3
|
docker pull git.shx.one/myuser/evobgp-api:1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
||||||
|
|
||||||
|
## act_runner: cache server
|
||||||
|
|
||||||
|
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||||
|
|
||||||
|
В `config.yaml` runner:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||||
|
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||||
|
port: 8088
|
||||||
|
```
|
||||||
|
|
||||||
|
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||||
|
|
||||||
|
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
name: CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
uses: ./.gitea/workflows/quality.yaml
|
||||||
|
with:
|
||||||
|
is_pull_request: false
|
||||||
|
before_sha: ${{ github.event.before }}
|
||||||
|
head_sha: ${{ github.sha }}
|
||||||
|
allow_registry_login: false
|
||||||
|
secrets:
|
||||||
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [quality]
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||||
|
needs.quality.result == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
releases: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
persist-credentials: true
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: Install release tooling
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: sh scripts/ci/pnpm-ci.sh
|
||||||
|
- name: Verify releasable commit messages
|
||||||
|
run: pnpm exec node scripts/commit/verify-release-commits.mjs
|
||||||
|
- name: Semantic release
|
||||||
|
run: pnpm exec semantic-release
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.shx.one
|
||||||
|
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
- name: Detect new release
|
||||||
|
id: rel
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version=""
|
||||||
|
if [ -f .release-version ]; then
|
||||||
|
version="$(tr -d '[:space:]' < .release-version)"
|
||||||
|
echo "New release from semantic-release: $version"
|
||||||
|
else
|
||||||
|
git fetch --tags --force origin || true
|
||||||
|
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
||||||
|
if [ -n "${tag:-}" ]; then
|
||||||
|
version="${tag#v}"
|
||||||
|
echo "Reuse existing tag $tag on HEAD (release retry)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "${version:-}" ]; then
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No releasable commits — skipping image publish"
|
||||||
|
fi
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
with:
|
||||||
|
name: evobgp
|
||||||
|
driver: docker-container
|
||||||
|
cleanup: false
|
||||||
|
- name: Prepare image metadata
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||||
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||||
|
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||||
|
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: git.shx.one
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
- name: Mirror base images into buildcache
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||||
|
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||||
|
run: sh deploy/docker/mirror-base-images.sh
|
||||||
|
- name: Build and push images (bake)
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||||
|
IMAGE_TAG: latest
|
||||||
|
VERSION: ${{ steps.meta.outputs.version }}
|
||||||
|
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||||
|
SHA_FULL: ${{ github.sha }}
|
||||||
|
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||||
|
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||||
|
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||||
|
CACHE_REF_BIRDC: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:birdc-buildcache
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||||
|
working-directory: deploy/docker
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "${MIRROR_ENV_FILE}"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||||
|
-f docker-bake.hcl default --push
|
||||||
+16
-365
@@ -1,372 +1,23 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main, master]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, master]
|
branches: [main, master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ---------------------------------------------------------------------------
|
quality:
|
||||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
uses: ./.gitea/workflows/quality.yaml
|
||||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
with:
|
||||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
is_pull_request: true
|
||||||
# ---------------------------------------------------------------------------
|
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||||
changes:
|
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||||
runs-on: ubuntu-latest
|
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
outputs:
|
secrets:
|
||||||
openapi: ${{ steps.detect.outputs.openapi }}
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
go: ${{ steps.detect.outputs.go }}
|
|
||||||
web: ${{ steps.detect.outputs.web }}
|
|
||||||
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
|
||||||
docker_go: ${{ steps.detect.outputs.docker_go }}
|
|
||||||
docker_web: ${{ steps.detect.outputs.docker_web }}
|
|
||||||
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- id: detect
|
|
||||||
name: Detect changed paths per module
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
openapi=false
|
|
||||||
go=false
|
|
||||||
web=false
|
|
||||||
bird_conf=false
|
|
||||||
docker_go=false
|
|
||||||
docker_web=false
|
|
||||||
docker_bird=false
|
|
||||||
|
|
||||||
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
|
|
||||||
set_all_flags_true() {
|
|
||||||
openapi=true
|
|
||||||
go=true
|
|
||||||
web=true
|
|
||||||
bird_conf=true
|
|
||||||
docker_go=true
|
|
||||||
docker_web=true
|
|
||||||
docker_bird=true
|
|
||||||
}
|
|
||||||
|
|
||||||
write_outputs() {
|
|
||||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
|
||||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
||||||
base="${{ github.event.pull_request.base.sha }}"
|
|
||||||
head="${{ github.event.pull_request.head.sha }}"
|
|
||||||
FILES="$(git diff --name-only "$base" "$head")"
|
|
||||||
else
|
|
||||||
before="${{ github.event.before }}"
|
|
||||||
after="${{ github.sha }}"
|
|
||||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
|
||||||
FILES="$(git diff --name-only "$before" "$after")"
|
|
||||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
|
||||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
|
||||||
else
|
|
||||||
set_all_flags_true
|
|
||||||
write_outputs
|
|
||||||
echo "No parent commit — full pipeline (all modules)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
|
||||||
set_all_flags_true
|
|
||||||
write_outputs
|
|
||||||
echo "Empty diff — full pipeline fallback"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
full_pipeline=false
|
|
||||||
|
|
||||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
|
||||||
[ -z "${f:-}" ] && continue
|
|
||||||
case "$f" in
|
|
||||||
# CI/CD инфраструктура — все узлы quality gates
|
|
||||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
|
||||||
full_pipeline=true
|
|
||||||
;;
|
|
||||||
docs/openapi.yaml|redocly.yaml)
|
|
||||||
openapi=true
|
|
||||||
;;
|
|
||||||
docs/api.md|docs/access.md)
|
|
||||||
openapi=true
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
|
||||||
;;
|
|
||||||
apps/web/*|packages/ui/*|packages/shared/*)
|
|
||||||
web=true
|
|
||||||
;;
|
|
||||||
deploy/bird/*)
|
|
||||||
bird_conf=true
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
deploy/compose/*|deploy/docker/*)
|
|
||||||
docker_go=true
|
|
||||||
docker_web=true
|
|
||||||
docker_bird=true
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
go.mod|go.sum|go.work)
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
migrations/*)
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
cmd/*|internal/*|*.go)
|
|
||||||
go=true
|
|
||||||
bird_conf=true
|
|
||||||
;;
|
|
||||||
docs/*)
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
|
||||||
full_pipeline=true
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done <<< "$FILES"
|
|
||||||
|
|
||||||
if $full_pipeline; then
|
|
||||||
set_all_flags_true
|
|
||||||
fi
|
|
||||||
|
|
||||||
write_outputs
|
|
||||||
|
|
||||||
echo "Changed files (first 30):"
|
|
||||||
printf '%s\n' "$FILES" | head -n 30
|
|
||||||
echo "--- flags ---"
|
|
||||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
|
||||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
openapi:
|
|
||||||
needs: [changes]
|
|
||||||
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
- name: Enable pnpm via corepack
|
|
||||||
run: corepack enable
|
|
||||||
- name: Lint OpenAPI (Redocly)
|
|
||||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
|
||||||
- name: Check OpenAPI→TS codegen is fresh
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
chmod +x scripts/check-openapi-gen.sh
|
|
||||||
sh scripts/check-openapi-gen.sh
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
web:
|
|
||||||
needs: [changes]
|
|
||||||
if: needs.changes.outputs.web == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
- name: Enable pnpm via corepack
|
|
||||||
run: corepack enable
|
|
||||||
- name: pnpm install, typecheck, lint, test, build
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm --filter @evobgp/web run typecheck
|
|
||||||
pnpm --filter @evobgp/web run lint
|
|
||||||
pnpm --filter @evobgp/web run test
|
|
||||||
pnpm --filter @evobgp/web run build
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
go:
|
|
||||||
needs: [changes]
|
|
||||||
if: needs.changes.outputs.go == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.24"
|
|
||||||
cache: true
|
|
||||||
cache-dependency-path: go.sum
|
|
||||||
- name: Vet
|
|
||||||
run: go vet ./...
|
|
||||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
|
||||||
run: sh scripts/lint-httpapi.sh
|
|
||||||
- name: Check migration pairs (DEP-03)
|
|
||||||
run: sh scripts/check-migrations-pair.sh
|
|
||||||
- name: Validate remote speaker compose
|
|
||||||
run: sh scripts/validate-remote-speaker-compose.sh
|
|
||||||
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
|
|
||||||
- name: golangci-lint
|
|
||||||
uses: golangci/golangci-lint-action@v6
|
|
||||||
with:
|
|
||||||
version: v1.64.8
|
|
||||||
install-mode: goinstall
|
|
||||||
- name: Test
|
|
||||||
run: go test ./... -race -count=1
|
|
||||||
- name: Build all commands
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
out="${RUNNER_TEMP}/evobgp-bin"
|
|
||||||
mkdir -p "$out"
|
|
||||||
for d in cmd/*/; do
|
|
||||||
name="$(basename "$d")"
|
|
||||||
go build -o "$out/$name" "./$d"
|
|
||||||
done
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
bird2:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [changes, go]
|
|
||||||
if: >-
|
|
||||||
always() &&
|
|
||||||
needs.changes.result == 'success' &&
|
|
||||||
needs.go.result != 'failure' &&
|
|
||||||
(needs.changes.outputs.go == 'true' ||
|
|
||||||
needs.changes.outputs.bird_conf == 'true' ||
|
|
||||||
needs.changes.outputs.docker_bird == 'true' ||
|
|
||||||
needs.changes.outputs.docker_go == 'true')
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
|
||||||
$SUDO apt-get update -qq
|
|
||||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
|
||||||
bird --version
|
|
||||||
- name: bird -p on all scenario bird.conf files
|
|
||||||
env:
|
|
||||||
WORKSPACE: ${{ github.workspace }}
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
WS="${WORKSPACE:-$PWD}"
|
|
||||||
cd "$WS"
|
|
||||||
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
|
||||||
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
|
||||||
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
|
||||||
echo "==> $conf"
|
|
||||||
bird -c "$WS/$conf" -p
|
|
||||||
done
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
commitlint:
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
- name: Lint commit messages
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
npm ci
|
|
||||||
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
release:
|
|
||||||
needs: [changes, openapi, web, go, bird2]
|
|
||||||
if: >-
|
|
||||||
always() &&
|
|
||||||
github.event_name == 'push' &&
|
|
||||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
|
||||||
needs.changes.result == 'success' &&
|
|
||||||
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
|
|
||||||
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
|
|
||||||
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
|
|
||||||
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
|
||||||
persist-credentials: true
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
- name: Install release tooling
|
|
||||||
run: npm ci
|
|
||||||
- name: Verify releasable commit messages
|
|
||||||
run: node scripts/commit/verify-release-commits.mjs
|
|
||||||
- name: Semantic release
|
|
||||||
run: npx semantic-release
|
|
||||||
env:
|
|
||||||
GITEA_URL: https://git.shts.su
|
|
||||||
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
|
||||||
- name: Detect new release
|
|
||||||
id: rel
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -f .release-version ]; then
|
|
||||||
echo "version=$(tr -d '[:space:]' < .release-version)" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "released=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "No releasable commits — skipping image publish"
|
|
||||||
fi
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
if: steps.rel.outputs.released == 'true'
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Prepare image metadata
|
|
||||||
if: steps.rel.outputs.released == 'true'
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
|
||||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
|
||||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
|
||||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
|
||||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Log in to Gitea Registry
|
|
||||||
if: steps.rel.outputs.released == 'true'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.shts.su
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
|
||||||
- name: Build and push images (bake)
|
|
||||||
if: steps.rel.outputs.released == 'true'
|
|
||||||
env:
|
|
||||||
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
|
|
||||||
IMAGE_TAG: latest
|
|
||||||
VERSION: ${{ steps.meta.outputs.version }}
|
|
||||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
|
||||||
SHA_FULL: ${{ github.sha }}
|
|
||||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
|
||||||
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
|
||||||
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
|
||||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
cd "${{ github.workspace }}/deploy/docker"
|
|
||||||
sh write-bake-override.sh
|
|
||||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
|
||||||
-f docker-bake.hcl -f docker-bake.override.hcl default --push
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
|
||||||
|
name: quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
is_pull_request:
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
base_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
head_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
before_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
allow_registry_login:
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
secrets:
|
||||||
|
ACTIONS_PAT:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
openapi: ${{ steps.detect.outputs.openapi }}
|
||||||
|
go: ${{ steps.detect.outputs.go }}
|
||||||
|
web: ${{ steps.detect.outputs.web }}
|
||||||
|
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
||||||
|
docker_go: ${{ steps.detect.outputs.docker_go }}
|
||||||
|
docker_web: ${{ steps.detect.outputs.docker_web }}
|
||||||
|
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
||||||
|
steps:
|
||||||
|
- if: ${{ inputs.is_pull_request }}
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- if: ${{ inputs.is_pull_request == false }}
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
- id: detect
|
||||||
|
name: Detect changed paths per module
|
||||||
|
env:
|
||||||
|
IS_PR: ${{ inputs.is_pull_request }}
|
||||||
|
BASE_SHA: ${{ inputs.base_sha }}
|
||||||
|
HEAD_SHA: ${{ inputs.head_sha }}
|
||||||
|
BEFORE_SHA: ${{ inputs.before_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
openapi=false
|
||||||
|
go=false
|
||||||
|
web=false
|
||||||
|
bird_conf=false
|
||||||
|
docker_go=false
|
||||||
|
docker_web=false
|
||||||
|
docker_bird=false
|
||||||
|
|
||||||
|
set_all_flags_true() {
|
||||||
|
openapi=true
|
||||||
|
go=true
|
||||||
|
web=true
|
||||||
|
bird_conf=true
|
||||||
|
docker_go=true
|
||||||
|
docker_web=true
|
||||||
|
docker_bird=true
|
||||||
|
}
|
||||||
|
|
||||||
|
write_outputs() {
|
||||||
|
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||||
|
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$IS_PR" = "true" ]; then
|
||||||
|
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
||||||
|
else
|
||||||
|
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||||
|
before="$BEFORE_SHA"
|
||||||
|
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||||
|
FILES="$(git diff --name-only "$before" "$after")"
|
||||||
|
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||||
|
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||||
|
else
|
||||||
|
set_all_flags_true
|
||||||
|
write_outputs
|
||||||
|
echo "No parent commit — full pipeline (all modules)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||||
|
set_all_flags_true
|
||||||
|
write_outputs
|
||||||
|
echo "Empty diff — full pipeline fallback"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
full_pipeline=false
|
||||||
|
|
||||||
|
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||||
|
[ -z "${f:-}" ] && continue
|
||||||
|
case "$f" in
|
||||||
|
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||||
|
full_pipeline=true
|
||||||
|
;;
|
||||||
|
docs/openapi.yaml|redocly.yaml)
|
||||||
|
openapi=true
|
||||||
|
;;
|
||||||
|
docs/api.md|docs/access.md)
|
||||||
|
openapi=true
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
.cursor/*|.claude/*|.codegraph/*|memory-bank/*)
|
||||||
|
;;
|
||||||
|
*.md|AGENTS.md)
|
||||||
|
;;
|
||||||
|
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||||
|
;;
|
||||||
|
apps/web/*|packages/ui/*|packages/shared/*)
|
||||||
|
web=true
|
||||||
|
;;
|
||||||
|
deploy/bird/*)
|
||||||
|
bird_conf=true
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
deploy/compose/*|deploy/docker/*|.dockerignore)
|
||||||
|
docker_go=true
|
||||||
|
docker_web=true
|
||||||
|
docker_bird=true
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
go.mod|go.sum|go.work)
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
migrations/*)
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
cmd/*|internal/*|*.go)
|
||||||
|
go=true
|
||||||
|
bird_conf=true
|
||||||
|
;;
|
||||||
|
docs/*)
|
||||||
|
;;
|
||||||
|
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
||||||
|
full_pipeline=true
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$FILES"
|
||||||
|
|
||||||
|
if $full_pipeline; then
|
||||||
|
set_all_flags_true
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_outputs
|
||||||
|
|
||||||
|
echo "Changed files (first 30):"
|
||||||
|
printf '%s\n' "$FILES" | head -n 30
|
||||||
|
echo "--- flags ---"
|
||||||
|
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||||
|
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||||
|
|
||||||
|
openapi:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: pnpm install, Redocly, codegen check
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm exec redocly lint docs/openapi.yaml
|
||||||
|
chmod +x scripts/check-openapi-gen.sh
|
||||||
|
sh scripts/check-openapi-gen.sh
|
||||||
|
|
||||||
|
web:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: pnpm install, typecheck, lint, test, build
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm --filter @evobgp/web run typecheck
|
||||||
|
pnpm --filter @evobgp/web run lint
|
||||||
|
pnpm --filter @evobgp/web run test
|
||||||
|
pnpm --filter @evobgp/web run build
|
||||||
|
|
||||||
|
go:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.go == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||||
|
with:
|
||||||
|
go-version: "1.24"
|
||||||
|
cache: false
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: go-hash
|
||||||
|
run: echo "key=$(sha256sum go.sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.GOMODCACHE }}
|
||||||
|
${{ env.GOCACHE }}
|
||||||
|
${{ env.GOBIN }}
|
||||||
|
${{ env.GOLANGCI_LINT_CACHE }}
|
||||||
|
key: go-${{ runner.os }}-1.24-gl1.64.8-${{ steps.go-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
go-${{ runner.os }}-1.24-gl1.64.8-
|
||||||
|
go-${{ runner.os }}-1.24-
|
||||||
|
- name: Download modules
|
||||||
|
env:
|
||||||
|
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||||
|
GOCACHE: ${{ env.GOCACHE }}
|
||||||
|
run: go mod download
|
||||||
|
- name: Vet
|
||||||
|
env:
|
||||||
|
GOFLAGS: -mod=readonly
|
||||||
|
run: go vet ./...
|
||||||
|
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||||
|
run: sh scripts/lint-httpapi.sh
|
||||||
|
- name: Check migration pairs (DEP-03)
|
||||||
|
run: sh scripts/check-migrations-pair.sh
|
||||||
|
- name: Validate remote speaker compose
|
||||||
|
run: sh scripts/validate-remote-speaker-compose.sh
|
||||||
|
- name: golangci-lint
|
||||||
|
env:
|
||||||
|
GOLANGCI_LINT_VERSION: v1.64.8
|
||||||
|
run: sh scripts/ci/golangci-lint.sh
|
||||||
|
- name: Test
|
||||||
|
env:
|
||||||
|
GOFLAGS: -mod=readonly
|
||||||
|
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||||
|
GOCACHE: ${{ env.GOCACHE }}
|
||||||
|
run: go test ./... -race -count=1
|
||||||
|
- name: Build all commands
|
||||||
|
env:
|
||||||
|
GOFLAGS: -mod=readonly
|
||||||
|
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||||
|
GOCACHE: ${{ env.GOCACHE }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
out="${RUNNER_TEMP}/evobgp-bin"
|
||||||
|
mkdir -p "$out"
|
||||||
|
for d in cmd/*/; do
|
||||||
|
name="$(basename "$d")"
|
||||||
|
go build -o "$out/$name" "./$d"
|
||||||
|
done
|
||||||
|
|
||||||
|
bird2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [changes, go]
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
needs.changes.result == 'success' &&
|
||||||
|
needs.go.result != 'failure' &&
|
||||||
|
(needs.changes.outputs.go == 'true' ||
|
||||||
|
needs.changes.outputs.bird_conf == 'true' ||
|
||||||
|
needs.changes.outputs.docker_bird == 'true' ||
|
||||||
|
needs.changes.outputs.docker_go == 'true')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
||||||
|
$SUDO apt-get update -qq
|
||||||
|
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
||||||
|
bird --version
|
||||||
|
- name: bird -p on all scenario bird.conf files
|
||||||
|
env:
|
||||||
|
WORKSPACE: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
WS="${WORKSPACE:-$PWD}"
|
||||||
|
cd "$WS"
|
||||||
|
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
||||||
|
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
||||||
|
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
||||||
|
echo "==> $conf"
|
||||||
|
bird -c "$WS/$conf" -p
|
||||||
|
done
|
||||||
|
|
||||||
|
commitlint:
|
||||||
|
if: inputs.is_pull_request
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: Lint commit messages
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ inputs.base_sha }}
|
||||||
|
HEAD_SHA: ${{ inputs.head_sha }}
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||||
|
|
||||||
|
docker-check:
|
||||||
|
needs: [changes]
|
||||||
|
if: >-
|
||||||
|
inputs.is_pull_request &&
|
||||||
|
(needs.changes.outputs.docker_go == 'true' ||
|
||||||
|
needs.changes.outputs.docker_web == 'true' ||
|
||||||
|
needs.changes.outputs.docker_bird == 'true')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
with:
|
||||||
|
name: evobgp
|
||||||
|
driver: docker-container
|
||||||
|
cleanup: false
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
if: inputs.allow_registry_login
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: git.shx.one
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
- name: bake --print
|
||||||
|
working-directory: deploy/docker
|
||||||
|
env:
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||||
|
- name: bake (no push)
|
||||||
|
if: inputs.allow_registry_login
|
||||||
|
working-directory: deploy/docker
|
||||||
|
env:
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
CACHE_REF_GO: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:go-buildcache
|
||||||
|
CACHE_REF_WEB: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:web-buildcache
|
||||||
|
CACHE_REF_BIRDC: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:birdc-buildcache
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
export CACHE_REF_GO="git.shx.one/${owner_lc}/evobgp-buildcache:go-buildcache"
|
||||||
|
export CACHE_REF_WEB="git.shx.one/${owner_lc}/evobgp-buildcache:web-buildcache"
|
||||||
|
export CACHE_REF_BIRDC="git.shx.one/${owner_lc}/evobgp-buildcache:birdc-buildcache"
|
||||||
|
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
|
||||||
+1
-1
@@ -37,7 +37,7 @@
|
|||||||
[
|
[
|
||||||
"@markwylde/semantic-release-gitea",
|
"@markwylde/semantic-release-gitea",
|
||||||
{
|
{
|
||||||
"giteaUrl": "https://git.shts.su",
|
"giteaUrl": "https://git.shx.one",
|
||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
"path": "CHANGELOG.md",
|
"path": "CHANGELOG.md",
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ Control plane для управления префиксами, модулями
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd deploy\compose
|
cd deploy\compose
|
||||||
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shts.su/<owner>
|
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shx.one/<owner>
|
||||||
docker login git.shts.su
|
docker login git.shx.one
|
||||||
docker compose --profile microvps pull
|
docker compose --profile microvps pull
|
||||||
docker compose --profile microvps up -d
|
docker compose --profile microvps up -d
|
||||||
```
|
```
|
||||||
@@ -50,6 +50,6 @@ docker compose --profile reference up -d
|
|||||||
|
|
||||||
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
||||||
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
||||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
|
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CD run**, что и релиз (job `publish`)
|
||||||
|
|
||||||
Лицензия и условия использования — по политике владельца репозитория.
|
Лицензия и условия использования — по политике владельца репозитория.
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export function AccessApiKeysCard({
|
|||||||
<>
|
<>
|
||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
title="API-ключи"
|
title="API-ключи"
|
||||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
description="Полный токен показывается только при создании и ротации."
|
||||||
|
className="min-w-0"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||||
|
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type { ApiKey } from '@/types/api'
|
import type { ApiKey } from '@/types/api'
|
||||||
|
|
||||||
@@ -34,7 +35,14 @@ export function AccessApiKeysGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
|
cell: ({ row }) => (
|
||||||
|
<DataGridPrimaryCell
|
||||||
|
title={row.original.name}
|
||||||
|
accent="primary"
|
||||||
|
subtitle={`${row.original.prefix}…`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
minSize: 160,
|
||||||
meta: { headerTitle: 'Имя' },
|
meta: { headerTitle: 'Имя' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -45,14 +53,6 @@ export function AccessApiKeysGrid({
|
|||||||
),
|
),
|
||||||
meta: { headerTitle: 'Роль' },
|
meta: { headerTitle: 'Роль' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: 'prefix',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}…</span>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Префикс' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@@ -77,11 +77,11 @@ export function AccessApiKeysGrid({
|
|||||||
{
|
{
|
||||||
id: 'last_used_at',
|
id: 'last_used_at',
|
||||||
accessorFn: (row) => row.last_used_at ?? '',
|
accessorFn: (row) => row.last_used_at ?? '',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Последнее использование' },
|
meta: { headerTitle: 'Использован' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -90,7 +90,7 @@ export function AccessApiKeysGrid({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const k = row.original
|
const k = row.original
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
@@ -149,6 +149,7 @@ export function AccessApiKeysGrid({
|
|||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет ключей"
|
emptyMessage="Нет ключей"
|
||||||
|
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||||
searchValue={globalFilter}
|
searchValue={globalFilter}
|
||||||
onSearchChange={setGlobalFilter}
|
onSearchChange={setGlobalFilter}
|
||||||
searchPlaceholder="Поиск API-ключей…"
|
searchPlaceholder="Поиск API-ключей…"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
} from '@evobgp/ui/components/chart'
|
} from '@evobgp/ui/components/chart'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
@@ -40,9 +42,10 @@ export function ChartDonutMetric({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center gap-6', className)}>
|
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
|
||||||
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
|
||||||
<Pie
|
<Pie
|
||||||
data={data}
|
data={data}
|
||||||
dataKey="count"
|
dataKey="count"
|
||||||
@@ -75,7 +78,7 @@ export function ChartDonutMetric({
|
|||||||
</PieChart>
|
</PieChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|
||||||
<ul className="min-w-0 flex-1 space-y-3">
|
<ul className="flex w-full min-w-0 max-w-xs flex-col gap-3 sm:w-auto sm:min-w-[10rem]">
|
||||||
{slices.map((slice) => {
|
{slices.map((slice) => {
|
||||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ export function MonitoringHealthCard({
|
|||||||
return (
|
return (
|
||||||
<DonutBreakdownCard
|
<DonutBreakdownCard
|
||||||
title="Доступность системы"
|
title="Доступность системы"
|
||||||
description="GET /v1/health · GET /v1/ready"
|
description="Проверки живучести и готовности"
|
||||||
slices={slices}
|
slices={slices}
|
||||||
centerLabel="Проверки"
|
centerLabel="Проверки"
|
||||||
badge={healthOk ? 'API OK' : undefined}
|
badge={healthOk ? 'API в норме' : undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,18 +40,18 @@ export function NetworkOverviewAnalyticsCard({
|
|||||||
/>
|
/>
|
||||||
<SegmentedProgressCard
|
<SegmentedProgressCard
|
||||||
title="Спикеры"
|
title="Спикеры"
|
||||||
description="Доступность live-агентов"
|
description="Доступность агентов"
|
||||||
primary={{
|
primary={{
|
||||||
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
||||||
label: 'Online',
|
label: 'В сети',
|
||||||
percent: speakersPct,
|
percent: speakersPct,
|
||||||
}}
|
}}
|
||||||
secondary={{
|
secondary={{
|
||||||
value: net.speakersTotal - net.speakersOnline,
|
value: net.speakersTotal - net.speakersOnline,
|
||||||
label: 'Offline',
|
label: 'Не в сети',
|
||||||
percent: 100 - speakersPct,
|
percent: 100 - speakersPct,
|
||||||
}}
|
}}
|
||||||
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`}
|
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,14 +41,17 @@ export function BadgeTabs({
|
|||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onValueChange}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
className={cn('w-full', className)}
|
className={cn('w-full min-w-0', className)}
|
||||||
>
|
>
|
||||||
<TabsList
|
<TabsList
|
||||||
variant="line"
|
variant="line"
|
||||||
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
|
className={cn(
|
||||||
|
'mb-3.5 w-full min-w-0 justify-start gap-4',
|
||||||
|
listClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
|
||||||
{item.icon}
|
{item.icon}
|
||||||
{item.label}
|
{item.label}
|
||||||
{item.count !== undefined ? (
|
{item.count !== undefined ? (
|
||||||
|
|||||||
@@ -1,62 +1,2 @@
|
|||||||
import { type ReactNode } from "react"
|
/** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */
|
||||||
|
export { SettingRow } from '@/components/settings/setting-row'
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldContent,
|
|
||||||
FieldDescription,
|
|
||||||
FieldLabel,
|
|
||||||
FieldSeparator,
|
|
||||||
FieldTitle,
|
|
||||||
} from "@evobgp/ui/components/field"
|
|
||||||
|
|
||||||
interface SettingRowProps {
|
|
||||||
title: string
|
|
||||||
description?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
last?: boolean
|
|
||||||
labelFor?: string
|
|
||||||
contentClassName?: string
|
|
||||||
titleAddon?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRow({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
last,
|
|
||||||
labelFor,
|
|
||||||
contentClassName,
|
|
||||||
titleAddon,
|
|
||||||
}: SettingRowProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Field orientation="responsive" className="gap-4 px-5 py-3">
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{labelFor ? (
|
|
||||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
|
||||||
) : (
|
|
||||||
<FieldTitle>{title}</FieldTitle>
|
|
||||||
)}
|
|
||||||
{titleAddon}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{description ? (
|
|
||||||
<FieldDescription>{description}</FieldDescription>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldContent
|
|
||||||
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
|
||||||
>
|
|
||||||
<div className="flex w-full justify-start @md/field-group:justify-end">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{!last ? <FieldSeparator /> : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,53 +1,2 @@
|
|||||||
import { type ReactNode } from 'react'
|
/** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */
|
||||||
|
export { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameFooter,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
|
|
||||||
interface SettingsCardProps {
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
children: ReactNode
|
|
||||||
footer?: ReactNode
|
|
||||||
className?: string
|
|
||||||
contentClassName?: string
|
|
||||||
footerClassName?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
|
|
||||||
export function SettingsCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
footer,
|
|
||||||
className,
|
|
||||||
contentClassName,
|
|
||||||
footerClassName,
|
|
||||||
}: SettingsCardProps) {
|
|
||||||
return (
|
|
||||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
|
||||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
|
||||||
<FrameHeader className="gap-0 border-b px-5 py-3">
|
|
||||||
<FrameTitle>{title}</FrameTitle>
|
|
||||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
|
||||||
</FrameHeader>
|
|
||||||
|
|
||||||
<div className={cn('min-w-0', contentClassName)}>{children}</div>
|
|
||||||
|
|
||||||
{footer ? (
|
|
||||||
<FrameFooter
|
|
||||||
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
|
|
||||||
>
|
|
||||||
{footer}
|
|
||||||
</FrameFooter>
|
|
||||||
) : null}
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,37 +1,2 @@
|
|||||||
import { type ReactNode } from "react"
|
/** @deprecated Import from `@/components/settings/settings-field-group` — settings-7 is not a target. */
|
||||||
|
export { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLegend,
|
|
||||||
FieldSet,
|
|
||||||
} from "@evobgp/ui/components/field"
|
|
||||||
|
|
||||||
interface SettingsFieldGroupProps {
|
|
||||||
legend: string
|
|
||||||
description: string
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
fieldGroupClassName?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsFieldGroup({
|
|
||||||
legend,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
fieldGroupClassName,
|
|
||||||
}: SettingsFieldGroupProps) {
|
|
||||||
return (
|
|
||||||
<FieldSet className={cn("gap-0", className)}>
|
|
||||||
<FieldLegend className="sr-only">{legend}</FieldLegend>
|
|
||||||
{/* Description */}
|
|
||||||
<FieldDescription className="sr-only">{description}</FieldDescription>
|
|
||||||
{/* List */}
|
|
||||||
<FieldGroup className={cn("gap-0", fieldGroupClassName)}>
|
|
||||||
{children}
|
|
||||||
</FieldGroup>
|
|
||||||
</FieldSet>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { Button } from '@evobgp/ui/components/button'
|
import { useState, type ReactElement, type ReactNode } from 'react'
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
DrawerTrigger,
|
|
||||||
} from '@evobgp/ui/components/drawer'
|
|
||||||
import type { ReactElement, ReactNode } from 'react'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
confirmDrawerContentClassName,
|
AlertDialog,
|
||||||
DrawerActionsFooter,
|
AlertDialogAction,
|
||||||
} from '@/components/drawer-layout'
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@evobgp/ui/components/alert-dialog'
|
||||||
|
|
||||||
type ConfirmDialogBaseProps = {
|
type ConfirmDialogBaseProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -41,90 +38,52 @@ type ConfirmDialogControlled = ConfirmDialogBaseProps & {
|
|||||||
|
|
||||||
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
|
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
|
||||||
|
|
||||||
function ConfirmDrawerBody({
|
/** Confirm overlay — Alert Dialog. Preview: https://reui.io/preview/base/components/c-alert-dialog-11 */
|
||||||
title,
|
|
||||||
description,
|
|
||||||
confirmLabel = 'Подтвердить',
|
|
||||||
cancelLabel = 'Отмена',
|
|
||||||
destructive,
|
|
||||||
onConfirm,
|
|
||||||
confirmDisabled,
|
|
||||||
confirmLoading,
|
|
||||||
confirmLoadingLabel,
|
|
||||||
controlled,
|
|
||||||
}: ConfirmDialogBaseProps & { controlled?: boolean }) {
|
|
||||||
const confirmText =
|
|
||||||
confirmLoading && confirmLoadingLabel
|
|
||||||
? confirmLoadingLabel
|
|
||||||
: confirmLoading
|
|
||||||
? `${confirmLabel}…`
|
|
||||||
: confirmLabel
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DrawerHeader className="shrink-0 border-b border-border pb-4">
|
|
||||||
<DrawerTitle>{title}</DrawerTitle>
|
|
||||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
|
||||||
</DrawerHeader>
|
|
||||||
<DrawerActionsFooter>
|
|
||||||
<DrawerClose render={<Button variant="outline" disabled={confirmLoading} />}>
|
|
||||||
{cancelLabel}
|
|
||||||
</DrawerClose>
|
|
||||||
{controlled ? (
|
|
||||||
<Button
|
|
||||||
variant={destructive ? 'destructive' : 'default'}
|
|
||||||
disabled={confirmDisabled || confirmLoading}
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{confirmText}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<DrawerClose
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant={destructive ? 'destructive' : 'default'}
|
|
||||||
disabled={confirmDisabled || confirmLoading}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{confirmText}
|
|
||||||
</DrawerClose>
|
|
||||||
)}
|
|
||||||
</DrawerActionsFooter>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||||
const bodyProps: ConfirmDialogBaseProps = {
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
|
||||||
title: props.title,
|
const isControlled = props.open !== undefined
|
||||||
description: props.description,
|
const open = isControlled ? props.open : uncontrolledOpen
|
||||||
confirmLabel: props.confirmLabel,
|
|
||||||
cancelLabel: props.cancelLabel,
|
function setOpen(next: boolean) {
|
||||||
destructive: props.destructive,
|
if (!isControlled) setUncontrolledOpen(next)
|
||||||
onConfirm: props.onConfirm,
|
if (isControlled) props.onOpenChange?.(next)
|
||||||
confirmDisabled: props.confirmDisabled,
|
|
||||||
confirmLoading: props.confirmLoading,
|
|
||||||
confirmLoadingLabel: props.confirmLoadingLabel,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.trigger) {
|
const confirmText =
|
||||||
return (
|
props.confirmLoading && props.confirmLoadingLabel
|
||||||
<Drawer swipeDirection="right">
|
? props.confirmLoadingLabel
|
||||||
<DrawerTrigger render={props.trigger} />
|
: props.confirmLoading
|
||||||
<DrawerContent className={confirmDrawerContentClassName}>
|
? `${props.confirmLabel ?? 'Подтвердить'}…`
|
||||||
<ConfirmDrawerBody {...bodyProps} />
|
: (props.confirmLabel ?? 'Подтвердить')
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
function handleConfirm() {
|
||||||
)
|
props.onConfirm()
|
||||||
|
if (!props.confirmLoading && !isControlled) setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
<DrawerContent className={confirmDrawerContentClassName}>
|
{props.trigger ? <AlertDialogTrigger render={props.trigger} /> : null}
|
||||||
<ConfirmDrawerBody {...bodyProps} controlled />
|
<AlertDialogContent>
|
||||||
</DrawerContent>
|
<AlertDialogHeader>
|
||||||
</Drawer>
|
<AlertDialogTitle>{props.title}</AlertDialogTitle>
|
||||||
|
{props.description ? (
|
||||||
|
<AlertDialogDescription>{props.description}</AlertDialogDescription>
|
||||||
|
) : null}
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={props.confirmLoading}>
|
||||||
|
{props.cancelLabel ?? 'Отмена'}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant={props.destructive ? 'destructive' : 'default'}
|
||||||
|
disabled={props.confirmDisabled || props.confirmLoading}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function DashboardActivityTimeline({
|
|||||||
<DashboardFramePanel
|
<DashboardFramePanel
|
||||||
title="Недавняя активность"
|
title="Недавняя активность"
|
||||||
description="Задачи, ревизии и сетевые события"
|
description="Задачи, ревизии и сетевые события"
|
||||||
className="h-full"
|
className="h-full min-w-0"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
||||||
@@ -82,7 +82,7 @@ export function DashboardActivityTimeline({
|
|||||||
</TimelineIndicator>
|
</TimelineIndicator>
|
||||||
</TimelineHeader>
|
</TimelineHeader>
|
||||||
<TimelineContent className="min-w-0 pb-1 text-foreground">
|
<TimelineContent className="min-w-0 pb-1 text-foreground">
|
||||||
<TimelineTitle className="text-sm leading-snug font-normal">
|
<TimelineTitle className="text-sm leading-snug font-normal break-words">
|
||||||
{item.message}
|
{item.message}
|
||||||
</TimelineTitle>
|
</TimelineTitle>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function buildKpis({
|
|||||||
icon: <Share2 aria-hidden />,
|
icon: <Share2 aria-hidden />,
|
||||||
iconClassName: 'text-success',
|
iconClassName: 'text-success',
|
||||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||||
label: 'Пиры Established',
|
label: 'Пиры установлены',
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant="success-light" size="sm">
|
<Badge variant="success-light" size="sm">
|
||||||
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
||||||
@@ -94,13 +94,13 @@ function buildKpis({
|
|||||||
icon: <ServerCog aria-hidden />,
|
icon: <ServerCog aria-hidden />,
|
||||||
iconClassName: 'text-warning',
|
iconClassName: 'text-warning',
|
||||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||||
label: 'Спикеры online',
|
label: 'Спикеры в сети',
|
||||||
footer: (
|
footer: (
|
||||||
<Badge
|
<Badge
|
||||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{loading ? '…' : 'live-снимок'}
|
{loading ? '…' : 'в сети'}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function buildMetrics({
|
|||||||
{
|
{
|
||||||
id: 'bgp',
|
id: 'bgp',
|
||||||
title: 'BGP готовность',
|
title: 'BGP готовность',
|
||||||
label: 'Established / включённые',
|
label: 'Установлено / включено',
|
||||||
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
|
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
|
||||||
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
|
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
|
||||||
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
|
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
|
||||||
@@ -62,13 +62,13 @@ function buildMetrics({
|
|||||||
{
|
{
|
||||||
id: 'speakers',
|
id: 'speakers',
|
||||||
title: 'Спикеры',
|
title: 'Спикеры',
|
||||||
label: 'Online / всего',
|
label: 'В сети / всего',
|
||||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||||
delta:
|
delta:
|
||||||
loading || network.speakersTotal === 0
|
loading || network.speakersTotal === 0
|
||||||
? '…'
|
? '…'
|
||||||
: network.speakersOnline === network.speakersTotal
|
: network.speakersOnline === network.speakersTotal
|
||||||
? 'все online'
|
? 'все в сети'
|
||||||
: 'частично',
|
: 'частично',
|
||||||
deltaVariant:
|
deltaVariant:
|
||||||
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
InputGroupButton,
|
InputGroupButton,
|
||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
} from '@evobgp/ui/components/input-group'
|
} from '@evobgp/ui/components/input-group'
|
||||||
|
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||||
import type { ModuleRow } from '@/types/api'
|
import type { ModuleRow } from '@/types/api'
|
||||||
import {
|
import {
|
||||||
@@ -51,20 +52,19 @@ import {
|
|||||||
type SortingState,
|
type SortingState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
|
|
||||||
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at'
|
type ModuleSort = 'name' | 'type' | 'priority'
|
||||||
type EnabledFilter = 'all' | 'enabled' | 'disabled'
|
type EnabledFilter = 'all' | 'enabled' | 'disabled'
|
||||||
|
|
||||||
const sortLabels: Record<ModuleSort, string> = {
|
const sortLabels: Record<ModuleSort, string> = {
|
||||||
name: 'Название',
|
name: 'Название',
|
||||||
type: 'Тип',
|
type: 'Тип',
|
||||||
priority: 'Приоритет',
|
priority: 'Приоритет',
|
||||||
last_refreshed_at: 'Обновлено',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
|
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
|
||||||
|
|
||||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
function buildSorting(sortBy: ModuleSort): SortingState {
|
||||||
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }]
|
return [{ id: sortBy, desc: false }]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardModulesGrid({
|
export function DashboardModulesGrid({
|
||||||
@@ -119,9 +119,10 @@ export function DashboardModulesGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
||||||
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" />
|
<DataGridPrimaryCell title={row.original.name} accent="primary" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
minSize: 180,
|
||||||
meta: { headerTitle: 'Модуль' },
|
meta: { headerTitle: 'Модуль' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -138,30 +139,9 @@ export function DashboardModulesGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
|
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
|
||||||
),
|
),
|
||||||
|
size: 88,
|
||||||
meta: { headerTitle: 'Приоритет' },
|
meta: { headerTitle: 'Приоритет' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'enabled',
|
|
||||||
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
|
||||||
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
|
|
||||||
meta: { headerTitle: 'Состояние' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'last_refreshed_at',
|
|
||||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DataGridMutedCell>
|
|
||||||
{row.original.last_refreshed_at
|
|
||||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
|
||||||
: '—'}
|
|
||||||
</DataGridMutedCell>
|
|
||||||
),
|
|
||||||
sortingFn: (a, b) =>
|
|
||||||
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
|
|
||||||
meta: { headerTitle: 'Обновлено' },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -185,8 +165,9 @@ export function DashboardModulesGrid({
|
|||||||
<DashboardFramePanel
|
<DashboardFramePanel
|
||||||
title="Модули"
|
title="Модули"
|
||||||
description="Поиск, сортировка и быстрый переход к настройке"
|
description="Поиск, сортировка и быстрый переход к настройке"
|
||||||
|
className="min-w-0"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</Button>
|
||||||
@@ -204,14 +185,14 @@ export function DashboardModulesGrid({
|
|||||||
columnsVisibility: false,
|
columnsVisibility: false,
|
||||||
columnsResizable: false,
|
columnsResizable: false,
|
||||||
columnsMovable: false,
|
columnsMovable: false,
|
||||||
width: 'fixed',
|
width: 'auto',
|
||||||
}}
|
}}
|
||||||
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
||||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
|
||||||
<InputGroup className="w-full min-w-40 sm:max-w-xs">
|
<InputGroup className="w-full min-w-0 @3xl:max-w-xs">
|
||||||
<InputGroupAddon align="inline-start">
|
<InputGroupAddon align="inline-start">
|
||||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
|
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
@@ -337,8 +318,8 @@ export function DashboardModulesGrid({
|
|||||||
<div className="border-t px-4 py-3">
|
<div className="border-t px-4 py-3">
|
||||||
{filteredModules.length > 0 ? (
|
{filteredModules.length > 0 ? (
|
||||||
<DataGridPagination
|
<DataGridPagination
|
||||||
|
{...DATA_GRID_PAGINATION_RU}
|
||||||
sizes={[10, 15, 20]}
|
sizes={[10, 15, 20]}
|
||||||
info="{from}–{to} из {count}"
|
|
||||||
className="py-0"
|
className="py-0"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function DashboardNetworkHealth({
|
|||||||
}
|
}
|
||||||
primary={{
|
primary={{
|
||||||
value: loading ? '—' : `${utilization}%`,
|
value: loading ? '—' : `${utilization}%`,
|
||||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры online',
|
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
|
||||||
percent: loading ? 0 : utilization,
|
percent: loading ? 0 : utilization,
|
||||||
badge: (
|
badge: (
|
||||||
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
|
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
|
||||||
@@ -67,7 +67,7 @@ export function DashboardNetworkHealth({
|
|||||||
}}
|
}}
|
||||||
secondary={{
|
secondary={{
|
||||||
value: loading ? '—' : offline,
|
value: loading ? '—' : offline,
|
||||||
label: mode === 'peers' ? 'Не Established' : 'Offline',
|
label: mode === 'peers' ? 'Не установлены' : 'Не в сети',
|
||||||
percent: total > 0 ? Math.round((offline / total) * 100) : 0,
|
percent: total > 0 ? Math.round((offline / total) * 100) : 0,
|
||||||
}}
|
}}
|
||||||
footer={
|
footer={
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import { useMemo, useState } from 'react'
|
|||||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import { PanelCard } from '@/components/panel-card'
|
||||||
import { Separator } from '@evobgp/ui/components/separator'
|
|
||||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||||
import type { JobRow, ModuleRow } from '@/types/api'
|
import type { JobRow, ModuleRow } from '@/types/api'
|
||||||
|
|
||||||
type FlowMode = 'jobs' | 'modules'
|
type FlowMode = 'jobs' | 'modules'
|
||||||
|
|
||||||
/** chart-13 inspired donut breakdown (Card surface). */
|
/**
|
||||||
|
* Donut distribution — chart-27 DNA: Frame + one legend (inside ChartDonutMetric).
|
||||||
|
* @see https://reui.io/preview/base/chart-27
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
export function DashboardOperationsBreakdown({
|
export function DashboardOperationsBreakdown({
|
||||||
jobs,
|
jobs,
|
||||||
modules,
|
modules,
|
||||||
@@ -32,7 +35,7 @@ export function DashboardOperationsBreakdown({
|
|||||||
return (
|
return (
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Поток операций"
|
title="Поток операций"
|
||||||
description="Распределение задач и типов модулей"
|
description="Доли статусов задач или типов модулей"
|
||||||
actions={
|
actions={
|
||||||
<AnalyticsSegmentControl
|
<AnalyticsSegmentControl
|
||||||
value={mode}
|
value={mode}
|
||||||
@@ -43,45 +46,16 @@ export function DashboardOperationsBreakdown({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
className="h-full"
|
className="min-w-0"
|
||||||
|
contentClassName="px-5 py-4"
|
||||||
>
|
>
|
||||||
<div className="p-4">
|
{loading ? (
|
||||||
{loading ? (
|
<div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
|
||||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
Загрузка…
|
||||||
Загрузка…
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
||||||
<>
|
)}
|
||||||
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
|
||||||
{slices.length > 0 ? (
|
|
||||||
<ul className="mt-4 flex min-w-0 flex-col">
|
|
||||||
{slices.map((slice, index) => {
|
|
||||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
|
||||||
return (
|
|
||||||
<li key={slice.key}>
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
|
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
|
||||||
style={{ backgroundColor: slice.color }}
|
|
||||||
/>
|
|
||||||
<span className="truncate text-sm font-medium">{slice.label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
|
|
||||||
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
|
|
||||||
{pct}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'lookup',
|
id: 'lookup',
|
||||||
title: 'Проверка IP/домена',
|
title: 'Проверка IP/домена',
|
||||||
description: 'Membership в списках и community (entry + snapshot).',
|
description: 'Проверка IP в списках и BGP community.',
|
||||||
to: '/lookup',
|
to: '/lookup',
|
||||||
icon: <Search aria-hidden />,
|
icon: <Search aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
@@ -16,14 +16,15 @@ const ACTIONS: QuickActionItem[] = [
|
|||||||
id: 'new-module',
|
id: 'new-module',
|
||||||
title: 'Создать модуль',
|
title: 'Создать модуль',
|
||||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||||
to: '/modules/new',
|
to: '/modules',
|
||||||
|
search: { create: true },
|
||||||
icon: <Plus aria-hidden />,
|
icon: <Plus aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'communities',
|
id: 'communities',
|
||||||
title: 'BGP-сообщества',
|
title: 'BGP community',
|
||||||
description: 'Справочник communities для политик экспорта.',
|
description: 'Справочник BGP community для политик экспорта.',
|
||||||
to: '/directories',
|
to: '/directories',
|
||||||
icon: <Tags aria-hidden />,
|
icon: <Tags aria-hidden />,
|
||||||
iconClassName: 'text-info',
|
iconClassName: 'text-info',
|
||||||
@@ -31,7 +32,7 @@ const ACTIONS: QuickActionItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'network',
|
id: 'network',
|
||||||
title: 'Сеть',
|
title: 'Сеть',
|
||||||
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
description: 'Обзор пиров, спикеров и живых BGP-сессий.',
|
||||||
to: '/network',
|
to: '/network',
|
||||||
search: { tab: 'overview' },
|
search: { tab: 'overview' },
|
||||||
icon: <Network aria-hidden />,
|
icon: <Network aria-hidden />,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridShell } from '@/components/data-grid-shell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||||
@@ -29,7 +29,6 @@ export function DashboardRecentJobsGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
title={jobKindRu(row.original.kind)}
|
title={jobKindRu(row.original.kind)}
|
||||||
accent="mono"
|
|
||||||
subtitle={
|
subtitle={
|
||||||
row.original.meta?.module_id
|
row.original.meta?.module_id
|
||||||
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
||||||
@@ -49,7 +48,7 @@ export function DashboardRecentJobsGrid({
|
|||||||
[nameById],
|
[nameById],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
const { table, filteredCount } = useClientDataGrid({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getSearchText: (row) => {
|
getSearchText: (row) => {
|
||||||
@@ -63,16 +62,13 @@ export function DashboardRecentJobsGrid({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<DataGridShell
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет задач"
|
emptyMessage="Нет задач"
|
||||||
showPagination={false}
|
showPagination={false}
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск задач…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridShell } from '@/components/data-grid-shell'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
@@ -42,7 +42,7 @@ export function DashboardRecentRevisionsGrid({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
const { table, filteredCount } = useClientDataGrid({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getSearchText: (row) => row.id,
|
getSearchText: (row) => row.id,
|
||||||
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<DataGridShell
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет ревизий"
|
emptyMessage="Нет ревизий"
|
||||||
showPagination={false}
|
showPagination={false}
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск ревизий…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function DataGridShell<TData extends object>({
|
|||||||
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
|
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||||
tableLayout={tableLayout}
|
tableLayout={tableLayout}
|
||||||
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
|
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
|
||||||
className={className}
|
className={cn('min-w-0', className)}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
>
|
>
|
||||||
<DataGridContainer border={false}>
|
<DataGridContainer border={false}>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function CommunityFormDialog({
|
|||||||
<FormDrawer
|
<FormDrawer
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title={editTarget ? 'Редактировать сообщество' : 'Новое сообщество BGP'}
|
title={editTarget ? 'Редактировать community' : 'Новое BGP community'}
|
||||||
description="Тег для префиксов в фильтрах BIRD"
|
description="Тег для префиксов в фильтрах BIRD"
|
||||||
className="sm:max-w-sm"
|
className="sm:max-w-sm"
|
||||||
footer={
|
footer={
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ export function DirectoriesCommunitiesGrid({
|
|||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет сообществ"
|
emptyMessage="Нет community"
|
||||||
searchValue={globalFilter}
|
searchValue={globalFilter}
|
||||||
onSearchChange={setGlobalFilter}
|
onSearchChange={setGlobalFilter}
|
||||||
searchPlaceholder="Поиск сообществ…"
|
searchPlaceholder="Поиск community…"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function DohProfileFormDialog({
|
|||||||
if (timeoutMs.trim() !== '') {
|
if (timeoutMs.trim() !== '') {
|
||||||
const ms = Number(timeoutMs)
|
const ms = Number(timeoutMs)
|
||||||
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
|
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
|
||||||
toast.error('Timeout должен быть целым числом > 0')
|
toast.error('Таймаут должен быть целым числом больше 0')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
timeout = ms
|
timeout = ms
|
||||||
@@ -136,7 +136,7 @@ export function DohProfileFormDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="doh-timeout">Timeout, мс (опционально)</Label>
|
<Label htmlFor="doh-timeout">Таймаут, мс (необязательно)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="doh-timeout"
|
id="doh-timeout"
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import { DrawerFooter } from '@evobgp/ui/components/drawer'
|
|
||||||
|
|
||||||
/** Shared footer layout for right-side form and confirm drawers. */
|
|
||||||
export function DrawerActionsFooter({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DrawerFooter
|
|
||||||
className={cn(
|
|
||||||
'mt-0 shrink-0 border-t border-border bg-muted/50 p-4',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</DrawerFooter>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formDrawerContentClassName =
|
|
||||||
'flex h-full max-h-dvh flex-col sm:max-w-lg'
|
|
||||||
|
|
||||||
export const confirmDrawerContentClassName = 'flex h-auto max-h-dvh flex-col sm:max-w-sm'
|
|
||||||
@@ -1,28 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* ReUI Empty + IconStack — Frame-friendly (empty-state-14 / empty-state-12).
|
||||||
|
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
|
||||||
|
* Docs: https://reui.io/docs/components/base/icon-stack
|
||||||
|
*/
|
||||||
|
import { InboxIcon, type LucideIcon } from 'lucide-react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { IconStack } from '@/components/reui/icon-stack'
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from '@evobgp/ui/components/empty'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
|
icon?: LucideIcon | ReactNode
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
icon?: ReactNode
|
|
||||||
action?: ReactNode
|
action?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
stackedIcon?: boolean
|
||||||
|
centered?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
|
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
|
||||||
|
if (typeof icon === 'function') return true
|
||||||
|
if (icon != null && typeof icon === 'object' && '$$typeof' in icon) {
|
||||||
|
if ('props' in icon) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
stackedIcon = true,
|
||||||
|
centered = true,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
const Icon = isLucideIcon(icon) ? icon : InboxIcon
|
||||||
|
const customIcon = icon && !isLucideIcon(icon) ? icon : null
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<Empty
|
||||||
|
className={cn(
|
||||||
|
'max-w-md flex-none border-0 bg-transparent p-0',
|
||||||
|
!centered && className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<EmptyHeader className="gap-5 text-center">
|
||||||
|
<EmptyMedia className="mb-0">
|
||||||
|
{customIcon ? (
|
||||||
|
stackedIcon ? (
|
||||||
|
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
|
||||||
|
{customIcon}
|
||||||
|
</IconStack>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground">{customIcon}</div>
|
||||||
|
)
|
||||||
|
) : stackedIcon ? (
|
||||||
|
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
|
||||||
|
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
|
||||||
|
</IconStack>
|
||||||
|
) : (
|
||||||
|
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</EmptyMedia>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
|
||||||
|
{description ? (
|
||||||
|
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</EmptyHeader>
|
||||||
|
{action ? (
|
||||||
|
<EmptyContent className="mt-1 items-center justify-center">{action}</EmptyContent>
|
||||||
|
) : null}
|
||||||
|
</Empty>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!centered) return body
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
|
'flex w-full flex-1 items-center justify-center self-stretch py-14 sm:py-16',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
{body}
|
||||||
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,13 @@ import type { ReactNode } from 'react'
|
|||||||
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Sheet,
|
||||||
DrawerContent,
|
SheetContent,
|
||||||
DrawerDescription,
|
SheetDescription,
|
||||||
DrawerHeader,
|
SheetFooter,
|
||||||
DrawerTitle,
|
SheetHeader,
|
||||||
} from '@evobgp/ui/components/drawer'
|
SheetTitle,
|
||||||
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
} from '@evobgp/ui/components/sheet'
|
||||||
|
|
||||||
import {
|
|
||||||
DrawerActionsFooter,
|
|
||||||
formDrawerContentClassName,
|
|
||||||
} from '@/components/drawer-layout'
|
|
||||||
|
|
||||||
interface FormDrawerProps {
|
interface FormDrawerProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -25,6 +20,7 @@ interface FormDrawerProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
|
||||||
export function FormDrawer({
|
export function FormDrawer({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -35,17 +31,22 @@ export function FormDrawer({
|
|||||||
className,
|
className,
|
||||||
}: FormDrawerProps) {
|
}: FormDrawerProps) {
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<DrawerContent className={cn(formDrawerContentClassName, className)}>
|
<SheetContent
|
||||||
<DrawerHeader className="shrink-0 border-b border-border pb-4">
|
side="right"
|
||||||
<DrawerTitle>{title}</DrawerTitle>
|
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
|
||||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
>
|
||||||
</DrawerHeader>
|
<SheetHeader className="shrink-0 border-b border-border/50">
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<SheetTitle>{title}</SheetTitle>
|
||||||
<div className="space-y-4 px-4 py-4">{children}</div>
|
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||||
</ScrollArea>
|
</SheetHeader>
|
||||||
<DrawerActionsFooter>{footer}</DrawerActionsFooter>
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
|
||||||
</DrawerContent>
|
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
|
||||||
</Drawer>
|
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
BookText,
|
BookText,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ServerCog,
|
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
@@ -47,6 +46,7 @@ import { CommandPalette, type CommandPaletteItem } from '@/components/layout/com
|
|||||||
import { NavUser } from '@/components/layout/nav-user'
|
import { NavUser } from '@/components/layout/nav-user'
|
||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
||||||
|
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string
|
to: string
|
||||||
@@ -71,7 +71,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
to: '/dashboard',
|
to: '/dashboard',
|
||||||
label: 'Панель',
|
label: 'Панель',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
description: 'KPI, модули и активность',
|
description: 'Метрики, модули и активность',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -81,23 +81,22 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
||||||
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
||||||
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
||||||
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
|
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Операции',
|
label: 'Операции',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
||||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
||||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Система',
|
label: 'Система',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
|
||||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -161,6 +160,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||||
|
К содержимому
|
||||||
|
</a>
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<AppSwitcher />
|
<AppSwitcher />
|
||||||
@@ -196,7 +198,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<NavUser />
|
<NavUser />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<SidebarInset>
|
<SidebarInset id="main-content">
|
||||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
@@ -220,9 +222,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<SystemMonitorPopover />
|
<SystemMonitorPopover />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function AppsMenu() {
|
|||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||||
className="justify-center text-sm font-medium"
|
className="justify-center text-sm font-medium"
|
||||||
>
|
>
|
||||||
Настройки
|
Настройки
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function NavUser() {
|
|||||||
setApiToken(null)
|
setApiToken(null)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||||
window.location.assign('/settings?tab=connection&reason=token-required')
|
window.location.assign('/settings?tab=ui&reason=token-required')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,10 +190,10 @@ export function NavUser() {
|
|||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||||
>
|
>
|
||||||
<SettingsIcon aria-hidden />
|
<SettingsIcon aria-hidden />
|
||||||
Настройки UI
|
Настройки
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{authOn ? (
|
{authOn ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ export function SystemMonitorPopover() {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: 'api',
|
id: 'api',
|
||||||
label: 'API health',
|
label: 'Состояние API',
|
||||||
value: healthOk ? 'OK' : '—',
|
value: healthOk ? 'норма' : '—',
|
||||||
unit: '',
|
unit: '',
|
||||||
percent: healthOk ? 100 : 0,
|
percent: healthOk ? 100 : 0,
|
||||||
icon: <HeartPulse aria-hidden />,
|
icon: <HeartPulse aria-hidden />,
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function LookupAddStep({
|
|||||||
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
|
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
|
||||||
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
|
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules" search={{ create: true }} />}>
|
||||||
Перейти к модулям
|
Перейти к модулям
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
|
|||||||
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
|
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
|
||||||
|
|
||||||
const CDN_KIND_ITEMS = [
|
const CDN_KIND_ITEMS = [
|
||||||
{ value: 'plaintext', label: 'plaintext' },
|
{ value: 'plaintext', label: 'Текст' },
|
||||||
{ value: 'json', label: 'json' },
|
{ value: 'json', label: 'JSON' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
interface ModuleCdnSourceDialogProps {
|
interface ModuleCdnSourceDialogProps {
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
|
|
||||||
|
import { FormDrawer } from '@/components/form-drawer'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { CommunitySelect } from '@/components/modules/community-select'
|
||||||
|
import { SelectField } from '@/components/select-field'
|
||||||
|
import { dohProfileShortLabel } from '@/lib/modules/helpers'
|
||||||
|
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
|
||||||
|
import { useCreateModuleMutation } from '@/queries/modules'
|
||||||
|
import type {
|
||||||
|
BgpCommunity,
|
||||||
|
DohProfile,
|
||||||
|
DohResolverPolicy,
|
||||||
|
ModuleCreate,
|
||||||
|
ModuleRow,
|
||||||
|
ModuleType,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
interface ModuleCreateDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
communities: BgpCommunity[]
|
||||||
|
dohProfiles: DohProfile[]
|
||||||
|
onCreated?: (mod: ModuleRow) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @see https://reui.io/preview/base/form-7 */
|
||||||
|
/** @see https://reui.io/preview/base/sheet-8 */
|
||||||
|
|
||||||
|
const MODULE_TYPE_ITEMS: { value: ModuleType; label: string }[] = [
|
||||||
|
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') },
|
||||||
|
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
|
||||||
|
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
|
||||||
|
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
|
||||||
|
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
|
||||||
|
{ value: 'failover', label: dohPolicyRu('failover') },
|
||||||
|
{ value: 'union', label: dohPolicyRu('union') },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ModuleCreateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
communities,
|
||||||
|
dohProfiles,
|
||||||
|
onCreated,
|
||||||
|
}: ModuleCreateDialogProps) {
|
||||||
|
const createMutation = useCreateModuleMutation()
|
||||||
|
|
||||||
|
const [type, setType] = useState<ModuleType>('IP_RANGES')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [enabled, setEnabled] = useState(true)
|
||||||
|
const [priority, setPriority] = useState('0')
|
||||||
|
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
|
||||||
|
const [cronExpr, setCronExpr] = useState('')
|
||||||
|
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
|
||||||
|
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
|
||||||
|
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
const isDomains = type === 'DOMAINS'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setType('IP_RANGES')
|
||||||
|
setName('')
|
||||||
|
setEnabled(true)
|
||||||
|
setPriority('0')
|
||||||
|
setRefreshIntervalSec('')
|
||||||
|
setCronExpr('')
|
||||||
|
setDefaultCommunityId(null)
|
||||||
|
setDohResolverPolicy('primary_only')
|
||||||
|
setDohProfileIds([])
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function toggleDohProfile(id: string, checked: boolean) {
|
||||||
|
setDohProfileIds((prev) => {
|
||||||
|
if (checked) {
|
||||||
|
if (prev.includes(id)) return prev
|
||||||
|
return [...prev, id]
|
||||||
|
}
|
||||||
|
return prev.filter((x) => x !== id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
if (!trimmedName) {
|
||||||
|
toast.error('Укажите название модуля')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityNum = Number(priority)
|
||||||
|
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
|
||||||
|
toast.error('Приоритет должен быть целым числом')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let refresh: number | undefined
|
||||||
|
if (refreshIntervalSec.trim() !== '') {
|
||||||
|
const n = Number(refreshIntervalSec)
|
||||||
|
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||||
|
toast.error('Интервал обновления должен быть целым числом ≥ 0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refresh = n
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: ModuleCreate = {
|
||||||
|
type,
|
||||||
|
name: trimmedName,
|
||||||
|
enabled,
|
||||||
|
priority: priorityNum,
|
||||||
|
}
|
||||||
|
if (refresh !== undefined) {
|
||||||
|
body.refresh_interval_sec = refresh
|
||||||
|
}
|
||||||
|
const cron = cronExpr.trim()
|
||||||
|
if (cron) {
|
||||||
|
body.cron_expr = cron
|
||||||
|
}
|
||||||
|
if (defaultCommunityId) {
|
||||||
|
body.default_community_id = defaultCommunityId
|
||||||
|
}
|
||||||
|
if (isDomains) {
|
||||||
|
body.doh_resolver_policy = dohResolverPolicy
|
||||||
|
body.doh_profile_ids = dohProfileIds
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createMutation.mutateAsync(body)
|
||||||
|
onOpenChange(false)
|
||||||
|
onCreated?.(created)
|
||||||
|
} catch {
|
||||||
|
// toast in mutation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormDrawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Новый модуль"
|
||||||
|
description="Тип задаётся один раз. Записи добавляются на карточке модуля."
|
||||||
|
className="sm:max-w-md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
|
||||||
|
Создать
|
||||||
|
</LoadingButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectField
|
||||||
|
id="mod-create-type"
|
||||||
|
label="Тип"
|
||||||
|
items={MODULE_TYPE_ITEMS}
|
||||||
|
value={type}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) setType(v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mod-create-name">Название</Label>
|
||||||
|
<Input
|
||||||
|
id="mod-create-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Имя модуля"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
|
<Label htmlFor="mod-create-enabled">Включён</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Выключенный модуль не участвует в обновлении и применении.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
id="mod-create-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={(v) => setEnabled(v === true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mod-create-priority">Приоритет</Label>
|
||||||
|
<Input
|
||||||
|
id="mod-create-priority"
|
||||||
|
type="number"
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
|
||||||
|
<Input
|
||||||
|
id="mod-create-interval"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
placeholder="пусто = по умолчанию"
|
||||||
|
value={refreshIntervalSec}
|
||||||
|
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
|
||||||
|
<Input
|
||||||
|
id="mod-create-cron"
|
||||||
|
placeholder="0 * * * *"
|
||||||
|
value={cronExpr}
|
||||||
|
onChange={(e) => setCronExpr(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CommunitySelect
|
||||||
|
id="mod-create-community"
|
||||||
|
label="Community по умолчанию"
|
||||||
|
value={defaultCommunityId}
|
||||||
|
onValueChange={setDefaultCommunityId}
|
||||||
|
communities={communities}
|
||||||
|
nullable
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isDomains ? (
|
||||||
|
<>
|
||||||
|
<SelectField
|
||||||
|
id="mod-create-doh-policy"
|
||||||
|
label="Политика DoH"
|
||||||
|
items={DOH_POLICY_ITEMS}
|
||||||
|
value={dohResolverPolicy}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) setDohResolverPolicy(v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>DoH профили</Label>
|
||||||
|
{dohProfiles.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||||
|
{dohProfiles.map((p) => {
|
||||||
|
const checked = dohProfileIds.includes(p.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={p.id}
|
||||||
|
htmlFor={`mod-create-doh-${p.id}`}
|
||||||
|
className="flex cursor-pointer items-start gap-3"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`mod-create-doh-${p.id}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{dohProfileShortLabel(p.id, dohProfiles)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs" title={p.url}>
|
||||||
|
{p.url}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</FormDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -160,7 +160,7 @@ export function ModuleEditDialog({
|
|||||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
<Label htmlFor="mod-enabled">Включён</Label>
|
<Label htmlFor="mod-enabled">Включён</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Выключенный модуль не участвует в refresh и apply.
|
Выключенный модуль не участвует в обновлении и применении.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
|
|||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { formatDateTime } from '@/lib/modules/display'
|
import { formatDateTime } from '@/lib/modules/display'
|
||||||
import { communityLabel } from '@/lib/modules/helpers'
|
import { communityLabel } from '@/lib/modules/helpers'
|
||||||
|
import { cdnSourceKindRu } from '@/lib/ui-labels'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type {
|
import type {
|
||||||
AsEntry,
|
AsEntry,
|
||||||
@@ -145,7 +146,7 @@ export function ModuleEntriesGrid({
|
|||||||
accessorKey: 'source_kind',
|
accessorKey: 'source_kind',
|
||||||
header: 'Тип',
|
header: 'Тип',
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||||
<CategoryBadge>{row.original.source_kind}</CategoryBadge>
|
<CategoryBadge>{cdnSourceKindRu(row.original.source_kind)}</CategoryBadge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,12 +7,20 @@ import { DataGridSection } from '@/components/data-grid-shell'
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import { jobStatusRu, readyCheckRu } from '@/lib/ui-labels'
|
import {
|
||||||
|
isReadyCheckOk,
|
||||||
|
isSystemReady,
|
||||||
|
readyCheckStatusLabel,
|
||||||
|
} from '@/lib/metrics'
|
||||||
|
import { readyCheckRu } from '@/lib/ui-labels'
|
||||||
import type { ReadyStatus } from '@/queries/monitoring'
|
import type { ReadyStatus } from '@/queries/monitoring'
|
||||||
|
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
const READY_CHECK_ICONS: Record<string, typeof Database> = {
|
const READY_CHECK_ICONS: Record<string, typeof Database> = {
|
||||||
postgres: Database,
|
postgres: Database,
|
||||||
store: HardDrive,
|
store: HardDrive,
|
||||||
|
store_backend: HardDrive,
|
||||||
jobs: ListTodo,
|
jobs: ListTodo,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +29,7 @@ interface ReadyCheckRow {
|
|||||||
label: string
|
label: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
icon: typeof Database
|
icon: typeof Database
|
||||||
|
iconClassName?: string
|
||||||
status: string
|
status: string
|
||||||
statusLabel: string
|
statusLabel: string
|
||||||
}
|
}
|
||||||
@@ -34,12 +43,14 @@ export function MonitoringReadyGrid({
|
|||||||
}) {
|
}) {
|
||||||
const data = useMemo<ReadyCheckRow[]>(() => {
|
const data = useMemo<ReadyCheckRow[]>(() => {
|
||||||
const checks = ready.checks ?? {}
|
const checks = ready.checks ?? {}
|
||||||
|
const systemReady = isSystemReady(ready.status)
|
||||||
const rows: ReadyCheckRow[] = [
|
const rows: ReadyCheckRow[] = [
|
||||||
{
|
{
|
||||||
id: 'liveness',
|
id: 'liveness',
|
||||||
label: 'Живучесть',
|
label: 'Живучесть',
|
||||||
subtitle: '/v1/health',
|
subtitle: '/v1/health',
|
||||||
icon: HeartPulse,
|
icon: HeartPulse,
|
||||||
|
iconClassName: health?.ok ? 'text-success' : 'text-destructive',
|
||||||
status: health?.ok ? 'ok' : 'error',
|
status: health?.ok ? 'ok' : 'error',
|
||||||
statusLabel: health?.ok ? 'В норме' : 'Ошибка',
|
statusLabel: health?.ok ? 'В норме' : 'Ошибка',
|
||||||
},
|
},
|
||||||
@@ -48,19 +59,21 @@ export function MonitoringReadyGrid({
|
|||||||
label: 'Готовность',
|
label: 'Готовность',
|
||||||
subtitle: '/v1/ready',
|
subtitle: '/v1/ready',
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
status: ready.status === 'ok' ? 'ok' : 'warning',
|
iconClassName: systemReady ? 'text-success' : 'text-warning',
|
||||||
statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'),
|
status: systemReady ? 'ok' : 'warning',
|
||||||
|
statusLabel: systemReady ? 'Готов' : 'Не готов',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
for (const key of Object.keys(checks)) {
|
for (const key of Object.keys(checks)) {
|
||||||
const value = checks[key]
|
const value = checks[key]
|
||||||
const ok = typeof value === 'boolean' ? value : value?.ok !== false
|
const ok = isReadyCheckOk(value)
|
||||||
rows.push({
|
rows.push({
|
||||||
id: key,
|
id: key,
|
||||||
label: readyCheckRu(key),
|
label: readyCheckRu(key),
|
||||||
icon: READY_CHECK_ICONS[key] ?? ListTodo,
|
icon: READY_CHECK_ICONS[key] ?? ListTodo,
|
||||||
|
iconClassName: ok ? 'text-success' : 'text-destructive',
|
||||||
status: ok ? 'ok' : 'error',
|
status: ok ? 'ok' : 'error',
|
||||||
statusLabel: ok ? 'В норме' : 'Ошибка',
|
statusLabel: readyCheckStatusLabel(value, ok),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
@@ -74,8 +87,17 @@ export function MonitoringReadyGrid({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const Icon = row.original.icon
|
const Icon = row.original.icon
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2.5 py-0.5">
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
<Item
|
||||||
|
className={cn(
|
||||||
|
'border-background bg-muted flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-3.5',
|
||||||
|
row.original.iconClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
<Icon />
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
title={row.original.label}
|
title={row.original.label}
|
||||||
subtitle={row.original.subtitle}
|
subtitle={row.original.subtitle}
|
||||||
@@ -90,7 +112,9 @@ export function MonitoringReadyGrid({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: 'Статус',
|
header: 'Статус',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
<div className="flex justify-start">
|
||||||
|
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Статус' },
|
meta: { headerTitle: 'Статус' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
|
|||||||
function speakerLabel(s: SpeakerRow): string {
|
function speakerLabel(s: SpeakerRow): string {
|
||||||
if (s.role === 'master') {
|
if (s.role === 'master') {
|
||||||
const host = s.agent_domain ?? s.endpoint
|
const host = s.agent_domain ?? s.endpoint
|
||||||
return host ? `CP · ${host}` : 'CP (master)'
|
return host ? `Плоскость · ${host}` : 'Плоскость управления'
|
||||||
}
|
}
|
||||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ const filterFields: FilterFieldConfig[] = [
|
|||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-48',
|
className: 'w-48',
|
||||||
placeholder: 'IP или Neighbor ID…',
|
placeholder: 'IP или ID соседа…',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
id: 'neighbor_id',
|
id: 'neighbor_id',
|
||||||
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Neighbor ID" />
|
<DataGridColumnHeader column={column} title="ID соседа" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
@@ -103,7 +103,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
accent="mono"
|
accent="mono"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Neighbor ID' },
|
meta: { headerTitle: 'ID соседа' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'remote_asn',
|
accessorKey: 'remote_asn',
|
||||||
@@ -212,7 +212,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
title="Одобрить пира"
|
title="Одобрить пира"
|
||||||
description={
|
description={
|
||||||
approveTarget
|
approveTarget
|
||||||
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
? `ID соседа ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className="sm:max-w-sm"
|
className="sm:max-w-sm"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function NetworkKpi({
|
|||||||
id: 'peers-established',
|
id: 'peers-established',
|
||||||
icon: <Share2 aria-hidden />,
|
icon: <Share2 aria-hidden />,
|
||||||
iconClassName: 'text-success',
|
iconClassName: 'text-success',
|
||||||
label: 'Пиры Established',
|
label: 'Пиры установлены',
|
||||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant="success-light" size="sm">
|
<Badge variant="success-light" size="sm">
|
||||||
@@ -44,7 +44,7 @@ export function NetworkKpi({
|
|||||||
id: 'speakers-online',
|
id: 'speakers-online',
|
||||||
icon: <ServerCog aria-hidden />,
|
icon: <ServerCog aria-hidden />,
|
||||||
iconClassName: 'text-info',
|
iconClassName: 'text-info',
|
||||||
label: 'Спикеры online',
|
label: 'Спикеры в сети',
|
||||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||||
footer: (
|
footer: (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -55,7 +55,7 @@ export function NetworkKpi({
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{loading ? '…' : 'live'}
|
{loading ? '…' : 'в сети'}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
to: '/network',
|
to: '/network',
|
||||||
|
|||||||
@@ -32,18 +32,18 @@ function peerDeleteLabel(p: PeerRow): string {
|
|||||||
|
|
||||||
const PEER_TABS = [
|
const PEER_TABS = [
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
{ id: 'established', label: 'Established' },
|
{ id: 'established', label: 'Установлены' },
|
||||||
{ id: 'pending', label: 'Ожидание' },
|
{ id: 'pending', label: 'Ожидание' },
|
||||||
{ id: 'disabled', label: 'Выключены' },
|
{ id: 'disabled', label: 'Выключены' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const SESSION_STATE_OPTIONS = [
|
const SESSION_STATE_OPTIONS = [
|
||||||
{ value: 'Established', label: 'Established' },
|
{ value: 'Established', label: 'Установлена' },
|
||||||
{ value: 'Idle', label: 'Idle' },
|
{ value: 'Idle', label: 'Простой' },
|
||||||
{ value: 'Active', label: 'Active' },
|
{ value: 'Active', label: 'Поиск' },
|
||||||
{ value: 'Connect', label: 'Connect' },
|
{ value: 'Connect', label: 'Соединение' },
|
||||||
{ value: 'OpenSent', label: 'OpenSent' },
|
{ value: 'OpenSent', label: 'Open отправлен' },
|
||||||
{ value: 'OpenConfirm', label: 'OpenConfirm' },
|
{ value: 'OpenConfirm', label: 'Open подтверждён' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDefaultPeerFilters(): Filter[] {
|
function createDefaultPeerFilters(): Filter[] {
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ function speakerDeleteLabel(s: SpeakerRow): string {
|
|||||||
|
|
||||||
const SPEAKER_TABS = [
|
const SPEAKER_TABS = [
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
{ id: 'online', label: 'Online' },
|
{ id: 'online', label: 'В сети' },
|
||||||
{ id: 'offline', label: 'Offline' },
|
{ id: 'offline', label: 'Не в сети' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
{ value: 'primary', label: 'primary' },
|
{ value: 'primary', label: 'Основной' },
|
||||||
{ value: 'secondary', label: 'secondary' },
|
{ value: 'secondary', label: 'Резервный' },
|
||||||
{ value: 'speaker', label: 'speaker' },
|
{ value: 'speaker', label: 'Спикер' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDefaultSpeakerFilters(): Filter[] {
|
function createDefaultSpeakerFilters(): Filter[] {
|
||||||
@@ -52,7 +52,7 @@ const speakerFilterFields: FilterFieldConfig[] = [
|
|||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-52',
|
className: 'w-52',
|
||||||
placeholder: 'endpoint…',
|
placeholder: 'Адрес агента…',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'role',
|
key: 'role',
|
||||||
@@ -128,7 +128,7 @@ export function NetworkSpeakersCard({
|
|||||||
<>
|
<>
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Спикеры"
|
title="Спикеры"
|
||||||
description="BIRD-агенты на нодах tenant"
|
description="BIRD-агенты на нодах арендатора"
|
||||||
tabs={SPEAKER_TABS}
|
tabs={SPEAKER_TABS}
|
||||||
tabFilter={speakerTabFilter}
|
tabFilter={speakerTabFilter}
|
||||||
filterFields={speakerFilterFields}
|
filterFields={speakerFilterFields}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { speakerOnlineLabel } from '@/lib/ui-labels'
|
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
|
||||||
import type { SpeakerRow } from '@/types/api'
|
import type { SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||||
@@ -20,7 +20,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'role',
|
accessorKey: 'role',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
cell: ({ row }) => <CategoryBadge>{speakerRoleRu(row.original.role)}</CategoryBadge>,
|
||||||
meta: { headerTitle: 'Роль' },
|
meta: { headerTitle: 'Роль' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface PeerFormDialogProps {
|
|||||||
function speakerLabel(s: SpeakerRow): string {
|
function speakerLabel(s: SpeakerRow): string {
|
||||||
if (s.role === 'master') {
|
if (s.role === 'master') {
|
||||||
const host = s.agent_domain ?? s.endpoint
|
const host = s.agent_domain ?? s.endpoint
|
||||||
return host ? `CP · ${host}` : 'CP (master)'
|
return host ? `Плоскость · ${host}` : 'Плоскость управления'
|
||||||
}
|
}
|
||||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ export function PeerFormDialog({
|
|||||||
}
|
}
|
||||||
const asn = Number(remoteAsn)
|
const asn = Number(remoteAsn)
|
||||||
if (!asn || asn <= 0) {
|
if (!asn || asn <= 0) {
|
||||||
toast.error('Remote ASN должен быть больше 0')
|
toast.error('ASN соседа должен быть больше 0')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const body: BgpPeerCreate = {
|
const body: BgpPeerCreate = {
|
||||||
@@ -132,7 +132,7 @@ export function PeerFormDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="peer-asn">Remote ASN</Label>
|
<Label htmlFor="peer-asn">ASN соседа</Label>
|
||||||
<Input
|
<Input
|
||||||
id="peer-asn"
|
id="peer-asn"
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
const ep =
|
const ep =
|
||||||
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
|
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
|
||||||
if (!ep) {
|
if (!ep) {
|
||||||
toast.error('Укажите endpoint или agent domain')
|
toast.error('Укажите конечную точку или домен агента')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const body: BgpSpeakerCreate = {
|
const body: BgpSpeakerCreate = {
|
||||||
@@ -95,7 +95,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title="Новый спикер"
|
title="Новый спикер"
|
||||||
description="BIRD-агент на ноде реплики или control plane"
|
description="BIRD-агент на ноде реплики или плоскости управления"
|
||||||
className="sm:max-w-md"
|
className="sm:max-w-md"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -122,7 +122,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
label="Роль"
|
label="Роль"
|
||||||
items={[
|
items={[
|
||||||
{ value: 'replica', label: 'Реплика' },
|
{ value: 'replica', label: 'Реплика' },
|
||||||
{ value: 'master', label: 'Мастер (CP)' },
|
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||||
]}
|
]}
|
||||||
value={role}
|
value={role}
|
||||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||||
@@ -146,7 +146,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
|
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
||||||
<Input
|
<Input
|
||||||
id="speaker-bgp-source"
|
id="speaker-bgp-source"
|
||||||
placeholder="203.0.113.10"
|
placeholder="203.0.113.10"
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export function OperationsJobsGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
title={jobKindRu(row.original.kind)}
|
title={jobKindRu(row.original.kind)}
|
||||||
accent="mono"
|
|
||||||
subtitle={
|
subtitle={
|
||||||
row.original.meta?.module_id
|
row.original.meta?.module_id
|
||||||
? (nameById.get(String(row.original.meta.module_id)) ??
|
? (nameById.get(String(row.original.meta.module_id)) ??
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function OperationsRevisionsGrid({
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
||||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
|
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
||||||
confirmLabel="Откатить"
|
confirmLabel="Откатить"
|
||||||
destructive
|
destructive
|
||||||
onConfirm={() => rollbackMutation.mutate(row.original.id)}
|
onConfirm={() => rollbackMutation.mutate(row.original.id)}
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ interface PageHeaderProps {
|
|||||||
|
|
||||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
{description ? (
|
||||||
|
<p className="text-muted-foreground max-w-prose text-sm break-words">{description}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function PanelCard({
|
|||||||
<Frame
|
<Frame
|
||||||
dense
|
dense
|
||||||
spacing={spacing}
|
spacing={spacing}
|
||||||
className={cn(panelCardClassName, className)}
|
className={cn(panelCardClassName, 'min-w-0', className)}
|
||||||
>
|
>
|
||||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
{hasHeader ? (
|
{hasHeader ? (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '@evobgp/ui/components/chart'
|
} from '@evobgp/ui/components/chart'
|
||||||
import type { BreakdownSlice } from '@/lib/metrics'
|
import type { BreakdownSlice } from '@/lib/metrics'
|
||||||
|
|
||||||
/** chart-27 / chart-13 inspired donut in PanelCard. */
|
/** chart-27 / chart-13 inspired donut in PanelCard (Frame surface). */
|
||||||
export function DonutBreakdownCard({
|
export function DonutBreakdownCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -30,13 +30,24 @@ export function DonutBreakdownCard({
|
|||||||
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
|
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelCard title={title} description={description} className="h-full">
|
<PanelCard
|
||||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center">
|
title={title}
|
||||||
|
description={description}
|
||||||
|
className="h-full"
|
||||||
|
actions={
|
||||||
|
badge ? (
|
||||||
|
<Badge variant="success-light" className="hidden sm:inline-flex">
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-start gap-4 p-4 sm:flex-row sm:items-center sm:justify-start sm:gap-6">
|
||||||
{total === 0 ? (
|
{total === 0 ? (
|
||||||
<p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p>
|
<p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="relative mx-auto size-36 shrink-0">
|
<div className="relative mx-auto size-36 shrink-0 sm:mx-0">
|
||||||
<ChartContainer config={chartConfig} className="aspect-square size-36">
|
<ChartContainer config={chartConfig} className="aspect-square size-36">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
@@ -59,11 +70,11 @@ export function DonutBreakdownCard({
|
|||||||
<span className="text-lg font-semibold tabular-nums">{total}</span>
|
<span className="text-lg font-semibold tabular-nums">{total}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul className="min-w-0 flex-1 space-y-2">
|
<ul className="flex w-full min-w-0 max-w-xs flex-col gap-2 sm:w-auto sm:min-w-[10rem]">
|
||||||
{slices.map((slice) => {
|
{slices.map((slice) => {
|
||||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||||
return (
|
return (
|
||||||
<li key={slice.key} className="flex items-center justify-between gap-2 text-sm">
|
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="size-2.5 shrink-0 rounded-full"
|
className="size-2.5 shrink-0 rounded-full"
|
||||||
@@ -81,11 +92,6 @@ export function DonutBreakdownCard({
|
|||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{badge ? (
|
|
||||||
<Badge variant="success-light" className="absolute top-4 right-4 hidden sm:flex">
|
|
||||||
{badge}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import type { ReactNode } from 'react'
|
|||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { IconStack } from '@/components/reui/icon-stack'
|
import { IconStack } from '@/components/reui/icon-stack'
|
||||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
import {
|
||||||
|
Frame,
|
||||||
|
FramePanel,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyDescription,
|
EmptyDescription,
|
||||||
@@ -11,7 +14,7 @@ import {
|
|||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from '@evobgp/ui/components/empty'
|
} from '@evobgp/ui/components/empty'
|
||||||
|
|
||||||
/** empty-state-11 pattern adapted to Card surface. */
|
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
|
||||||
export function IllustratedEmptyState({
|
export function IllustratedEmptyState({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -24,8 +27,8 @@ export function IllustratedEmptyState({
|
|||||||
action?: ReactNode
|
action?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-muted min-h-[240px] p-0 shadow-none">
|
<Frame dense spacing="sm" className="w-full">
|
||||||
<CardContent className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
||||||
{action ? <div className="w-full self-end">{action}</div> : null}
|
{action ? <div className="w-full self-end">{action}</div> : null}
|
||||||
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
||||||
<EmptyHeader className="items-center gap-5 text-center">
|
<EmptyHeader className="items-center gap-5 text-center">
|
||||||
@@ -40,7 +43,7 @@ export function IllustratedEmptyState({
|
|||||||
</div>
|
</div>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Badge } from '@/components/reui/badge'
|
|||||||
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
||||||
import { PanelCorners } from '@/components/patterns/panel-corners'
|
import { PanelCorners } from '@/components/patterns/panel-corners'
|
||||||
import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles'
|
import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles'
|
||||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
|
||||||
export type KpiSparklineMetric = {
|
export type KpiSparklineMetric = {
|
||||||
id: string
|
id: string
|
||||||
@@ -44,21 +44,21 @@ function Sparkline({ values, color }: { values: readonly number[]; color: string
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** KPI card with sparkline (dashboard-5 / chart-15 pattern, Card surface). */
|
/** KPI sparkline tile — chart-15 Frame DNA. Preview: https://reui.io/preview/base/chart-15 */
|
||||||
export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
|
export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
|
||||||
const tone = toneStyles[metric.tone]
|
const tone = toneStyles[metric.tone]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative overflow-hidden p-0">
|
<Frame dense spacing="sm" className="relative overflow-hidden">
|
||||||
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
<FramePanel className="relative isolate flex min-h-[7.25rem] flex-col justify-between gap-5 overflow-hidden p-4">
|
||||||
<PanelCorners />
|
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
||||||
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
|
<PanelCorners />
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
<div className="relative z-10 flex min-w-0 flex-col gap-0.5">
|
||||||
<span className="text-foreground truncate text-sm leading-4 font-semibold">{metric.title}</span>
|
<span className="text-foreground truncate text-sm leading-4 font-semibold">{metric.title}</span>
|
||||||
<span className="text-muted-foreground truncate text-xs leading-4">{metric.label}</span>
|
<span className="text-muted-foreground truncate text-xs leading-4">{metric.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end justify-between gap-4">
|
<div className="relative z-10 flex items-end justify-between gap-4">
|
||||||
<div className="min-w-0 space-y-2.5">
|
<div className="flex min-w-0 flex-col gap-2.5">
|
||||||
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight tabular-nums">
|
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight tabular-nums">
|
||||||
{metric.value}
|
{metric.value}
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +71,7 @@ export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
|
|||||||
</div>
|
</div>
|
||||||
<Sparkline values={metric.sparkline} color={tone.stroke} />
|
<Sparkline values={metric.sparkline} color={tone.stroke} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import { Boxes, Plus } from 'lucide-react'
|
import { Boxes, Plus } from 'lucide-react'
|
||||||
|
|
||||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
/** empty-state-3 pattern for first module. */
|
/** empty-state-3 pattern for first module. */
|
||||||
export function ProjectsEmptyState() {
|
export function ProjectsEmptyState({
|
||||||
|
canCreate = true,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
canCreate?: boolean
|
||||||
|
onCreate?: () => void
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<IllustratedEmptyState
|
<IllustratedEmptyState
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
title="Создайте первый модуль"
|
title="Создайте первый модуль"
|
||||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||||
action={
|
action={
|
||||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
canCreate && onCreate ? (
|
||||||
<Plus />
|
<Button size="sm" onClick={onCreate}>
|
||||||
Новый модуль
|
<Plus />
|
||||||
</Button>
|
Новый модуль
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,23 +34,29 @@ export function QueryState<T>({
|
|||||||
children,
|
children,
|
||||||
}: QueryStateProps<T>) {
|
}: QueryStateProps<T>) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <>{skeleton ?? <DefaultSkeleton />}</>
|
return (
|
||||||
|
<div role="status" aria-live="polite" aria-busy="true">
|
||||||
|
{skeleton ?? <DefaultSkeleton />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<div role="alert">
|
||||||
icon={<AlertCircle className="size-8" />}
|
<EmptyState
|
||||||
title="Ошибка загрузки"
|
icon={AlertCircle}
|
||||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
title="Ошибка загрузки"
|
||||||
action={
|
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||||
onRetry ? (
|
action={
|
||||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
onRetry ? (
|
||||||
<RefreshCwIcon data-icon="inline-start" />
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
Повторить
|
<RefreshCwIcon data-icon="inline-start" />
|
||||||
</Button>
|
Повторить
|
||||||
) : null
|
</Button>
|
||||||
}
|
) : null
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (empty || data == null) {
|
if (empty || data == null) {
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ export function FrameDataGrid({
|
|||||||
}) {
|
}) {
|
||||||
const hasHeader = Boolean(title || description || actions)
|
const hasHeader = Boolean(title || description || actions)
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
{hasHeader ? (
|
{hasHeader ? (
|
||||||
<FrameHeader className="flex-row items-start justify-between gap-3 border-b">
|
<FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||||
{description ? (
|
{description ? (
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
|||||||
export { OpsDashboard } from './ops-dashboard'
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
export { FrameDataGrid } from './frame-data-grid'
|
export { FrameDataGrid } from './frame-data-grid'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
export { SettingsShell } from './settings-shell'
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
|
/**
|
||||||
|
* Shared grid column classes for hybrid KPI / Quick Actions tiles.
|
||||||
|
* Container queries only — never 5–6 columns on laptop width (tiles overlap).
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
export function kpiCols(count: number): string {
|
export function kpiCols(count: number): string {
|
||||||
if (count <= 1) return 'grid-cols-1'
|
if (count <= 1) return 'grid-cols-1'
|
||||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
if (count <= 6) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
|
||||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4'
|
||||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { kpiCols } from './kpi-cols'
|
import { kpiCols } from './kpi-cols'
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||||
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
{item.icon ? (
|
{item.icon ? (
|
||||||
<Item
|
<IconTile
|
||||||
className={cn(
|
variant="elevated"
|
||||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
aria-hidden="true"
|
||||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
{item.icon}
|
||||||
{item.icon}
|
</IconTile>
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
@@ -308,7 +305,7 @@ export function KpiStatGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
<Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
|
||||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||||
{list.map((item, index) => (
|
{list.map((item, index) => (
|
||||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
|
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
|
||||||
@@ -27,7 +20,7 @@ interface OpsDashboardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rootClassName =
|
const rootClassName =
|
||||||
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
'text-foreground @container flex w-full min-w-0 flex-col gap-4 md:gap-6'
|
||||||
|
|
||||||
function OpsDashboardSkeleton() {
|
function OpsDashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -37,7 +30,7 @@ function OpsDashboardSkeleton() {
|
|||||||
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||||
</header>
|
</header>
|
||||||
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||||
<div className="grid gap-2 @3xl:grid-cols-2">
|
<div className="flex min-w-0 flex-col gap-4">
|
||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
@@ -47,9 +40,11 @@ function OpsDashboardSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ops dashboard: KPI → optional Quick Actions → charts → attention queue.
|
* Ops dashboard: KPI → optional Quick Actions → charts → queue.
|
||||||
|
* Queue is a sibling Frame grid — never wrap FrameDataGrid inside another Frame.
|
||||||
* @see https://reui.io/preview/base/dashboard-1
|
* @see https://reui.io/preview/base/dashboard-1
|
||||||
* @see https://reui.io/preview/base/stats-12
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
*/
|
*/
|
||||||
export function OpsDashboard({
|
export function OpsDashboard({
|
||||||
title = 'Панель управления',
|
title = 'Панель управления',
|
||||||
@@ -77,21 +72,18 @@ export function OpsDashboard({
|
|||||||
|
|
||||||
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
|
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
|
||||||
|
|
||||||
<section
|
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
|
||||||
aria-label="Аналитика"
|
|
||||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
|
||||||
>
|
|
||||||
{charts}
|
{charts}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label={queueTitle}>
|
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<FrameHeader>
|
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||||
<FrameTitle>{queueTitle}</FrameTitle>
|
{queueDescription ? (
|
||||||
<FrameDescription>{queueDescription}</FrameDescription>
|
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
|
||||||
</FrameHeader>
|
) : null}
|
||||||
<FramePanel>{queue}</FramePanel>
|
</div>
|
||||||
</Frame>
|
{queue}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { kpiCols } from './kpi-cols'
|
import { kpiCols } from './kpi-cols'
|
||||||
|
|
||||||
@@ -54,16 +54,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
{action.icon ? (
|
{action.icon ? (
|
||||||
<Item
|
<IconTile
|
||||||
className={cn(
|
variant="elevated"
|
||||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
aria-hidden="true"
|
||||||
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
{action.icon}
|
||||||
{action.icon}
|
</IconTile>
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
AlertTitle,
|
AlertTitle,
|
||||||
} from '@/components/reui/alert'
|
} from '@/components/reui/alert'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||||
|
import { FILTERS_I18N_RU } from '@/lib/filters-i18n'
|
||||||
import { applyFiltersToData } from './filter-utils'
|
import { applyFiltersToData } from './filter-utils'
|
||||||
|
|
||||||
export interface ResourcePageTab {
|
export interface ResourcePageTab {
|
||||||
@@ -322,6 +324,7 @@ export function ResourcePage<T extends object>({
|
|||||||
fields={filterFields}
|
fields={filterFields}
|
||||||
onChange={handleFiltersChange}
|
onChange={handleFiltersChange}
|
||||||
size="default"
|
size="default"
|
||||||
|
i18n={FILTERS_I18N_RU}
|
||||||
trigger={
|
trigger={
|
||||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||||
<FilterIcon className="size-4" aria-hidden="true" />
|
<FilterIcon className="size-4" aria-hidden="true" />
|
||||||
@@ -355,11 +358,8 @@ export function ResourcePage<T extends object>({
|
|||||||
|
|
||||||
<FrameFooter>
|
<FrameFooter>
|
||||||
<DataGridPagination
|
<DataGridPagination
|
||||||
|
{...DATA_GRID_PAGINATION_RU}
|
||||||
sizes={[5, 10, 20, 50]}
|
sizes={[5, 10, 20, 50]}
|
||||||
rowsPerPageLabel="Строк на странице"
|
|
||||||
info="{from} - {to} of {count}"
|
|
||||||
previousPageLabel="Предыдущая"
|
|
||||||
nextPageLabel="Следующая"
|
|
||||||
/>
|
/>
|
||||||
</FrameFooter>
|
</FrameFooter>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
|
|||||||
@@ -1,104 +1,17 @@
|
|||||||
import type { ReactNode } from 'react'
|
import { Outlet } from '@tanstack/react-router'
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
|
||||||
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
|
|
||||||
export interface SettingsTabConfig {
|
|
||||||
id: string
|
|
||||||
to: string
|
|
||||||
label: string
|
|
||||||
icon?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default nav for EvoBGP settings routes (`/settings`, `/tenant-settings`). */
|
|
||||||
const DEFAULT_TABS: SettingsTabConfig[] = [
|
|
||||||
{
|
|
||||||
id: 'ui',
|
|
||||||
to: '/settings',
|
|
||||||
label: 'Настройки UI',
|
|
||||||
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tenant',
|
|
||||||
to: '/tenant-settings',
|
|
||||||
label: 'Настройки BIRD',
|
|
||||||
icon: <ServerCogIcon className="size-4" aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
interface SettingsShellProps {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
tabs?: SettingsTabConfig[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings shell — Frame surface, no hairline Separator under header.
|
* Settings layout — page chrome only.
|
||||||
* @see https://reui.io/preview/base/settings-16
|
* Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
|
||||||
* @see https://reui.io/preview/base/settings-3
|
* @see https://reui.io/preview/base/settings-7
|
||||||
|
* @see https://reui.io/blocks
|
||||||
*/
|
*/
|
||||||
export function SettingsShell({
|
export function SettingsShell() {
|
||||||
title = 'Настройки',
|
|
||||||
description = 'Подключение UI и параметры плоскости управления',
|
|
||||||
tabs = DEFAULT_TABS,
|
|
||||||
}: SettingsShellProps) {
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
<Outlet />
|
||||||
<PageHeader title={title} description={description} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex gap-5',
|
|
||||||
isMobile ? 'flex-col' : 'flex-row items-start',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tabs.length > 1 ? (
|
|
||||||
<nav
|
|
||||||
aria-label="Разделы настроек"
|
|
||||||
className={cn(
|
|
||||||
'flex gap-1',
|
|
||||||
isMobile
|
|
||||||
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
|
||||||
: 'w-44 shrink-0 flex-col',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const isActive = pathname.startsWith(tab.to)
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.id}
|
|
||||||
to={tab.to}
|
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
|
||||||
isMobile && 'shrink-0',
|
|
||||||
!isMobile && 'w-full',
|
|
||||||
isActive
|
|
||||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
|
||||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.icon}
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function DataGridColumnFilter<TData, TValue>({
|
|||||||
<div className="hidden space-x-1 lg:flex">
|
<div className="hidden space-x-1 lg:flex">
|
||||||
{selectedValues.size > 2 ? (
|
{selectedValues.size > 2 ? (
|
||||||
<Badge variant="secondary" className="px-1 font-normal">
|
<Badge variant="secondary" className="px-1 font-normal">
|
||||||
{selectedValues.size} selected
|
{selectedValues.size} выбрано
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
options
|
options
|
||||||
@@ -94,7 +94,7 @@ function DataGridColumnFilter<TData, TValue>({
|
|||||||
<div className="max-h-[300px] overflow-y-auto">
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
{filteredOptions.length === 0 ? (
|
{filteredOptions.length === 0 ? (
|
||||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||||
No results found.
|
Ничего не найдено.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
@@ -170,7 +170,7 @@ function DataGridColumnFilter<TData, TValue>({
|
|||||||
}}
|
}}
|
||||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
||||||
>
|
>
|
||||||
Clear filters
|
Сбросить фильтры
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
sizes: [5, 10, 25, 50, 100],
|
sizes: [5, 10, 25, 50, 100],
|
||||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||||
moreLimit: 5,
|
moreLimit: 5,
|
||||||
info: "{from} - {to} of {count}",
|
info: "{from}–{to} из {count}",
|
||||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||||
rowsPerPageLabel: "Rows per page",
|
rowsPerPageLabel: "Строк на странице",
|
||||||
previousPageLabel: "Go to previous page",
|
previousPageLabel: "Предыдущая страница",
|
||||||
nextPageLabel: "Go to next page",
|
nextPageLabel: "Следующая страница",
|
||||||
ellipsisText: "...",
|
ellipsisText: "...",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
|||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label="Drag to reorder row"
|
aria-label="Перетащить строку"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<GripHorizontalIcon aria-hidden="true" />
|
<GripHorizontalIcon aria-hidden="true" />
|
||||||
@@ -89,7 +89,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
|||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label="Drag to reorder row"
|
aria-label="Перетащить строку"
|
||||||
{...context.attributes}
|
{...context.attributes}
|
||||||
{...context.listeners}
|
{...context.listeners}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function DataGridTableDndHeader<TData>({
|
|||||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
aria-label="Drag to reorder"
|
aria-label="Перетащить для изменения порядка"
|
||||||
>
|
>
|
||||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -397,9 +397,9 @@ function DataGridTableVirtual<TData>({
|
|||||||
|
|
||||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||||
const loadingMoreMessage =
|
const loadingMoreMessage =
|
||||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
props.fetchingMoreMessage || props.loadingMessage || "Загрузка…"
|
||||||
const allRowsLoadedMessage =
|
const allRowsLoadedMessage =
|
||||||
props.allRowsLoadedMessage || "All records loaded"
|
props.allRowsLoadedMessage || "Все записи загружены"
|
||||||
|
|
||||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
setViewportElements({
|
setViewportElements({
|
||||||
|
|||||||
@@ -1241,7 +1241,7 @@ function DataGridTableEmpty() {
|
|||||||
colSpan={Math.max(visibleColumnCount, 1)}
|
colSpan={Math.max(visibleColumnCount, 1)}
|
||||||
className="text-muted-foreground py-6 text-center text-sm"
|
className="text-muted-foreground py-6 text-center text-sm"
|
||||||
>
|
>
|
||||||
{props.emptyMessage || "No data available"}
|
{props.emptyMessage || "Нет данных"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
@@ -1254,7 +1254,7 @@ function DataGridTableLoader() {
|
|||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
<div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium">
|
<div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium">
|
||||||
<Spinner className="size-5 opacity-60" />
|
<Spinner className="size-5 opacity-60" />
|
||||||
{props.loadingMessage || "Loading..."}
|
{props.loadingMessage || "Загрузка…"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1266,7 +1266,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={isPinned ? "Unpin row" : "Pin row"}
|
aria-label={isPinned ? "Открепить строку" : "Закрепить строку"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isPinned) {
|
if (isPinned) {
|
||||||
row.pin(false)
|
row.pin(false)
|
||||||
@@ -1322,7 +1322,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
aria-label="Select row"
|
aria-label="Выбрать строку"
|
||||||
className="align-[inherit]"
|
className="align-[inherit]"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -1341,7 +1341,7 @@ function DataGridTableRowSelectAll() {
|
|||||||
indeterminate={isSomeSelected && !isAllSelected}
|
indeterminate={isSomeSelected && !isAllSelected}
|
||||||
disabled={isLoading || recordCount === 0}
|
disabled={isLoading || recordCount === 0}
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
aria-label="Select all"
|
aria-label="Выбрать все"
|
||||||
className="align-[inherit]"
|
className="align-[inherit]"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -1408,7 +1408,7 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
|
|||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{props.loadingMessage || "Loading..."}
|
{props.loadingMessage || "Загрузка…"}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -123,75 +123,70 @@ export interface FilterI18nConfig {
|
|||||||
|
|
||||||
// Default English i18n configuration
|
// Default English i18n configuration
|
||||||
export const DEFAULT_I18N: FilterI18nConfig = {
|
export const DEFAULT_I18N: FilterI18nConfig = {
|
||||||
// UI Labels
|
addFilter: "Фильтр",
|
||||||
addFilter: "Filter",
|
searchFields: "Фильтр…",
|
||||||
searchFields: "Filter...",
|
noFieldsFound: "Поля не найдены.",
|
||||||
noFieldsFound: "No filters found.",
|
noResultsFound: "Ничего не найдено.",
|
||||||
noResultsFound: "No results found.",
|
select: "Выбрать…",
|
||||||
select: "Select...",
|
true: "Да",
|
||||||
true: "True",
|
false: "Нет",
|
||||||
false: "False",
|
min: "Мин.",
|
||||||
min: "Min",
|
max: "Макс.",
|
||||||
max: "Max",
|
to: "—",
|
||||||
to: "to",
|
typeAndPressEnter: "Введите и нажмите Enter",
|
||||||
typeAndPressEnter: "Type and press Enter to add tag",
|
selected: "выбрано",
|
||||||
selected: "selected",
|
selectedCount: "выбрано",
|
||||||
selectedCount: "selected",
|
|
||||||
percent: "%",
|
percent: "%",
|
||||||
defaultCurrency: "$",
|
defaultCurrency: "$",
|
||||||
defaultColor: "#000000",
|
defaultColor: "#000000",
|
||||||
addFilterTitle: "Add filter",
|
addFilterTitle: "Добавить фильтр",
|
||||||
|
|
||||||
// Operators
|
|
||||||
operators: {
|
operators: {
|
||||||
is: "is",
|
is: "равно",
|
||||||
isNot: "is not",
|
isNot: "не равно",
|
||||||
isAnyOf: "is any of",
|
isAnyOf: "любое из",
|
||||||
isNotAnyOf: "is not any of",
|
isNotAnyOf: "кроме",
|
||||||
includesAll: "includes all",
|
includesAll: "включает все",
|
||||||
excludesAll: "excludes all",
|
excludesAll: "исключает все",
|
||||||
before: "before",
|
before: "до",
|
||||||
after: "after",
|
after: "после",
|
||||||
between: "between",
|
between: "между",
|
||||||
notBetween: "not between",
|
notBetween: "вне диапазона",
|
||||||
contains: "contains",
|
contains: "содержит",
|
||||||
notContains: "does not contain",
|
notContains: "не содержит",
|
||||||
startsWith: "starts with",
|
startsWith: "начинается с",
|
||||||
endsWith: "ends with",
|
endsWith: "заканчивается на",
|
||||||
isExactly: "is exactly",
|
isExactly: "точно",
|
||||||
equals: "equals",
|
equals: "равно",
|
||||||
notEquals: "not equals",
|
notEquals: "не равно",
|
||||||
greaterThan: "greater than",
|
greaterThan: "больше",
|
||||||
lessThan: "less than",
|
lessThan: "меньше",
|
||||||
overlaps: "overlaps",
|
overlaps: "пересекается",
|
||||||
includes: "includes",
|
includes: "включает",
|
||||||
excludes: "excludes",
|
excludes: "исключает",
|
||||||
includesAllOf: "includes all of",
|
includesAllOf: "включает все",
|
||||||
includesAnyOf: "includes any of",
|
includesAnyOf: "включает любое",
|
||||||
empty: "is empty",
|
empty: "пусто",
|
||||||
notEmpty: "is not empty",
|
notEmpty: "не пусто",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Placeholders
|
|
||||||
placeholders: {
|
placeholders: {
|
||||||
enterField: (fieldType: string) => `Enter ${fieldType}...`,
|
enterField: (fieldType: string) => `Введите ${fieldType}…`,
|
||||||
selectField: "Select...",
|
selectField: "Выбрать…",
|
||||||
searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`,
|
searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}…`,
|
||||||
enterKey: "Enter key...",
|
enterKey: "Введите ключ…",
|
||||||
enterValue: "Enter value...",
|
enterValue: "Введите значение…",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
helpers: {
|
helpers: {
|
||||||
formatOperator: (operator: string) => operator.replace(/_/g, " "),
|
formatOperator: (operator: string) => operator.replace(/_/g, " "),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Validation
|
|
||||||
validation: {
|
validation: {
|
||||||
invalidEmail: "Invalid email format",
|
invalidEmail: "Некорректный email",
|
||||||
invalidUrl: "Invalid URL format",
|
invalidUrl: "Некорректный URL",
|
||||||
invalidTel: "Invalid phone format",
|
invalidTel: "Некорректный телефон",
|
||||||
invalid: "Invalid input format",
|
invalid: "Некорректное значение",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS variable architecture:
|
||||||
|
*
|
||||||
|
* The root owns four variables so every part of the tile stays in proportion
|
||||||
|
* and stays overridable from a single `className`:
|
||||||
|
*
|
||||||
|
* --icon-tile-size tile width/height
|
||||||
|
* --icon-tile-icon-size glyph size applied to child svgs
|
||||||
|
* --icon-tile-radius corner radius (also drives the nested inner card)
|
||||||
|
* --icon-tile-inset gap between the outer ring and the inner card
|
||||||
|
*
|
||||||
|
* The `frame` and `soft` variants paint their inner card with an `::after`
|
||||||
|
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
|
||||||
|
* context, so the negative z-index pseudo paints above the root background but
|
||||||
|
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
|
||||||
|
*
|
||||||
|
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
|
||||||
|
* a single text color class (e.g. `text-success`) retints the whole tile. They
|
||||||
|
* default to `text-primary`; override it to recolor without touching internals.
|
||||||
|
*/
|
||||||
|
const iconTileVariants = cva(
|
||||||
|
[
|
||||||
|
"relative inline-flex shrink-0 items-center justify-center align-middle",
|
||||||
|
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
/** Plain bordered surface. The quiet default for list rows and toolbars. */
|
||||||
|
outline: "border border-border bg-background dark:bg-input/32",
|
||||||
|
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
|
||||||
|
elevated:
|
||||||
|
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
|
||||||
|
/**
|
||||||
|
* Tinted double container: an opacity-filled outer ring with no border
|
||||||
|
* around a bordered inner card, all derived from `currentColor`. The
|
||||||
|
* quiet, colorful sibling of `frame`. Retint with a text color class.
|
||||||
|
*/
|
||||||
|
soft: [
|
||||||
|
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
|
||||||
|
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||||
|
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||||
|
"after:border after:border-current/20 after:bg-current/5",
|
||||||
|
],
|
||||||
|
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
|
||||||
|
solid: "bg-primary text-primary-foreground",
|
||||||
|
/** Double container - a muted ring around an inset card, matching Frame. */
|
||||||
|
frame: [
|
||||||
|
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
|
||||||
|
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||||
|
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||||
|
"after:border after:border-border after:bg-card after:shadow-xs",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
|
||||||
|
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
|
||||||
|
default:
|
||||||
|
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||||
|
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||||
|
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* `default`: active style radius. `full`: circular.
|
||||||
|
* The plain value is the fallback outside a style scope; the `style-*`
|
||||||
|
* tokens win by specificity inside one, and survive the registry
|
||||||
|
* transform as the single resolved value per generated style.
|
||||||
|
*
|
||||||
|
* Each style token is clamped to a fraction of the tile. A flat radius
|
||||||
|
* is a circle once it reaches half the box, so the soft styles used to
|
||||||
|
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
|
||||||
|
* `full` variant's meaning. Clamping keeps one corner ratio at every
|
||||||
|
* size instead, so a tile still reads as the same shape when it scales.
|
||||||
|
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
|
||||||
|
*/
|
||||||
|
radius: {
|
||||||
|
default:
|
||||||
|
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
|
||||||
|
full: "[--icon-tile-radius:calc(infinity*1px)]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "outline",
|
||||||
|
size: "default",
|
||||||
|
radius: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface IconTileProps extends useRender.ComponentProps<"span"> {
|
||||||
|
variant?: VariantProps<typeof iconTileVariants>["variant"]
|
||||||
|
size?: VariantProps<typeof iconTileVariants>["size"]
|
||||||
|
radius?: VariantProps<typeof iconTileVariants>["radius"]
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconTile({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
radius = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: IconTileProps) {
|
||||||
|
const defaultProps = {
|
||||||
|
"data-slot": "icon-tile",
|
||||||
|
"data-variant": variant,
|
||||||
|
"data-size": size,
|
||||||
|
className: cn(iconTileVariants({ variant, size, radius, className })),
|
||||||
|
}
|
||||||
|
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
render,
|
||||||
|
props: mergeProps<"span">(defaultProps, props),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { IconTile, iconTileVariants, type IconTileProps }
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@evobgp/ui/components/select'
|
} from '@evobgp/ui/components/select'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { jobKindRu } from '@/lib/ui-labels'
|
import { isRefreshJobKind, jobKindRu } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
import { ScheduleCalendarView } from './schedule-calendar-view'
|
import { ScheduleCalendarView } from './schedule-calendar-view'
|
||||||
@@ -34,7 +34,7 @@ function jobTimestamp(job: JobRow): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
|
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
|
||||||
if (filter === 'refresh') return job.kind === 'module_refresh'
|
if (filter === 'refresh') return isRefreshJobKind(job.kind)
|
||||||
if (filter === 'failed')
|
if (filter === 'failed')
|
||||||
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
||||||
return true
|
return true
|
||||||
@@ -107,7 +107,7 @@ export function ScheduleAgendaPanel({
|
|||||||
return (
|
return (
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Календарь задач"
|
title="Календарь задач"
|
||||||
description="Задачи refresh и apply по дням"
|
description="Задачи обновления и применения по дням"
|
||||||
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col lg:flex-row">
|
<div className="flex flex-col lg:flex-row">
|
||||||
@@ -133,7 +133,9 @@ export function ScheduleAgendaPanel({
|
|||||||
</div>
|
</div>
|
||||||
<Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
|
<Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
|
||||||
<SelectTrigger size="sm" className="w-full sm:w-44">
|
<SelectTrigger size="sm" className="w-full sm:w-44">
|
||||||
<SelectValue />
|
<SelectValue>
|
||||||
|
{FILTER_ITEMS.find((item) => item.value === filter)?.label}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{FILTER_ITEMS.map((item) => (
|
{FILTER_ITEMS.map((item) => (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FrameDataGrid } from '@/components/reui-kit'
|
|||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import { isRefreshJobKind } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
||||||
@@ -11,7 +12,7 @@ import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
|||||||
type JobTab = 'all' | 'refresh' | 'failed'
|
type JobTab = 'all' | 'refresh' | 'failed'
|
||||||
|
|
||||||
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||||
if (tab === 'refresh') return items.filter((j) => j.kind === 'module_refresh')
|
if (tab === 'refresh') return items.filter((j) => isRefreshJobKind(j.kind))
|
||||||
if (tab === 'failed')
|
if (tab === 'failed')
|
||||||
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
return items
|
return items
|
||||||
@@ -20,7 +21,7 @@ function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
|||||||
function tabCounts(items: JobRow[]) {
|
function tabCounts(items: JobRow[]) {
|
||||||
return {
|
return {
|
||||||
all: items.length,
|
all: items.length,
|
||||||
refresh: items.filter((j) => j.kind === 'module_refresh').length,
|
refresh: items.filter((j) => isRefreshJobKind(j.kind)).length,
|
||||||
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
.length,
|
.length,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function ScheduleJobsGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||||
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} accent="mono" />,
|
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} />,
|
||||||
meta: { headerTitle: 'Вид' },
|
meta: { headerTitle: 'Вид' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function ScheduleModulesGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
{row.original.cron_expr ??
|
{row.original.cron_expr ??
|
||||||
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec}s` : '—')}
|
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec} с` : '—')}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Расписание' },
|
meta: { headerTitle: 'Расписание' },
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useTheme } from 'next-themes'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
import { SettingRow } from '@/components/settings/setting-row'
|
||||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
ToggleGroup,
|
ToggleGroup,
|
||||||
@@ -44,7 +44,7 @@ export function AppearanceSettingsTab() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Тема интерфейса"
|
title="Тема интерфейса"
|
||||||
description="Быстрый переключатель также доступен в боковой панели"
|
description="Быстрый переключатель также доступен в боковой панели"
|
||||||
@@ -61,6 +61,7 @@ export function AppearanceSettingsTab() {
|
|||||||
мгновенно
|
мгновенно
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
|
stacked
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
@@ -72,7 +73,7 @@ export function AppearanceSettingsTab() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="Тема интерфейса"
|
aria-label="Тема интерфейса"
|
||||||
className="w-full sm:w-auto"
|
className="w-full min-w-0"
|
||||||
>
|
>
|
||||||
{THEME_OPTIONS.map((option) => {
|
{THEME_OPTIONS.map((option) => {
|
||||||
const Icon = option.icon
|
const Icon = option.icon
|
||||||
@@ -89,16 +90,16 @@ export function AppearanceSettingsTab() {
|
|||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Дашборд"
|
title="Обзор"
|
||||||
description="Блоки на экране «Обзор»"
|
description="Блоки на экране «Обзор»"
|
||||||
>
|
>
|
||||||
<SettingsFieldGroup
|
<SettingsFieldGroup
|
||||||
legend="Быстрые действия"
|
legend="Быстрые действия"
|
||||||
description="Показывать KPI-like плитки быстрых переходов под метриками."
|
description="Показывать плитки быстрых переходов под метриками."
|
||||||
>
|
>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Быстрые действия"
|
title="Быстрые действия"
|
||||||
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
|
description="Блок с частыми переходами (модули, сеть, деплой) на экране «Обзор»."
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Save } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import { FrameDataGrid } from '@/components/reui-kit'
|
||||||
|
import { SelectMenu } from '@/components/select-field'
|
||||||
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
|
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
|
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||||
|
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BIRD_SETTING_KEYS,
|
||||||
|
REVISION_SETTING_KEYS,
|
||||||
|
RUNTIME_LOGS_SETTING_KEYS,
|
||||||
|
buildPayload,
|
||||||
|
partitionSettings,
|
||||||
|
settingsKeys,
|
||||||
|
settingsQueryOptions,
|
||||||
|
type BirdSettingKey,
|
||||||
|
} from '@/queries/settings'
|
||||||
|
import { apiMutate } from '@/lib/api-client'
|
||||||
|
|
||||||
|
const RUNTIME_LOGS_ENABLED_ITEMS = [
|
||||||
|
{ value: 'true', label: 'Вкл' },
|
||||||
|
{ value: 'false', label: 'Выкл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const RUNTIME_LOGS_MODE_ITEMS = [
|
||||||
|
{ value: 'truncate', label: 'Обнулить файл' },
|
||||||
|
{ value: 'delete', label: 'Удалить файл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||||
|
bird_router_id: 'Router ID',
|
||||||
|
bird_local_ipv4: 'Локальный IPv4',
|
||||||
|
bird_local_ipv6: 'Локальный IPv6',
|
||||||
|
bird_local_asn: 'Локальный ASN',
|
||||||
|
bird_bgp_source_ipv4: 'Исходный IPv4 BGP',
|
||||||
|
bird_bgp_source_ipv6: 'Исходный IPv6 BGP',
|
||||||
|
peer_discovery_enabled: 'Автообнаружение пиров',
|
||||||
|
peer_discovery_ranges_v4: 'CIDR автообнаружения IPv4',
|
||||||
|
peer_discovery_ranges_v6: 'CIDR автообнаружения IPv6',
|
||||||
|
peer_discovery_require_external: 'Только внешние ASN',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BIRD_BOOL_ITEMS = [
|
||||||
|
{ value: 'true', label: 'Вкл' },
|
||||||
|
{ value: 'false', label: 'Выкл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
||||||
|
peer_discovery_enabled:
|
||||||
|
'Динамический диапазон соседей в BIRD (карантин import/export none). Нужны CIDR.',
|
||||||
|
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
|
||||||
|
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
|
||||||
|
peer_discovery_require_external: 'Диапазон соседей с внешним (любым чужим) ASN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BirdSettingsTab() {
|
||||||
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
||||||
|
|
||||||
|
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
||||||
|
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
||||||
|
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (partitioned) {
|
||||||
|
setBirdForm({ ...partitioned.bird })
|
||||||
|
setRevisionForm({ ...partitioned.revision })
|
||||||
|
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settingsQ.data])
|
||||||
|
|
||||||
|
const patchMutation = useMutation({
|
||||||
|
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
||||||
|
apiMutate('/v1/settings', 'PATCH', payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Параметры сохранены')
|
||||||
|
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function saveBird() {
|
||||||
|
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
||||||
|
}
|
||||||
|
function saveRevision() {
|
||||||
|
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
||||||
|
}
|
||||||
|
function saveRuntimeLogs() {
|
||||||
|
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<SettingsCard
|
||||||
|
title="Плоскость управления BIRD"
|
||||||
|
description="Глобальные параметры BIRD для обновления модулей и применения конфигурации. Сохранение доступно оператору."
|
||||||
|
footer={
|
||||||
|
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-64" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="BIRD"
|
||||||
|
description="Параметры демона и автообнаружения пиров."
|
||||||
|
>
|
||||||
|
{BIRD_SETTING_KEYS.map((key, index) => (
|
||||||
|
<SettingsSettingField
|
||||||
|
key={key}
|
||||||
|
title={BIRD_LABELS[key]}
|
||||||
|
description={BIRD_HINTS[key]}
|
||||||
|
labelFor={key}
|
||||||
|
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||||
|
stacked
|
||||||
|
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||||
|
>
|
||||||
|
{key === 'peer_discovery_enabled' ||
|
||||||
|
key === 'peer_discovery_require_external' ? (
|
||||||
|
<SelectMenu
|
||||||
|
id={key}
|
||||||
|
items={[...BIRD_BOOL_ITEMS]}
|
||||||
|
value={birdForm[key] || 'false'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||||
|
}
|
||||||
|
placeholder="Выкл"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={key}
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={birdForm[key] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={BIRD_LABELS[key]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSettingField>
|
||||||
|
))}
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Ревизии"
|
||||||
|
description="Время хранения ревизий в БД"
|
||||||
|
footer={
|
||||||
|
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-32" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Хранение"
|
||||||
|
description="Срок хранения ревизий в минутах."
|
||||||
|
>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Срок хранения"
|
||||||
|
description="Сколько минут хранить ревизии конфигурации."
|
||||||
|
labelFor="revision_retention_minutes"
|
||||||
|
stacked
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="revision_retention_minutes"
|
||||||
|
type="number"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={revisionForm.revision_retention_minutes ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRevisionForm((s) => ({
|
||||||
|
...s,
|
||||||
|
revision_retention_minutes: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Файловые логи"
|
||||||
|
description="Автоматическая очистка логов"
|
||||||
|
footer={
|
||||||
|
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-48" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Авто-очистка"
|
||||||
|
description="Расписание и лимиты файловых логов runtime."
|
||||||
|
>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Авто-очистка"
|
||||||
|
description="Включить периодическую очистку файловых логов."
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<SelectMenu
|
||||||
|
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||||
|
placeholder="Выберите"
|
||||||
|
onValueChange={(v) =>
|
||||||
|
v &&
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_enabled: v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Макс. размер файла"
|
||||||
|
description="Порог в мегабайтах, после которого срабатывает очистка."
|
||||||
|
labelFor="runtime_logs_max_file_mb"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="runtime_logs_max_file_mb"
|
||||||
|
type="number"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_max_file_mb: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Расписание"
|
||||||
|
description="Cron-выражение для авто-очистки."
|
||||||
|
labelFor="runtime_logs_auto_schedule"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="runtime_logs_auto_schedule"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_schedule: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Режим очистки"
|
||||||
|
description="Обнулить файл или удалить его."
|
||||||
|
stacked
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<SelectMenu
|
||||||
|
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||||
|
placeholder="Выберите"
|
||||||
|
onValueChange={(v) =>
|
||||||
|
v &&
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_mode: v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<FrameDataGrid
|
||||||
|
title="Дополнительные параметры"
|
||||||
|
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
|
||||||
|
>
|
||||||
|
<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) => (
|
||||||
|
<SettingsKvGrid
|
||||||
|
items={items}
|
||||||
|
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</FrameDataGrid>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Link, useNavigate } from '@tanstack/react-router'
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Save } from 'lucide-react'
|
import { KeyRound, Save } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
import { SettingRow } from '@/components/settings/setting-row'
|
||||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import {
|
import {
|
||||||
DEV_API_TOKEN,
|
DEV_API_TOKEN,
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
TOKEN_STORAGE_KEY,
|
TOKEN_STORAGE_KEY,
|
||||||
} from '@/lib/api-client'
|
} from '@/lib/api-client'
|
||||||
import { authKeys } from '@/queries/auth'
|
import { authKeys } from '@/queries/auth'
|
||||||
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
|
||||||
@@ -50,21 +50,22 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
{tokenRequired ? (
|
{tokenRequired ? (
|
||||||
<Alert>
|
<Alert variant="warning">
|
||||||
|
<KeyRound aria-hidden />
|
||||||
|
<AlertTitle>Нужен API-токен</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
|
Сохраните токен ниже или нажмите «Использовать dev».
|
||||||
dev».
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Подключение к API"
|
title="Подключение к API"
|
||||||
description="Токен хранится только в этом браузере (localStorage)"
|
description="Токен хранится только в этом браузере"
|
||||||
footer={
|
footer={
|
||||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
<div className="flex w-full min-w-0 flex-wrap justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={useDevToken}>
|
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||||
Использовать dev
|
Использовать dev
|
||||||
</Button>
|
</Button>
|
||||||
@@ -83,8 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
|||||||
title="Токен для запросов"
|
title="Токен для запросов"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
|
Ключ для заголовка Authorization. Управление ключами арендатора — в разделе{' '}
|
||||||
ключами tenant — в разделе{' '}
|
|
||||||
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||||
Права доступа
|
Права доступа
|
||||||
</Link>
|
</Link>
|
||||||
@@ -93,16 +93,18 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
|||||||
}
|
}
|
||||||
titleAddon={
|
titleAddon={
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm">
|
||||||
localStorage
|
браузер
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
labelFor="settings-token"
|
labelFor="settings-token"
|
||||||
|
stacked
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="settings-token"
|
id="settings-token"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="w-full min-w-0"
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(e) => setTokenValue(e.target.value)}
|
onChange={(e) => setTokenValue(e.target.value)}
|
||||||
placeholder="dev или API-ключ"
|
placeholder="dev или API-ключ"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
|
|
||||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import {
|
import {
|
||||||
@@ -23,42 +23,45 @@ import {
|
|||||||
const ADMIN_SECTIONS = [
|
const ADMIN_SECTIONS = [
|
||||||
{
|
{
|
||||||
id: 'tenant-settings',
|
id: 'tenant-settings',
|
||||||
to: '/tenant-settings' as const,
|
to: '/settings' as const,
|
||||||
|
search: { tab: 'bird' as const },
|
||||||
title: 'Параметры арендатора',
|
title: 'Параметры арендатора',
|
||||||
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
||||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
badge: { label: 'оператор', variant: 'warning-light' as const },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'access',
|
id: 'access',
|
||||||
to: '/access' as const,
|
to: '/access' as const,
|
||||||
|
search: undefined,
|
||||||
title: 'Права доступа',
|
title: 'Права доступа',
|
||||||
description: 'API-ключи tenant, роли и управление доступом.',
|
description: 'API-ключи арендатора, роли и управление доступом.',
|
||||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
badge: { label: 'оператор', variant: 'warning-light' as const },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'monitoring',
|
id: 'monitoring',
|
||||||
to: '/monitoring' as const,
|
to: '/monitoring' as const,
|
||||||
|
search: undefined,
|
||||||
title: 'Мониторинг',
|
title: 'Мониторинг',
|
||||||
description: 'Метрики, состояние jobs и observability control plane.',
|
description: 'Метрики, состояние задач и наблюдаемость плоскости управления.',
|
||||||
icon: <ActivityIcon aria-hidden="true" />,
|
icon: <ActivityIcon aria-hidden="true" />,
|
||||||
badge: { label: 'viewer+', variant: 'info-light' as const },
|
badge: { label: 'просмотр+', variant: 'info-light' as const },
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function SectionsSettingsTab() {
|
export function SectionsSettingsTab() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Разделы control plane"
|
title="Разделы плоскости управления"
|
||||||
description="Параметры tenant и операции — отдельно от настроек браузера"
|
description="Параметры арендатора и операции — отдельно от настроек браузера"
|
||||||
>
|
>
|
||||||
<ItemGroup className="gap-0">
|
<ItemGroup className="gap-0">
|
||||||
{ADMIN_SECTIONS.map((section, index) => (
|
{ADMIN_SECTIONS.map((section, index) => (
|
||||||
<Fragment key={section.id}>
|
<Fragment key={section.id}>
|
||||||
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||||
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
<Item className="min-h-0 min-w-0 flex-wrap items-center gap-4 px-5 py-3.5">
|
||||||
<ItemMedia className="self-center!">
|
<ItemMedia className="self-center!">
|
||||||
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||||
{section.icon}
|
{section.icon}
|
||||||
@@ -66,7 +69,7 @@ export function SectionsSettingsTab() {
|
|||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
|
|
||||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||||
<ItemTitle className="gap-2 leading-5">
|
<ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
|
||||||
{section.title}
|
{section.title}
|
||||||
<Badge variant={section.badge.variant} size="xs">
|
<Badge variant={section.badge.variant} size="xs">
|
||||||
{section.badge.label}
|
{section.badge.label}
|
||||||
@@ -76,7 +79,17 @@ export function SectionsSettingsTab() {
|
|||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
<ItemActions className="shrink-0 justify-end self-center">
|
<ItemActions className="shrink-0 justify-end self-center">
|
||||||
<Button variant="outline" size="sm" render={<Link to={section.to} />}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
render={
|
||||||
|
section.to === '/settings' ? (
|
||||||
|
<Link to="/settings" search={{ tab: 'bird' }} />
|
||||||
|
) : (
|
||||||
|
<Link to={section.to} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
Открыть
|
Открыть
|
||||||
</Button>
|
</Button>
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { MonitorIcon } from 'lucide-react'
|
import { MonitorIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
import { SettingRow } from '@/components/settings/setting-row'
|
||||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
import { TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
@@ -35,8 +35,8 @@ export function SessionSettingsTab() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
|
<SettingsCard title="Текущая сессия" description="Проверка токена через API сессии">
|
||||||
<QueryState
|
<QueryState
|
||||||
data={sessionQ.data}
|
data={sessionQ.data}
|
||||||
isLoading={sessionQ.isLoading && hasStoredToken}
|
isLoading={sessionQ.isLoading && hasStoredToken}
|
||||||
@@ -58,15 +58,17 @@ export function SessionSettingsTab() {
|
|||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
|
|
||||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||||
<ItemTitle className="gap-2 leading-5">
|
<ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
|
||||||
Этот браузер
|
Этот браузер
|
||||||
<Badge variant="success-light" size="xs">
|
<Badge variant="success-light" size="xs">
|
||||||
Активна
|
Активна
|
||||||
</Badge>
|
</Badge>
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
<ItemDescription className="leading-5">
|
<ItemDescription className="leading-5">
|
||||||
Tenant:{' '}
|
Арендатор:{' '}
|
||||||
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
<code className="text-foreground font-mono text-xs break-all">
|
||||||
|
{session.tenant_id}
|
||||||
|
</code>
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
@@ -82,7 +84,7 @@ export function SessionSettingsTab() {
|
|||||||
>
|
>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Роль"
|
title="Роль"
|
||||||
description="Определяет доступ к операциям control plane и CRUD."
|
description="Определяет доступ к операциям плоскости управления и изменению данных."
|
||||||
titleAddon={
|
titleAddon={
|
||||||
sessionQ.data ? (
|
sessionQ.data ? (
|
||||||
<Badge variant="info-light" size="sm">
|
<Badge variant="info-light" size="sm">
|
||||||
@@ -94,12 +96,13 @@ export function SessionSettingsTab() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
stacked
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
{sessionQ.data ? (
|
{sessionQ.data ? (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm break-words">
|
||||||
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
|
Ключ с ролью <strong>{ROLE_LABELS[sessionQ.data.role] ?? sessionQ.data.role}</strong> у арендатора{' '}
|
||||||
<code className="font-mono text-xs">{sessionQ.data.tenant_id}</code>.
|
<code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldLabel,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldTitle,
|
||||||
|
} from '@evobgp/ui/components/field'
|
||||||
|
|
||||||
|
export interface SettingRowProps {
|
||||||
|
title: string
|
||||||
|
description?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
last?: boolean
|
||||||
|
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
|
||||||
|
separated?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
stacked?: boolean
|
||||||
|
labelFor?: string
|
||||||
|
contentClassName?: string
|
||||||
|
className?: string
|
||||||
|
titleAddon?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings row — settings-3 / settings-16 DNA.
|
||||||
|
* Control is flex-1 + min-w-0 (never a fixed w-78) so label and input do not overlap.
|
||||||
|
* @see https://reui.io/preview/base/settings-3
|
||||||
|
* @see https://reui.io/preview/base/settings-16
|
||||||
|
*/
|
||||||
|
export function SettingRow({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
last,
|
||||||
|
separated = false,
|
||||||
|
compact,
|
||||||
|
stacked,
|
||||||
|
labelFor,
|
||||||
|
contentClassName,
|
||||||
|
className,
|
||||||
|
titleAddon,
|
||||||
|
}: SettingRowProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
orientation={stacked ? 'vertical' : 'responsive'}
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 gap-4 px-5 py-3.5',
|
||||||
|
!stacked &&
|
||||||
|
'@md/field-group:items-start @md/field-group:*:min-w-0 @md/field-group:*:max-w-full',
|
||||||
|
!last && !separated && 'border-border/40 border-b',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-0 flex-1 flex-col gap-0.5',
|
||||||
|
!stacked && '@md/field-group:max-w-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
{labelFor ? (
|
||||||
|
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||||
|
) : (
|
||||||
|
<FieldTitle>{title}</FieldTitle>
|
||||||
|
)}
|
||||||
|
{titleAddon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{description ? (
|
||||||
|
<FieldDescription className="text-sm break-words">{description}</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldContent
|
||||||
|
className={cn(
|
||||||
|
'w-full min-w-0 @md/field-group:flex-1',
|
||||||
|
stacked
|
||||||
|
? 'max-w-none'
|
||||||
|
: compact
|
||||||
|
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
|
||||||
|
: '@md/field-group:max-w-[min(100%,34rem)]',
|
||||||
|
contentClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex w-full min-w-0 justify-start',
|
||||||
|
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{separated && !last ? <FieldSeparator /> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
|
||||||
|
interface SettingsCardProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
children: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
className?: string
|
||||||
|
contentClassName?: string
|
||||||
|
footerClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 */
|
||||||
|
export function SettingsCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
footerClassName,
|
||||||
|
}: SettingsCardProps) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||||
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
|
<FrameHeader className="gap-0 border-b px-5 py-3">
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||||
|
</FrameHeader>
|
||||||
|
|
||||||
|
<div className={cn('min-w-0', contentClassName)}>{children}</div>
|
||||||
|
|
||||||
|
{footer ? (
|
||||||
|
<FrameFooter
|
||||||
|
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</FrameFooter>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from '@evobgp/ui/components/field'
|
||||||
|
|
||||||
|
interface SettingsFieldGroupProps {
|
||||||
|
legend: string
|
||||||
|
description: string
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
fieldGroupClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings field group — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */
|
||||||
|
export function SettingsFieldGroup({
|
||||||
|
legend,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
fieldGroupClassName,
|
||||||
|
}: SettingsFieldGroupProps) {
|
||||||
|
return (
|
||||||
|
<FieldSet className={cn('gap-0', className)}>
|
||||||
|
<FieldLegend className="sr-only">{legend}</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">{description}</FieldDescription>
|
||||||
|
<FieldGroup className={cn('gap-0', fieldGroupClassName)}>
|
||||||
|
{children}
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,45 +1,138 @@
|
|||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
import { useIsMobile } from '@/hooks/use-mobile'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@evobgp/ui/components/tabs'
|
||||||
|
|
||||||
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
||||||
|
import { BirdSettingsTab } from './bird-settings-tab'
|
||||||
import { ConnectionSettingsTab } from './connection-settings-tab'
|
import { ConnectionSettingsTab } from './connection-settings-tab'
|
||||||
import { SectionsSettingsTab } from './sections-settings-tab'
|
|
||||||
import { SessionSettingsTab } from './session-settings-tab'
|
import { SessionSettingsTab } from './session-settings-tab'
|
||||||
import {
|
import {
|
||||||
SETTINGS_TAB_ITEMS,
|
SETTINGS_TAB_ITEMS,
|
||||||
type SettingsTab,
|
type SettingsSection,
|
||||||
|
type SettingsTabItem,
|
||||||
} from './settings-tabs-data'
|
} from './settings-tabs-data'
|
||||||
|
|
||||||
|
function UiSettingsPanel({ tokenRequired }: { tokenRequired: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
||||||
|
<SessionSettingsTab />
|
||||||
|
<AppearanceSettingsTab />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsNavigation({
|
||||||
|
isMobile,
|
||||||
|
activeValue,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
isMobile: boolean
|
||||||
|
activeValue: string
|
||||||
|
items: SettingsTabItem[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('min-w-0', isMobile ? 'w-full' : 'w-40 shrink-0')}>
|
||||||
|
{isMobile ? (
|
||||||
|
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||||
|
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
|
||||||
|
{items.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
|
||||||
|
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
|
||||||
|
{items.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
|
||||||
|
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified settings — settings-7 AccountSettings 1:1 (header + vertical Tabs).
|
||||||
|
* Surface of cards stays Frame (`SettingsCard`), not shadcn Card.
|
||||||
|
* @see https://reui.io/preview/base/settings-7
|
||||||
|
* @see https://reui.io/blocks
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
export function SettingsPageShell({
|
export function SettingsPageShell({
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
tokenRequired,
|
tokenRequired,
|
||||||
|
showBird,
|
||||||
}: {
|
}: {
|
||||||
activeTab: SettingsTab
|
activeTab: SettingsSection
|
||||||
onTabChange: (tab: SettingsTab) => void
|
onTabChange: (tab: SettingsSection) => void
|
||||||
tokenRequired: boolean
|
tokenRequired: boolean
|
||||||
|
showBird: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const items = showBird
|
||||||
|
? SETTINGS_TAB_ITEMS
|
||||||
|
: SETTINGS_TAB_ITEMS.filter((tab) => tab.value === 'ui')
|
||||||
|
const resolvedTab = activeTab === 'bird' && showBird ? 'bird' : 'ui'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BadgeTabs
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
|
||||||
value={activeTab}
|
<header className="px-1">
|
||||||
onValueChange={(value) => onTabChange(value as SettingsTab)}
|
<h1 className="text-xl font-semibold tracking-tight">Настройки</h1>
|
||||||
items={SETTINGS_TAB_ITEMS.map((tab) => ({
|
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||||
value: tab.value,
|
Интерфейс UI и глобальные параметры BIRD в одном разделе.
|
||||||
label: tab.label,
|
</p>
|
||||||
icon: tab.icon,
|
</header>
|
||||||
}))}
|
|
||||||
>
|
<Tabs
|
||||||
<TabsContent value="connection" className="mt-0">
|
value={resolvedTab}
|
||||||
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
onValueChange={(value) => onTabChange(value as SettingsSection)}
|
||||||
</TabsContent>
|
orientation={isMobile ? 'horizontal' : 'vertical'}
|
||||||
<TabsContent value="session" className="mt-0">
|
className="w-full gap-4 lg:gap-8"
|
||||||
<SessionSettingsTab />
|
>
|
||||||
</TabsContent>
|
<SettingsNavigation
|
||||||
<TabsContent value="appearance" className="mt-0">
|
isMobile={isMobile}
|
||||||
<AppearanceSettingsTab />
|
activeValue={resolvedTab}
|
||||||
</TabsContent>
|
items={items}
|
||||||
<TabsContent value="sections" className="mt-0">
|
/>
|
||||||
<SectionsSettingsTab />
|
|
||||||
</TabsContent>
|
<div className="min-w-0 flex-1">
|
||||||
</BadgeTabs>
|
<TabsContent value="ui" className="mt-0">
|
||||||
|
<UiSettingsPanel tokenRequired={tokenRequired} />
|
||||||
|
</TabsContent>
|
||||||
|
{showBird ? (
|
||||||
|
<TabsContent value="bird" className="mt-0">
|
||||||
|
<BirdSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { type ComponentProps, type ReactNode } from 'react'
|
import { type ComponentProps, type ReactNode } from 'react'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { SettingRow } from '@/components/settings/setting-row'
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldContent,
|
|
||||||
FieldDescription,
|
|
||||||
FieldLabel,
|
|
||||||
FieldSeparator,
|
|
||||||
FieldTitle,
|
|
||||||
} from '@evobgp/ui/components/field'
|
|
||||||
|
|
||||||
/** settings-3 row pattern for tenant/UI settings. */
|
/** Tenant settings row — same SettingRow DNA as UI settings. */
|
||||||
export function SettingsSettingField({
|
export function SettingsSettingField({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -20,38 +12,34 @@ export function SettingsSettingField({
|
|||||||
last,
|
last,
|
||||||
labelFor,
|
labelFor,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
|
stacked,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: ReactNode
|
description?: ReactNode
|
||||||
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
last?: boolean
|
last?: boolean
|
||||||
labelFor?: string
|
labelFor?: string
|
||||||
contentClassName?: string
|
contentClassName?: string
|
||||||
|
stacked?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<SettingRow
|
||||||
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
title={title}
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
description={description}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
last={last}
|
||||||
{labelFor ? (
|
labelFor={labelFor}
|
||||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
contentClassName={contentClassName}
|
||||||
) : (
|
stacked={stacked}
|
||||||
<FieldTitle>{title}</FieldTitle>
|
titleAddon={
|
||||||
)}
|
badge ? (
|
||||||
{badge ? (
|
<Badge variant={badge.variant} size="sm">
|
||||||
<Badge variant={badge.variant} size="sm">
|
{badge.label}
|
||||||
{badge.label}
|
</Badge>
|
||||||
</Badge>
|
) : null
|
||||||
) : null}
|
}
|
||||||
</div>
|
>
|
||||||
<FieldDescription className="text-xs">{description}</FieldDescription>
|
{children}
|
||||||
</div>
|
</SettingRow>
|
||||||
<FieldContent className={cn('min-w-0 @md/field-group:w-78', contentClassName)}>
|
|
||||||
{children}
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
{!last ? <FieldSeparator /> : null}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,32 @@
|
|||||||
import {
|
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||||
KeyRoundIcon,
|
|
||||||
MonitorSmartphoneIcon,
|
|
||||||
PaletteIcon,
|
|
||||||
SlidersHorizontalIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { type ReactNode } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
|
export type SettingsSection = 'ui' | 'bird'
|
||||||
|
|
||||||
export type SettingsTabItem = {
|
export type SettingsTabItem = {
|
||||||
value: SettingsTab
|
value: SettingsSection
|
||||||
label: string
|
label: string
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Side rail items — settings-7 AccountSettings DNA. */
|
||||||
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||||
{
|
{
|
||||||
value: 'connection',
|
value: 'ui',
|
||||||
label: 'Подключение',
|
label: 'Настройки UI',
|
||||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
icon: <SettingsIcon aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'session',
|
value: 'bird',
|
||||||
label: 'Сессия',
|
label: 'Настройки BIRD',
|
||||||
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
|
icon: <ServerCogIcon aria-hidden="true" />,
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'appearance',
|
|
||||||
label: 'Оформление',
|
|
||||||
icon: <PaletteIcon aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'sections',
|
|
||||||
label: 'Разделы',
|
|
||||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function parseSettingsTab(value: unknown): SettingsTab {
|
const LEGACY_BIRD = new Set(['bird', 'revision', 'runtime-logs', 'additional'])
|
||||||
if (
|
|
||||||
value === 'session' ||
|
/** Maps current and legacy `?tab=` values onto the unified settings rail. */
|
||||||
value === 'appearance' ||
|
export function parseSettingsSection(value: unknown): SettingsSection {
|
||||||
value === 'sections'
|
if (typeof value === 'string' && LEGACY_BIRD.has(value)) return 'bird'
|
||||||
) {
|
return 'ui'
|
||||||
return value
|
|
||||||
}
|
|
||||||
return 'connection'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ export function AnalyticsDashboardSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={dashboardMainSidebarClassName}>
|
<div className={dashboardMainSidebarClassName}>
|
||||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-8" />
|
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
|
||||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-4" />
|
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className={chartPanelGridClassName}>
|
||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { apiKeyRoleRu } from '@/lib/ui-labels'
|
|||||||
|
|
||||||
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
|
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
|
||||||
{ value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
|
{ value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
|
||||||
{ value: 'editor', label: `${apiKeyRoleRu('editor')} — CRUD без применения` },
|
{ value: 'editor', label: `${apiKeyRoleRu('editor')} — изменение данных без применения` },
|
||||||
{ value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
|
{ value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
|
||||||
{ value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
|
{ value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -346,11 +346,17 @@ export function permissionForPath(pathname: string): string | null {
|
|||||||
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||||
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||||
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||||
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
|
if (pathname.startsWith('/tenant-settings') || pathname.startsWith('/settings')) {
|
||||||
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
|
return canOpenSettings() ? null : 'bgp:settings:read'
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Unified `/settings` — UI (`settings:read`) or BIRD (`tenant_settings:admin`). */
|
||||||
|
export function canOpenSettings(): boolean {
|
||||||
|
return can('bgp:settings:read') || can('bgp:tenant_settings:admin')
|
||||||
|
}
|
||||||
|
|
||||||
const FALLBACK_PATH = '/dashboard'
|
const FALLBACK_PATH = '/dashboard'
|
||||||
|
|
||||||
/** First path in the sidebar the current user may open. */
|
/** First path in the sidebar the current user may open. */
|
||||||
@@ -365,7 +371,6 @@ export function firstAllowedPath(): string {
|
|||||||
'/schedule',
|
'/schedule',
|
||||||
'/monitoring',
|
'/monitoring',
|
||||||
'/access',
|
'/access',
|
||||||
'/tenant-settings',
|
|
||||||
'/settings',
|
'/settings',
|
||||||
]
|
]
|
||||||
for (const path of candidates) {
|
for (const path of candidates) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const DATA_GRID_MESSAGES_RU = {
|
|||||||
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
|
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
|
||||||
...DATA_GRID_TABLE_LAYOUT,
|
...DATA_GRID_TABLE_LAYOUT,
|
||||||
dense: true,
|
dense: true,
|
||||||
|
width: 'auto',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTextGlobalFilter<TData extends object>(
|
export function createTextGlobalFilter<TData extends object>(
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_I18N,
|
||||||
|
type FilterI18nConfig,
|
||||||
|
} from '@/components/reui/filters'
|
||||||
|
|
||||||
|
const OPERATOR_LABEL_RU: Record<string, string> = {
|
||||||
|
is: 'равно',
|
||||||
|
is_not: 'не равно',
|
||||||
|
is_any_of: 'любое из',
|
||||||
|
is_not_any_of: 'кроме',
|
||||||
|
includes_all: 'включает все',
|
||||||
|
excludes_all: 'исключает все',
|
||||||
|
before: 'до',
|
||||||
|
after: 'после',
|
||||||
|
between: 'между',
|
||||||
|
not_between: 'вне диапазона',
|
||||||
|
contains: 'содержит',
|
||||||
|
not_contains: 'не содержит',
|
||||||
|
starts_with: 'начинается с',
|
||||||
|
ends_with: 'заканчивается на',
|
||||||
|
equals: 'равно',
|
||||||
|
not_equals: 'не равно',
|
||||||
|
greater_than: 'больше',
|
||||||
|
less_than: 'меньше',
|
||||||
|
overlaps: 'пересекается',
|
||||||
|
includes: 'включает',
|
||||||
|
excludes: 'исключает',
|
||||||
|
empty: 'пусто',
|
||||||
|
not_empty: 'не пусто',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FILTERS_I18N_RU: FilterI18nConfig = {
|
||||||
|
...DEFAULT_I18N,
|
||||||
|
addFilter: 'Фильтр',
|
||||||
|
searchFields: 'Фильтр…',
|
||||||
|
noFieldsFound: 'Поля не найдены.',
|
||||||
|
noResultsFound: 'Ничего не найдено.',
|
||||||
|
select: 'Выбрать…',
|
||||||
|
true: 'Да',
|
||||||
|
false: 'Нет',
|
||||||
|
min: 'Мин.',
|
||||||
|
max: 'Макс.',
|
||||||
|
to: '—',
|
||||||
|
typeAndPressEnter: 'Введите и нажмите Enter',
|
||||||
|
selected: 'выбрано',
|
||||||
|
selectedCount: 'выбрано',
|
||||||
|
addFilterTitle: 'Добавить фильтр',
|
||||||
|
operators: {
|
||||||
|
...DEFAULT_I18N.operators,
|
||||||
|
is: 'равно',
|
||||||
|
isNot: 'не равно',
|
||||||
|
isAnyOf: 'любое из',
|
||||||
|
isNotAnyOf: 'кроме',
|
||||||
|
includesAll: 'включает все',
|
||||||
|
excludesAll: 'исключает все',
|
||||||
|
before: 'до',
|
||||||
|
after: 'после',
|
||||||
|
between: 'между',
|
||||||
|
notBetween: 'вне диапазона',
|
||||||
|
contains: 'содержит',
|
||||||
|
notContains: 'не содержит',
|
||||||
|
startsWith: 'начинается с',
|
||||||
|
endsWith: 'заканчивается на',
|
||||||
|
isExactly: 'точно',
|
||||||
|
equals: 'равно',
|
||||||
|
notEquals: 'не равно',
|
||||||
|
greaterThan: 'больше',
|
||||||
|
lessThan: 'меньше',
|
||||||
|
overlaps: 'пересекается',
|
||||||
|
includes: 'включает',
|
||||||
|
excludes: 'исключает',
|
||||||
|
includesAllOf: 'включает все',
|
||||||
|
includesAnyOf: 'включает любое',
|
||||||
|
empty: 'пусто',
|
||||||
|
notEmpty: 'не пусто',
|
||||||
|
},
|
||||||
|
placeholders: {
|
||||||
|
enterField: (fieldType: string) => `Введите ${fieldType}…`,
|
||||||
|
selectField: 'Выбрать…',
|
||||||
|
searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}…`,
|
||||||
|
enterKey: 'Введите ключ…',
|
||||||
|
enterValue: 'Введите значение…',
|
||||||
|
},
|
||||||
|
helpers: {
|
||||||
|
formatOperator: (operator: string) =>
|
||||||
|
OPERATOR_LABEL_RU[operator] ?? operator.replace(/_/g, ' '),
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
invalidEmail: 'Некорректный email',
|
||||||
|
invalidUrl: 'Некорректный URL',
|
||||||
|
invalidTel: 'Некорректный телефон',
|
||||||
|
invalid: 'Некорректное значение',
|
||||||
|
},
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user