Compare commits

...
15 Commits
Author SHA1 Message Date
Denozordec e6e319a275 refactor(web): improve job kind handling in schedule components
CD / quality (push) Successful in 1m49s
quality / changes (push) Successful in 9s
quality / openapi (push) Successful in 25s
quality / web (push) Successful in 1m6s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Successful in 3m2s
- Removed hardcoded job kind checks in schedule components and replaced them with a utility function `isRefreshJobKind` for better maintainability.
- Updated the `ui-labels` module to include additional job kinds and their corresponding labels.
- Adjusted the `DashboardRecentJobsGrid`, `OperationsJobsGrid`, and `ScheduleJobsGrid` components to reflect these changes, enhancing the clarity and consistency of job kind representation.
2026-08-19 09:45:31 +07:00
DenozordecandCursor 869b13cb57 fix(web): open module create in FormDrawer
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 22s
quality / web (push) Successful in 56s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 2m53s
Создание модуля перенесено в боковую Sheet, как community и редактирование. /modules/new и быстрые ссылки открывают список с ?create=true.

Co-authored-by: Cursor <[email protected]>
2026-08-18 22:09:41 +07:00
DenozordecandCursor e190785d4f feat(web): add module create form on /modules/new
quality / commitlint (push) Skipped
quality / changes (push) Successful in 6s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 22s
quality / web (push) Successful in 58s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 3m1s
Форма POST /v1/modules вместо заглушки: тип, название, расписание и DoH для DOMAINS. После 201 переход на карточку; кнопка «Создать» только при bgp:modules:write.

Co-authored-by: Cursor <[email protected]>
2026-08-18 20:17:51 +07:00
Denozordec 3fd05ff833 fix(tests): improve concurrency handling in worker tests and enhance module cloning
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / go (push) Successful in 1m14s
quality / bird2 (push) Successful in 18s
CD / quality (push) Successful in 1m47s
CD / publish (push) Successful in 2m53s
- Added environment variable `EVOBGP_JOB_MAX_CONCURRENT` to control job concurrency in tests.
- Modified worker test to ensure proper synchronization of job processing by holding workers until both jobs are enqueued.
- Updated memory store methods to return cloned module instances, preventing unintended mutations of original modules during operations.
2026-08-18 19:58:39 +07:00
Denozordec e7f24f0be4 feat(docker): update runtime configurations and documentation for evobgp components
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / go (push) Failing after 44s
quality / bird2 (push) Skipped
CD / quality (push) Failing after 55s
CD / publish (push) Skipped
- Changed the inheritance for `evobgp-deploy` and `evobgp-node` targets to use `_go-runtime-birdc`, reflecting the inclusion of the `bird` and `birdc` components.
- Updated README and Dockerfile comments to clarify the runtime environments for various evobgp components, specifying the use of `bird` and `birdc` for parse-check functionality.
- Adjusted quickstart documentation to accurately describe the `evobgp-api` image, highlighting the inclusion of `bird` and `birdc` for enhanced functionality.
2026-08-18 19:47:14 +07:00
Denozordec 1bfe460e4b feat(ci): enhance caching strategy and update workflows for pnpm and Go
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 25s
quality / web (push) Successful in 1m16s
quality / go (push) Successful in 2m46s
quality / bird2 (push) Successful in 17s
CD / quality (push) Successful in 5m0s
CD / publish (push) Successful in 4m22s
- Updated CI workflows to export cache paths for pnpm and Go, improving cache efficiency and reducing package download times.
- Replaced corepack enable step with cache path exports in multiple workflows.
- Adjusted cache paths to utilize environment variables for better flexibility and clarity.
- Enhanced README documentation to reflect changes in caching mechanisms and runner configurations.
2026-08-18 19:13:10 +07:00
Denozordec e55d2c5aba feat(docker): enable relative path support for Docker bake configurations
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 28s
quality / web (push) Successful in 1m2s
quality / go (push) Successful in 1m6s
quality / bird2 (push) Successful in 17s
CD / quality (push) Successful in 3m12s
CD / publish (push) Successful in 5m44s
- Added `BUILDX_BAKE_FILE_RELATIVE_PATHS` environment variable to CI workflows to support relative paths in Docker bake commands.
- Updated `docker-bake.hcl` comments to clarify context resolution from the current working directory.
- Revised README documentation to reflect changes in context resolution and usage of the new environment variable.
2026-08-18 18:45:51 +07:00
Denozordec f63e9b5fd0 fix(docker): update base image references to Docker Hub
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / go (push) Successful in 57s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 1m27s
CD / publish (push) Failing after 4m13s
- Changed base image references in `docker-bake.hcl`, Dockerfiles, and `mirror-base-images.sh` from public ECR to Docker Hub to avoid 429 errors and improve reliability.
- Updated README documentation to reflect the new source for base images, clarifying the mirroring process and behavior when tags already exist.
2026-08-18 18:30:22 +07:00
Denozordec 0148d4ca37 feat(docker): enhance base image mirroring process and update documentation
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 40s
quality / web (push) Successful in 1m27s
quality / go (push) Successful in 1m4s
quality / bird2 (push) Successful in 18s
CD / quality (push) Successful in 3m45s
CD / publish (push) Canceled after 10m39s
- Updated `mirror-base-images.sh` to skip existing tags in Gitea and retry on 429 errors, improving efficiency and reducing unnecessary requests.
- Modified CI workflow to utilize a temporary environment file for mirrored base images, ensuring better handling of existing images.
- Enhanced README documentation to reflect changes in the mirroring process and clarify the behavior when tags already exist.
2026-08-18 18:22:26 +07:00
Denozordec e0ecabb22f feat(dependencies): add conventional-commits-parser and update verification script
quality / changes (push) Successful in 8s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 45s
quality / web (push) Successful in 1m30s
quality / go (push) Successful in 1m7s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 3m59s
CD / publish (push) Failing after 1m2s
- Added `conventional-commits-parser` version 6.4.0 to `package.json` and `pnpm-lock.yaml` for improved commit message parsing.
- Updated the commit verification script to utilize the new parser, enhancing error handling for unparseable commit messages.
- Adjusted CI workflow to run the verification script using `pnpm exec` for consistency in package execution.
2026-08-18 18:13:44 +07:00
Denozordec da301b1a94 feat(dependencies): add @redocly/cli and update CI workflows
quality / commitlint (push) Skipped
quality / changes (push) Successful in 20s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 2m54s
quality / web (push) Successful in 1m22s
quality / go (push) Successful in 3m37s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 8m37s
CD / publish (push) Failing after 1m51s
- Added `@redocly/cli` version 1.34.5 to `package.json` and `pnpm-lock.yaml` for OpenAPI linting.
- Updated CI workflows to reflect changes in job names and processes, including adjustments to the `publish` job in the CD workflow.
- Enhanced documentation to clarify the new CI/CD processes and Docker build configurations.
2026-08-18 17:48:41 +07:00
Denozordec 653bc6cc91 fix(web): update Russian translations for various components
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 56s
CI / release (push) Successful in 4m4s
Обновлены переводы на русский язык для компонентов, включая карточки мониторинга, сетевые панели и настройки. Исправлены описания и метки для улучшения пользовательского интерфейса, а также добавлены новые элементы для поддержки локализации в компонентах, таких как DataGrid и ResourcePage.
2026-08-18 17:00:15 +07:00
Denozordec e0d695f2a4 refactor(web): unify settings navigation and update tab structure
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 55s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m6s
Refactored the settings components to unify navigation between UI and BIRD settings. Updated tab structures to streamline access and improve user experience. Adjusted routing and search parameters to reflect the new tab organization, ensuring a cohesive interface. Removed legacy tenant settings references and enhanced the settings page layout for clarity and usability.
2026-08-18 16:30:10 +07:00
Denozordec dfc33dcb99 refactor(web): streamline dashboard and tab components for improved layout
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 57s
CI / release (push) Successful in 4m24s
Refactored various dashboard components and tab structures to enhance layout and responsiveness. Adjusted class names to include 'min-w-0' for better overflow handling and improved alignment. Updated the ChartDonutMetric to include a tooltip for better data representation. Simplified the DashboardRecentJobsGrid and DashboardRecentRevisionsGrid by replacing DataGridSection with DataGridShell for consistency. Enhanced the OpsDashboard layout for better clarity and usability.
2026-08-18 16:03:52 +07:00
Denozordec 44f70443c3 refactor(web): improve layout and responsiveness of settings components
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 53s
CI / web (push) Successful in 1m16s
CI / release (push) Successful in 4m24s
Enhanced various settings components by adding 'min-w-0' class for better overflow handling and adjusting flex properties for improved alignment. Updated card and grid structures to ensure a cohesive user experience across the settings interface. Refined descriptions and layout in several settings tabs for better clarity and usability.
2026-08-18 01:19:42 +07:00
110 changed files with 3851 additions and 1555 deletions
+1 -1
View File
@@ -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` |
--- ---
+4 -4
View File
@@ -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
+49
View File
@@ -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
+41 -16
View File
@@ -1,38 +1,43 @@
# 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, образы не публикуются.
Повтор упавшего **release** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-release). Повтор упавшего **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).
### Теги образов ### Теги образов
@@ -46,6 +51,8 @@ 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`.
Пример: Пример:
@@ -55,3 +62,21 @@ 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 не будет.
+148
View File
@@ -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 -380
View File
@@ -1,387 +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
fetch-tags: true
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.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
# Re-run after a failed docker step: tag already exists, successCmd
# did not write .release-version (semantic-release is a no-op).
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@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.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- 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
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
+428
View File
@@ -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
View File
@@ -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'
@@ -43,6 +45,7 @@ export function ChartDonutMetric({
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm: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"
@@ -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,15 +40,15 @@ 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={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`} footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
+6 -3
View File
@@ -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 ? (
@@ -100,7 +100,7 @@ function buildKpis({
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'} variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm" size="sm"
> >
{loading ? '…' : 'онлайн'} {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',
@@ -167,7 +167,7 @@ export function DashboardModulesGrid({
description="Поиск, сортировка и быстрый переход к настройке" description="Поиск, сортировка и быстрый переход к настройке"
className="min-w-0" 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>
@@ -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: 'Проверка IP в списках и BGP-сообществах.', 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: 'Справочник BGP-сообществ для политик экспорта.', description: 'Справочник BGP community для политик экспорта.',
to: '/directories', to: '/directories',
icon: <Tags aria-hidden />, icon: <Tags aria-hidden />,
iconClassName: 'text-info', iconClassName: 'text-info',
@@ -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="Поиск ревизий…"
/> />
) )
} }
+1 -1
View File
@@ -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"
+6 -8
View File
@@ -8,7 +8,6 @@ import {
Settings, Settings,
BookText, BookText,
KeyRound, KeyRound,
ServerCog,
Search, Search,
} from 'lucide-react' } from 'lucide-react'
@@ -72,7 +71,7 @@ const NAV_GROUPS: NavGroup[] = [
to: '/dashboard', to: '/dashboard',
label: 'Панель', label: 'Панель',
icon: LayoutDashboard, icon: LayoutDashboard,
description: 'KPI, модули и активность', description: 'Метрики, модули и активность',
}, },
], ],
}, },
@@ -82,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' } },
], ],
}, },
] ]
+1 -1
View File
@@ -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"
> >
Настройки Настройки
+3 -3
View File
@@ -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>
), ),
}, },
{ {
@@ -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"
@@ -55,7 +55,7 @@ export function NetworkKpi({
} }
size="sm" size="sm"
> >
{loading ? '…' : 'онлайн'} {loading ? '…' : 'в сети'}
</Badge> </Badge>
), ),
to: '/network', to: '/network',
@@ -39,11 +39,11 @@ const PEER_TABS = [
const SESSION_STATE_OPTIONS = [ const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Установлена' }, { 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)}`
} }
@@ -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 -4
View File
@@ -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>
) )
} }
@@ -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
} }
/> />
) )
@@ -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 ? (
+1 -1
View File
@@ -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'
@@ -305,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'
@@ -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="flex min-w-0 flex-col gap-4"
>
{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 min-w-0"> <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>
) )
@@ -1,125 +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: '/settings' | '/tenant-settings'
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,
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
const headerDescription =
description ??
(pathname.startsWith('/tenant-settings')
? 'Глобальные параметры BIRD и плоскости управления'
: 'Подключение UI и параметры плоскости управления')
return ( return (
<PageShell> <PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5"> <Outlet />
<PageHeader title={title} description={headerDescription} />
<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)
const 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',
)
if (tab.to === '/tenant-settings') {
return (
<Link
key={tab.id}
to="/tenant-settings"
search={{ tab: 'bird' }}
aria-current={isActive ? 'page' : undefined}
className={className}
>
{tab.icon}
{tab.label}
</Link>
)
}
return (
<Link
key={tab.id}
to="/settings"
search={{ tab: 'connection' }}
aria-current={isActive ? 'page' : undefined}
className={className}
>
{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>
+49 -54
View File
@@ -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: "Некорректное значение",
}, },
} }
@@ -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">
@@ -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: 'Расписание' },
@@ -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,6 +1,6 @@
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'
@@ -8,6 +8,7 @@ import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/settings/settings-card' import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/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-ключ"
@@ -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>
@@ -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">
@@ -10,31 +10,59 @@ import {
FieldTitle, FieldTitle,
} from '@evobgp/ui/components/field' } from '@evobgp/ui/components/field'
interface SettingRowProps { export interface SettingRowProps {
title: string title: string
description?: ReactNode description?: ReactNode
children: ReactNode children: ReactNode
last?: boolean last?: boolean
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
separated?: boolean
compact?: boolean
stacked?: boolean
labelFor?: string labelFor?: string
contentClassName?: string contentClassName?: string
className?: string
titleAddon?: ReactNode titleAddon?: ReactNode
} }
/** Settings row — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */ /**
* 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({ export function SettingRow({
title, title,
description, description,
children, children,
last, last,
separated = false,
compact,
stacked,
labelFor, labelFor,
contentClassName, contentClassName,
className,
titleAddon, titleAddon,
}: SettingRowProps) { }: SettingRowProps) {
return ( return (
<> <>
<Field orientation="responsive" className="gap-4 px-5 py-3"> <Field
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm"> orientation={stacked ? 'vertical' : 'responsive'}
<div className="flex flex-wrap items-center gap-2"> 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 ? ( {labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel> <FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : ( ) : (
@@ -44,20 +72,33 @@ export function SettingRow({
</div> </div>
{description ? ( {description ? (
<FieldDescription>{description}</FieldDescription> <FieldDescription className="text-sm break-words">{description}</FieldDescription>
) : null} ) : null}
</div> </div>
<FieldContent <FieldContent
className={cn('min-w-0 @md/field-group:w-78', contentClassName)} 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="flex w-full justify-start @md/field-group:justify-end"> <div
className={cn(
'flex w-full min-w-0 justify-start',
stacked ? 'justify-start' : '@md/field-group:justify-end',
)}
>
{children} {children}
</div> </div>
</FieldContent> </FieldContent>
</Field> </Field>
{!last ? <FieldSeparator /> : null} {separated && !last ? <FieldSeparator /> : null}
</> </>
) )
} }
@@ -31,7 +31,7 @@ export function SettingsCard({
footerClassName, footerClassName,
}: SettingsCardProps) { }: SettingsCardProps) {
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">
<FrameHeader className="gap-0 border-b px-5 py-3"> <FrameHeader className="gap-0 border-b px-5 py-3">
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
@@ -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'
} }
+1 -1
View File
@@ -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 ноды` },
] ]
+8 -3
View File
@@ -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) {
@@ -38,7 +38,7 @@ describe('isReadyCheckOk', () => {
describe('readyCheckStatusLabel', () => { describe('readyCheckStatusLabel', () => {
it('labels memory and failures', () => { it('labels memory and failures', () => {
expect(readyCheckStatusLabel('memory', true)).toBe('Memory') expect(readyCheckStatusLabel('memory', true)).toBe('В памяти')
expect(readyCheckStatusLabel('ok', true)).toBe('В норме') expect(readyCheckStatusLabel('ok', true)).toBe('В норме')
expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно') expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно')
}) })
@@ -54,7 +54,7 @@ export function readyCheckStatusLabel(value: ReadyCheckValue, ok: boolean): stri
} }
if (typeof value === 'string') { if (typeof value === 'string') {
const n = value.trim().toLowerCase() const n = value.trim().toLowerCase()
if (n === 'memory') return 'Memory' if (n === 'memory') return 'В памяти'
if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме' if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме'
if (n) return value if (n) return value
} }
+50 -4
View File
@@ -30,9 +30,28 @@ export function moduleTypeRu(type: string): string {
const JOB_KIND_RU: Record<string, string> = { const JOB_KIND_RU: Record<string, string> = {
module_refresh: 'Обновление модуля', module_refresh: 'Обновление модуля',
tenant_refresh: 'Обновление тенанта',
peer_reconcile: 'Согласование пиров',
deploy_apply: 'Применение на спикеры',
apply: 'Применение конфигурации', apply: 'Применение конфигурации',
revision_rollback: 'Откат ревизии',
rollback: 'Откат ревизии', rollback: 'Откат ревизии',
bird_reload: 'Перезагрузка BIRD', bird_reload: 'Перезагрузка BIRD',
postgres_metrics_refresh: 'Метрики PostgreSQL',
postgres_slow_query_aggregate: 'Медленные запросы PostgreSQL',
postgres_table_bloat_estimate: 'Bloat таблиц PostgreSQL',
postgres_index_usage_analyze: 'Использование индексов PostgreSQL',
postgres_autovacuum_lag_detect: 'Отставание autovacuum',
postgres_vacuum: 'VACUUM PostgreSQL',
postgres_vacuum_analyze: 'VACUUM ANALYZE PostgreSQL',
postgres_analyze: 'ANALYZE PostgreSQL',
postgres_reindex: 'REINDEX PostgreSQL',
postgres_cleanup: 'Очистка PostgreSQL',
maintenance_policy_run: 'Политика обслуживания',
}
export function isRefreshJobKind(kind: string): boolean {
return kind === 'module_refresh' || kind === 'tenant_refresh'
} }
export function jobKindRu(kind: string): string { export function jobKindRu(kind: string): string {
@@ -61,18 +80,45 @@ const JOB_STATUS_RU: Record<string, string> = {
paused: 'Приостановлен', paused: 'Приостановлен',
disabled: 'Выключен', disabled: 'Выключен',
archived: 'В архиве', archived: 'В архиве',
block: 'block', block: 'Блокировать',
accept: 'accept', accept: 'Принимать',
} }
export function jobStatusRu(status: string): string { export function jobStatusRu(status: string): string {
return JOB_STATUS_RU[status.toLowerCase()] ?? status return JOB_STATUS_RU[status.toLowerCase()] ?? status
} }
const BGP_SESSION_STATE_RU: Record<string, string> = {
Idle: 'Простой',
Connect: 'Соединение',
Active: 'Поиск',
OpenSent: 'Open отправлен',
OpenConfirm: 'Open подтверждён',
Established: 'Установлена',
}
export function bgpSessionStateRu(state: string | null | undefined): string { export function bgpSessionStateRu(state: string | null | undefined): string {
if (!state) return '—' if (!state) return '—'
if (state === 'Established') return 'Установлена' return BGP_SESSION_STATE_RU[state] ?? state
return state }
export function speakerRoleRu(role: string | null | undefined): string {
switch (role) {
case 'master':
return 'Основной'
case 'primary':
return 'Основной'
case 'secondary':
return 'Резервный'
case 'speaker':
return 'Спикер'
default:
return role?.trim() || '—'
}
}
export function cdnSourceKindRu(kind: string): string {
return kind === 'json' ? 'JSON' : 'Текст'
} }
export function speakerOnlineLabel(agentOk: boolean | undefined): string { export function speakerOnlineLabel(agentOk: boolean | undefined): string {
+4 -4
View File
@@ -41,11 +41,11 @@ export function useCreateCommunityMutation() {
mutationFn: (body: BgpCommunityCreate) => mutationFn: (body: BgpCommunityCreate) =>
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }), apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
onSuccess: () => { onSuccess: () => {
toast.success('Сообщество создано') toast.success('Community создано')
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() }) void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
}, },
onError: (e) => onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось создать сообщество'), toast.error(e instanceof Error ? e.message : 'Не удалось создать community'),
}) })
} }
@@ -55,11 +55,11 @@ export function useUpdateCommunityMutation() {
mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) => mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) =>
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }), apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => { onSuccess: () => {
toast.success('Сообщество обновлено') toast.success('Community обновлено')
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() }) void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
}, },
onError: (e) => onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось обновить сообщество'), toast.error(e instanceof Error ? e.message : 'Не удалось обновить community'),
}) })
} }
+13
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { apiJSON, apiMutate } from '@/lib/api-client' import { apiJSON, apiMutate } from '@/lib/api-client'
import { overviewKeys } from '@/queries/overview' import { overviewKeys } from '@/queries/overview'
import type { import type {
ModuleCreate,
ModulePatch, ModulePatch,
ModuleRow, ModuleRow,
ModulesResponse, ModulesResponse,
@@ -64,6 +65,18 @@ export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
}) })
} }
export function useCreateModuleMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ModuleCreate) => apiMutate<ModuleRow>('/v1/modules', 'POST', body),
onSuccess: (data) => {
toast.success('Модуль создан')
invalidateModules(qc, data.id)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать модуль'),
})
}
export function useUpdateModuleMutation() { export function useUpdateModuleMutation() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
+1 -1
View File
@@ -62,7 +62,7 @@ export const Route = createFileRoute('/_auth')({
if (!raw || !normalizeApiToken(raw)) { if (!raw || !normalizeApiToken(raw)) {
throw redirect({ throw redirect({
to: '/settings', to: '/settings',
search: { tab: 'connection', reason: 'token-required' }, search: { tab: 'ui', reason: 'token-required' },
}) })
} }
}, },
@@ -1,31 +1,39 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { SettingsPageShell } from '@/components/settings/settings-page-shell' import { SettingsPageShell } from '@/components/settings/settings-page-shell'
import { type SettingsTab } from '@/components/settings/settings-tabs-data' import {
parseSettingsSection,
const settingsSearchSchema = z.object({ type SettingsSection,
tab: z } from '@/components/settings/settings-tabs-data'
.enum(['connection', 'session', 'appearance', 'sections']) import { can } from '@/lib/auth'
.catch('connection'),
reason: z.enum(['token-required']).optional(),
})
export const Route = createFileRoute('/_auth/_settings/settings')({ export const Route = createFileRoute('/_auth/_settings/settings')({
component: SettingsComponent, component: SettingsComponent,
validateSearch: (search) => settingsSearchSchema.parse(search), validateSearch: (search: Record<string, unknown>): {
tab: SettingsSection
reason?: 'token-required'
} => ({
tab: parseSettingsSection(search.tab),
...(search.reason === 'token-required'
? { reason: 'token-required' as const }
: {}),
}),
}) })
function SettingsComponent() { function SettingsComponent() {
const navigate = Route.useNavigate() const navigate = Route.useNavigate()
const { tab, reason } = Route.useSearch() const { tab, reason } = Route.useSearch()
const tokenRequired = reason === 'token-required' const tokenRequired = reason === 'token-required'
const showBird = !tokenRequired && can('bgp:tenant_settings:admin')
const activeTab: SettingsSection =
tokenRequired || (tab === 'bird' && !showBird) ? 'ui' : tab
function handleTabChange(nextTab: SettingsTab) { function handleTabChange(nextTab: SettingsSection) {
void navigate({ void navigate({
search: (prev) => ({ search: (prev) => ({
...prev, ...prev,
tab: nextTab, tab: nextTab,
reason: nextTab === 'ui' ? prev.reason : undefined,
}), }),
replace: true, replace: true,
}) })
@@ -33,9 +41,10 @@ function SettingsComponent() {
return ( return (
<SettingsPageShell <SettingsPageShell
activeTab={tab} activeTab={activeTab}
onTabChange={handleTabChange} onTabChange={handleTabChange}
tokenRequired={tokenRequired} tokenRequired={tokenRequired}
showBird={showBird}
/> />
) )
} }
@@ -1,358 +1,8 @@
import { createFileRoute, useSearch } from '@tanstack/react-router' import { createFileRoute, redirect } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { PanelCard } from '@/components/panel-card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { FrameDataGrid } from '@/components/reui-kit'
import { SelectField } from '@/components/select-field'
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'
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({ export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
component: TenantSettingsComponent, beforeLoad: () => {
validateSearch: (search: Record<string, unknown>) => ({ throw redirect({ to: '/settings', search: { tab: 'bird' } })
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional' },
? search.tab component: () => null,
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
}),
}) })
const RUNTIME_LOGS_ENABLED_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'обнулить (truncate)' },
{ value: 'delete', label: 'удалить файл (delete)' },
] as const
const BIRD_LABELS: Record<BirdSettingKey, string> = {
bird_router_id: 'Router ID',
bird_local_ipv4: 'Локальный IPv4',
bird_local_ipv6: 'Локальный IPv6',
bird_local_asn: 'Локальный ASN',
bird_bgp_source_ipv4: 'BGP source IPv4',
bird_bgp_source_ipv6: 'BGP source IPv6',
peer_discovery_enabled: 'Автообнаружение пиров',
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
peer_discovery_require_external: 'Только external ASN',
}
const BIRD_BOOL_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
peer_discovery_enabled:
'Dynamic neighbor range в 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: 'neighbor range … external (любой чужой ASN)',
}
function TenantSettingsComponent() {
const search = useSearch({ from: '/_auth/_settings/tenant-settings' })
const navigate = Route.useNavigate()
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">
<BadgeTabs
value={search.tab}
onValueChange={(tab) =>
navigate({
search: { tab: tab as 'bird' | 'revision' | 'runtime-logs' | 'additional' },
})
}
items={[
{ value: 'bird', label: 'BIRD' },
{ value: 'revision', label: 'Ревизии' },
{ value: 'runtime-logs', label: 'Файловые логи' },
{ value: 'additional', label: 'Дополнительно' },
]}
>
<TabsContent value="bird" className="mt-0">
<PanelCard
title="BIRD control plane"
description={
<>
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
</>
}
contentClassName="space-y-4 py-4"
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-64" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="flex flex-col">
{BIRD_SETTING_KEYS.map((key, index) => (
<SettingsSettingField
key={key}
title={BIRD_LABELS[key]}
description={BIRD_HINTS[key] ?? key}
labelFor={key}
badge={{ label: 'BIRD', variant: 'info-light' }}
last={index === BIRD_SETTING_KEYS.length - 1}
>
{key === 'peer_discovery_enabled' ||
key === 'peer_discovery_require_external' ? (
<SelectField
id={key}
items={[...BIRD_BOOL_ITEMS]}
value={birdForm[key] || 'false'}
onValueChange={(v) =>
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
}
placeholder="Выкл"
/>
) : (
<Input
id={key}
value={birdForm[key] ?? ''}
onChange={(e) =>
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
}
placeholder={BIRD_LABELS[key]}
/>
)}
</SettingsSettingField>
))}
<div className="px-4 py-4">
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
</div>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="revision" className="mt-0">
<PanelCard
title="Ревизии"
description="Время хранения ревизий в БД"
contentClassName="space-y-4 py-4"
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="flex max-w-sm flex-col gap-1.5">
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label>
<Input
id="revision_retention_minutes"
type="number"
value={revisionForm.revision_retention_minutes ?? ''}
onChange={(e) =>
setRevisionForm((s) => ({
...s,
revision_retention_minutes: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
revision_retention_minutes
</p>
<LoadingButton
className="mt-2 w-fit"
onClick={saveRevision}
loading={patchMutation.isPending}
>
<Save />
Сохранить
</LoadingButton>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="runtime-logs" className="mt-0">
<PanelCard
title="Файловые логи"
description="Автоматическая очистка логов"
contentClassName="space-y-4 py-4"
>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-48" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="grid gap-4 md:grid-cols-2">
<SelectField
label="Авто-очистка включена"
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
placeholder="Выберите"
description="runtime_logs_auto_enabled"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_enabled: v,
}))
}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
<Input
id="runtime_logs_max_file_mb"
type="number"
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_max_file_mb: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_max_file_mb
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label>
<Input
id="runtime_logs_auto_schedule"
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_schedule: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_schedule
</p>
</div>
<SelectField
label="Режим очистки"
items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
placeholder="Выберите"
description="runtime_logs_auto_mode"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_mode: v,
}))
}
/>
<div className="md:col-span-2">
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
</div>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="additional" className="mt-0">
<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>
</TabsContent>
</BadgeTabs>
</div>
)
}
+72 -51
View File
@@ -1,15 +1,23 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react' import { KeyRound, MonitorIcon, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { PanelCard } from '@/components/panel-card' import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@evobgp/ui/components/item'
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card' import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit' import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { SettingsCard } from '@/components/settings/settings-card'
import { SectionCardsSkeleton } from '@/components/skeletons' import { SectionCardsSkeleton } from '@/components/skeletons'
import { sessionCanManageApiKeys } from '@/lib/auth' import { sessionCanManageApiKeys } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth' import { authSessionQueryOptions } from '@/queries/auth'
@@ -38,16 +46,18 @@ function AccessComponent() {
const kpiItems: KpiStatItem[] = useMemo( const kpiItems: KpiStatItem[] = useMemo(
() => [ () => [
{ {
id: 'keys-total',
label: 'Всего ключей', label: 'Всего ключей',
value: keys.length, value: keys.length,
icon: <KeyRound className="size-4" />, icon: <KeyRound className="size-4" />,
footer: ( footer: (
<Badge variant="primary-light" size="sm"> <Badge variant="primary-light" size="sm">
в tenant в арендаторе
</Badge> </Badge>
), ),
}, },
{ {
id: 'keys-active',
label: 'Активных', label: 'Активных',
value: activeCount, value: activeCount,
icon: <ShieldCheck className="size-4" />, icon: <ShieldCheck className="size-4" />,
@@ -58,6 +68,7 @@ function AccessComponent() {
), ),
}, },
{ {
id: 'keys-revoked',
label: 'Отозванных', label: 'Отозванных',
value: revokedCount, value: revokedCount,
icon: <ShieldOff className="size-4" />, icon: <ShieldOff className="size-4" />,
@@ -78,25 +89,25 @@ function AccessComponent() {
} }
const sessionKindLabel = const sessionKindLabel =
session?.kind === 'jwt' ? 'Portal JWT' : session?.kind === 'apikey' ? 'API-ключ' : null session?.kind === 'jwt' ? 'JWT портала' : session?.kind === 'apikey' ? 'API-ключ' : null
const sessionAccessLabel = (() => { const sessionAccessLabel = (() => {
if (!session) return null if (!session) return null
if (session.kind === 'jwt' || session.is_admin || (session.permissions?.length ?? 0) > 0) { if (session.kind === 'jwt' || session.is_admin || (session.permissions?.length ?? 0) > 0) {
if (session.is_admin) return 'admin (portal)' if (session.is_admin) return 'Администратор (портал)'
if (sessionCanManageApiKeys(session)) return 'bgp:access:admin' if (sessionCanManageApiKeys(session)) return 'bgp:access:admin'
return session.permissions?.length return session.permissions?.length
? session.permissions.slice(0, 3).join(', ') ? session.permissions.slice(0, 3).join(', ')
: 'без access:admin' : 'без права access:admin'
} }
return session.role || '—' return session.role || '—'
})() })()
return ( return (
<div className="flex flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
<PageHeader <PageHeader
title="Права доступа" title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена." description="API-ключи плоскости управления и текущая сессия."
actions={ actions={
canManageKeys ? ( canManageKeys ? (
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}> <Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
@@ -108,47 +119,55 @@ function AccessComponent() {
/> />
{session ? ( {session ? (
<PanelCard <SettingsCard
title="Текущая сессия" title="Текущая сессия"
description="Tenant и права текущего Bearer (API-ключ или portal JWT)." description="Арендатор и права текущей сессии (API-ключ или JWT портала)."
contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
> >
<div> <ItemGroup className="gap-0">
<p className="text-muted-foreground">Tenant</p> <Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
<p className="break-all font-mono text-xs">{session.tenant_id}</p> <ItemMedia className="self-start">
</div> <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">
<div> <MonitorIcon aria-hidden="true" />
<p className="text-muted-foreground">Доступ</p> </Item>
<p className="font-mono text-xs">{sessionAccessLabel}</p> </ItemMedia>
</div> <ItemContent className="min-w-0 gap-3">
{sessionKindLabel ? ( <div className="flex min-w-0 flex-wrap items-center gap-2">
<div> <ItemTitle className="leading-5">Этот браузер</ItemTitle>
<p className="text-muted-foreground">Тип</p> {sessionKindLabel ? (
<p className="font-mono text-xs">{sessionKindLabel}</p> <Badge variant="info-light" size="sm">
</div> {sessionKindLabel}
) : null} </Badge>
{session.email ? ( ) : null}
<div> </div>
<p className="text-muted-foreground">Email</p> <ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
<p className="break-all text-xs">{session.email}</p> <span className="break-all">
</div> Арендатор:{' '}
) : null} <code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
</PanelCard> </span>
<span className="break-words">Доступ: {sessionAccessLabel}</span>
{session.email ? <span className="break-all">Email: {session.email}</span> : null}
</ItemDescription>
</ItemContent>
</Item>
</ItemGroup>
</SettingsCard>
) : ( ) : (
<PanelCard contentClassName="py-4 text-sm text-muted-foreground"> <SettingsCard title="Текущая сессия">
Не удалось определить сессию. Укажите токен в{' '} <p className="text-muted-foreground px-5 py-4 text-sm">
<Link Не удалось определить сессию. Укажите токен в{' '}
to="/settings" <Link
search={{ tab: 'connection' }} to="/settings"
className="text-primary underline-offset-4 hover:underline" search={{ tab: 'ui' }}
> className="text-primary underline-offset-4 hover:underline"
настройках >
</Link>{' '} настройках
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed). </Link>{' '}
{sessionQuery.isError && sessionQuery.error instanceof Error ? ( (для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span> {sessionQuery.isError && sessionQuery.error instanceof Error ? (
) : null} <span className="text-destructive mt-2 block">{sessionQuery.error.message}</span>
</PanelCard> ) : null}
</p>
</SettingsCard>
)} )}
{canManageKeys ? ( {canManageKeys ? (
@@ -167,11 +186,13 @@ function AccessComponent() {
/> />
</> </>
) : session ? ( ) : session ? (
<PanelCard contentClassName="py-4 text-sm text-muted-foreground"> <SettingsCard title="API-ключи">
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT <p className="text-muted-foreground px-5 py-4 text-sm break-words">
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>. Управление API-ключами доступно роли <strong>оператор</strong> (API-ключ) или JWT портала
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>. с правом администратора / <code className="text-xs">bgp:access:admin</code>.
</PanelCard> Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
</p>
</SettingsCard>
) : null} ) : null}
</div> </div>
) )
+7 -10
View File
@@ -7,7 +7,6 @@ import { Button } from '@evobgp/ui/components/button'
import { Skeleton } from '@evobgp/ui/components/skeleton' import { Skeleton } from '@evobgp/ui/components/skeleton'
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline' import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid' import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid' import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health' import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
@@ -15,7 +14,7 @@ import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-o
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links' import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid' import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid' import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
import { OpsDashboard } from '@/components/reui-kit' import { FrameDataGrid, OpsDashboard } from '@/components/reui-kit'
import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface' import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface'
import { import {
moduleNameById, moduleNameById,
@@ -111,7 +110,7 @@ function DashboardComponent() {
peers={peers} peers={peers}
speakers={speakers} speakers={speakers}
/> />
<div className="grid min-w-0 gap-4"> <div className="grid min-w-0 items-start gap-4">
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} /> <DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsBreakdown jobs={jobs} modules={modules} /> <DashboardOperationsBreakdown jobs={jobs} modules={modules} />
</div> </div>
@@ -122,28 +121,26 @@ function DashboardComponent() {
queueDescription="Последние фоновые операции и история конфигураций" queueDescription="Последние фоновые операции и история конфигураций"
queue={ queue={
<div className={chartPanelGridClassName}> <div className={chartPanelGridClassName}>
<DashboardFramePanel <FrameDataGrid
title="Недавние задачи" title="Недавние задачи"
description="Последние фоновые операции" description="Последние фоновые операции"
className="h-full min-w-0"
> >
{activityLoading ? ( {activityLoading ? (
<Skeleton className="m-4 h-24 w-auto" /> <Skeleton className="m-4 h-24 w-auto" />
) : ( ) : (
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} /> <DashboardRecentJobsGrid jobs={jobs.slice(0, 8)} nameById={nameById} isLoading={refreshing} />
)} )}
</DashboardFramePanel> </FrameDataGrid>
<DashboardFramePanel <FrameDataGrid
title="Последние ревизии" title="Последние ревизии"
description="История конфигураций" description="История конфигураций"
className="h-full min-w-0"
> >
{activityLoading ? ( {activityLoading ? (
<Skeleton className="m-4 h-24 w-auto" /> <Skeleton className="m-4 h-24 w-auto" />
) : ( ) : (
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} /> <DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
)} )}
</DashboardFramePanel> </FrameDataGrid>
</div> </div>
} }
/> />
+6 -6
View File
@@ -45,7 +45,7 @@ function DirectoriesComponent() {
const items: KpiStatItem[] = [ const items: KpiStatItem[] = [
{ {
label: 'Сообщества BGP', label: 'BGP community',
value: communities.length, value: communities.length,
icon: <Tags className="size-4" />, icon: <Tags className="size-4" />,
footer: ( footer: (
@@ -70,7 +70,7 @@ function DirectoriesComponent() {
icon: <BookText className="size-4" />, icon: <BookText className="size-4" />,
footer: ( footer: (
<Badge variant="outline" size="sm"> <Badge variant="outline" size="sm">
все модули tenant все модули арендатора
</Badge> </Badge>
), ),
}, },
@@ -114,7 +114,7 @@ function DirectoriesComponent() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
title="Справочники" title="Справочники"
description="Сообщества BGP и DoH-профили для резолвинга доменов" description="BGP community и DoH-профили для резолвинга доменов"
actions={ actions={
<Button <Button
variant="outline" variant="outline"
@@ -136,13 +136,13 @@ function DirectoriesComponent() {
<BadgeTabs <BadgeTabs
defaultValue="communities" defaultValue="communities"
items={[ items={[
{ value: 'communities', label: 'Сообщества BGP', count: communities.length }, { value: 'communities', label: 'BGP community', count: communities.length },
{ value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' }, { value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
]} ]}
> >
<TabsContent value="communities" className="mt-0"> <TabsContent value="communities" className="mt-0">
<FrameDataGrid <FrameDataGrid
title="Сообщества BGP" title="BGP community"
description="Теги для префиксов в фильтрах BIRD" description="Теги для префиксов в фильтрах BIRD"
actions={addCommunityButton} actions={addCommunityButton}
> >
@@ -152,7 +152,7 @@ function DirectoriesComponent() {
isError={communitiesQ.isError} isError={communitiesQ.isError}
error={communitiesQ.error} error={communitiesQ.error}
empty={communities.length === 0} empty={communities.length === 0}
emptyTitle="Нет сообществ" emptyTitle="Нет community"
emptyAction={addCommunityButton} emptyAction={addCommunityButton}
skeleton={<TableSkeleton rows={4} cols={3} />} skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => communitiesQ.refetch()} onRetry={() => communitiesQ.refetch()}
+50 -6
View File
@@ -1,4 +1,4 @@
import { Link, createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Plus, RefreshCw } from 'lucide-react' import { Plus, RefreshCw } from 'lucide-react'
@@ -6,18 +6,45 @@ import { Button } from '@evobgp/ui/components/button'
import { FrameDataGrid } from '@/components/reui-kit' import { FrameDataGrid } from '@/components/reui-kit'
import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state' import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
import { ModuleCreateDialog } from '@/components/modules/module-create-dialog'
import { ModulesListGrid } from '@/components/modules/modules-list-grid' import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons' import { TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteModules } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth'
import {
directoriesCommunitiesQueryOptions,
directoriesDohQueryOptions,
} from '@/queries/directories'
import { modulesListQueryOptions } from '@/queries/modules' import { modulesListQueryOptions } from '@/queries/modules'
function parseCreateFlag(value: unknown): boolean {
return value === true || value === '1' || value === 'true'
}
export const Route = createFileRoute('/_auth/modules/')({ export const Route = createFileRoute('/_auth/modules/')({
component: ModulesListComponent, component: ModulesListComponent,
validateSearch: (search: Record<string, unknown>): { create?: boolean } => {
if (parseCreateFlag(search.create)) return { create: true }
return {}
},
}) })
function ModulesListComponent() { function ModulesListComponent() {
const { create } = Route.useSearch()
const navigate = Route.useNavigate()
const query = useQuery(modulesListQueryOptions()) const query = useQuery(modulesListQueryOptions())
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteModules(sessionQ.data)
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
const createOpen = canWrite && create === true
function setCreateOpen(open: boolean) {
void navigate({ search: open ? { create: true } : {}, replace: true })
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -42,10 +69,12 @@ function ModulesListComponent() {
<FrameDataGrid <FrameDataGrid
title="Все модули" title="Все модули"
actions={ actions={
<Button size="sm" render={<Link to="/modules/new" />}> canWrite ? (
<Plus /> <Button size="sm" onClick={() => setCreateOpen(true)}>
Создать <Plus />
</Button> Создать
</Button>
) : undefined
} }
> >
<QueryState <QueryState
@@ -54,7 +83,12 @@ function ModulesListComponent() {
isError={query.isError} isError={query.isError}
error={query.error} error={query.error}
empty={query.data?.items?.length === 0} empty={query.data?.items?.length === 0}
emptyContent={<ProjectsEmptyState />} emptyContent={
<ProjectsEmptyState
canCreate={canWrite}
onCreate={() => setCreateOpen(true)}
/>
}
skeleton={<TableSkeleton rows={6} cols={5} />} skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()} onRetry={() => query.refetch()}
> >
@@ -66,6 +100,16 @@ function ModulesListComponent() {
)} )}
</QueryState> </QueryState>
</FrameDataGrid> </FrameDataGrid>
<ModuleCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
communities={communitiesQ.data?.items ?? []}
dohProfiles={dohQ.data?.items ?? []}
onCreated={(mod) => {
void navigate({ to: '/modules/$moduleId', params: { moduleId: mod.id } })
}}
/>
</div> </div>
) )
} }
+4 -39
View File
@@ -1,42 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, redirect } from '@tanstack/react-router'
import { Button } from '@evobgp/ui/components/button'
import { PageHeader } from '@/components/page-header'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
export const Route = createFileRoute('/_auth/modules/new')({ export const Route = createFileRoute('/_auth/modules/new')({
component: NewModuleComponent, beforeLoad: () => {
throw redirect({ to: '/modules', search: { create: true } })
},
}) })
function NewModuleComponent() {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<PageHeader
title="Новый модуль"
description="Создание модуля — через API или будущая форма"
/>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Создание через API</FrameTitle>
<FrameDescription>
Форма в UI появится позже. Сейчас модуль можно создать запросом ниже.
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3 text-sm text-muted-foreground">
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{`POST /v1/modules
{ "type": "DOMAINS", "name": "Мой список" }`}
</pre>
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
Назад к списку
</Button>
</FramePanel>
</Frame>
</div>
)
}
+7 -8
View File
@@ -127,7 +127,7 @@ function MonitoringComponent() {
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2"> <div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
<FrameDataGrid <FrameDataGrid
title="Доступность и готовность" title="Доступность и готовность"
description="GET /v1/health · GET /v1/ready" description="Проверки живучести и готовности (/v1/health, /v1/ready)"
> >
<QueryState <QueryState
data={readyQ.data} data={readyQ.data}
@@ -148,7 +148,7 @@ function MonitoringComponent() {
BGP на API-хосте BGP на API-хосте
</span> </span>
} }
description="GET /v1/bird/status" description="Статус BIRD на хосте API"
className="h-full" className="h-full"
contentClassName="px-5 py-4" contentClassName="px-5 py-4"
> >
@@ -169,7 +169,7 @@ function MonitoringComponent() {
<div className="flex flex-col gap-2 md:gap-3"> <div className="flex flex-col gap-2 md:gap-3">
<SegmentedProgressCard <SegmentedProgressCard
title="Задачи" title="Задачи"
description="Последние 100 задач · GET /v1/jobs" description="Последние 100 задач"
primary={{ primary={{
value: jobs.filter((j) => j.status === 'running' || j.status === 'queued').length, value: jobs.filter((j) => j.status === 'running' || j.status === 'queued').length,
label: 'Активных', label: 'Активных',
@@ -230,11 +230,10 @@ function MonitoringComponent() {
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '} <span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '} <code className="text-xs">PostgreSQL</code>, затем хранилище и очередь задач в проверках.
и <code className="text-xs">jobs</code> в проверках.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '} <span className="font-medium text-foreground">Низкая доля установленных BGP-сессий.</span> Проверьте{' '}
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети. <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</li> </li>
<li> <li>
@@ -250,7 +249,7 @@ function MonitoringComponent() {
<IllustratedEmptyState <IllustratedEmptyState
icon={Database} icon={Database}
title="PostgreSQL" title="PostgreSQL"
description="Статус соединения и пул отображаются в readiness-проверке на вкладке «Система» (check postgres)." description="Состояние соединения и пула видно в проверке готовности на вкладке «Система»."
/> />
</TabsContent> </TabsContent>
@@ -258,7 +257,7 @@ function MonitoringComponent() {
<IllustratedEmptyState <IllustratedEmptyState
icon={FileText} icon={FileText}
title="Файловые логи" title="Файловые логи"
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в tenant-settings." description="Логи API и конвейера задаются переменной EVOBGP_LOG_* и управляются в Настройках BIRD."
/> />
</TabsContent> </TabsContent>
</BadgeTabs> </BadgeTabs>
File diff suppressed because one or more lines are too long
+36 -23
View File
@@ -2,29 +2,38 @@
## CI (Gitea Actions) ## CI (Gitea Actions)
Сборка образов — **`docker buildx bake`** (`deploy/docker/docker-bake.hcl`), не отдельные `docker build`. Сборка образов — **`docker buildx bake`** ([docker-bake.hcl](docker-bake.hcl)), не отдельные `docker build`.
**Publish:** push в `main` после quality gates — job **release** в [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml): semantic-release + bake с `VERSION` из релиза. **Publish:** push в `main` после quality gates — workflow [CD](../../.gitea/workflows/cd.yaml) job **publish**: semantic-release + зеркало base-образов + bake с `VERSION` из релиза.
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`. Bake читает переменные из **окружения** (`REGISTRY`, `IMAGE_TAG`, `CACHE_REF_*`, `BASE_*`). Скрипт [write-bake-override.sh](write-bake-override.sh) — опциональный helper для локальной отладки.
| Было | Стало | BuildKit кэширует `/go/pkg/mod`, `~/.cache/go-build` и pnpm store через `RUN --mount=type=cache`. На CI mounts живут, пока named builder `evobgp` не удаляют (`cleanup: false`).
|------|--------|
| 8× `go mod download` + 8× `go build` (разные BIN) | 1× download + 1× компиляция всех `cmd/*` |
| 2× сборка BIRD из исходников (api, all) | 1× stage `birdc`, копируется в runtime |
| 2× `npm ci` (web, web-all) | 1× `web-deps` + 1× `web-build` + два nginx-образа (общий артефакт) |
| 8 runner'ов с checkout/login | 1 job `docker-go`, 1 job `docker-web` |
Кэш registry (переменные bake): ### Слои и runtime
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache`**запись** только из target `go-build-all` | Образ | Runtime base | Заметка |
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache`**запись** только из target `web-build` |-------|----------------|---------|
| scheduler, ingest, render | `gcr.io/distroless/static-debian12:nonroot` | static Go (`CGO_ENABLED=0`), без shell |
| api, all, deploy, node | `debian:bookworm-slim` + `bird` + `birdc` | `bird -p` (parse-check) и `birdc`; демон не запускается |
| agent | тот же Ubuntu+bird2, что bird2 | общие слои с `evobgp-bird2` |
| bird2 | Ubuntu Noble + пакет bird2 | |
| web, web-all | `nginx:1.27-alpine` | `worker_processes 1` |
Остальные bake-target’ы только `cache-from` (чтение). Параллельный `cache-to` в один ref ломает manifest в registry (`content descriptor … not found`). Сборка Go: `golang:1.24-alpine`. Context режется корневым [.dockerignore](../../.dockerignore).
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`. ### Кэш registry
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evobgp-buildcache:go-buildcache` и `:web-buildcache` в registry и пересоберите. - `git.shx.one/<owner>/evobgp-buildcache:go-buildcache`**запись** только из `go-build-all`
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache`**запись** только из `web-build`
- `git.shx.one/<owner>/evobgp-buildcache:birdc-buildcache`**запись** только из `go-birdc`
- `git.shx.one/<owner>/evobgp-buildcache:base-*` — зеркало FROM с **Docker Hub** (`docker.io/library/…`; distroless — `gcr.io`). Только `linux/amd64`; skip если тег уже в Gitea. Неуспешный copy не валит CD — bake берёт Docker Hub FROM для этой базы.
`pull = false` в bake: не перекачивать FROM, если слой уже в builder. Не запускайте `docker system prune -a` на runner.
Параллельный `cache-to` в один ref ломает manifest (`content descriptor … not found`).
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evobgp-buildcache:*` в registry и пересоберите.
## Локальная сборка ## Локальная сборка
@@ -37,20 +46,24 @@ export IMAGE_TAG=latest
export SHORT_SHA=$(git rev-parse --short HEAD) export SHORT_SHA=$(git rev-parse --short HEAD)
export VERSION=dev export VERSION=dev
export BUILD_TIME= export BUILD_TIME=
sh write-bake-override.sh docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl go-images
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl go-images docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl web-images
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl web-images
``` ```
В `docker-bake.hcl`: `context = "../.."` (корень репо), `dockerfile = "deploy/docker/…"` (путь от корня репо). **CI:** `write-bake-override.sh` + `--allow=fs.read=$GITHUB_WORKSPACE` в `.gitea/workflows/ci.yaml`. Проверка манифеста без сборки:
Один образ (legacy): ```bash
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl --print default
```
В `docker-bake.hcl`: `context = "../.."` резолвится **от cwd** (каталог `deploy/docker/`). `dockerfile` — путь от этого context (корень репо). Из корня репо не вызывать bake с `-f deploy/docker/docker-bake.hcl`: получится `lstat ../../deploy`. Можно задать `BUILDX_BAKE_FILE_RELATIVE_PATHS=1`.
Один образ (legacy, target `build-all`):
```bash ```bash
docker build -f deploy/docker/gobinary/Dockerfile \ docker build -f deploy/docker/gobinary/Dockerfile \
--target build-all \ --target build-all \
--build-arg BIN=evobgp-api \ -t evobgp-build-all:local .
-t evobgp-api:local .
``` ```
Runtime-образы в bake ожидают stage `build-all` (через bake contexts), не отдельный `--build-arg BIN` на старый target `build`. Runtime-образы в bake ожидают stage `build-all` (через bake contexts).
+13 -6
View File
@@ -1,10 +1,17 @@
# syntax=docker/dockerfile:1.7
# BIRD из репозитория Ubuntu (Noble 24.04 LTS) — актуальнее пакета Debian bookworm. # BIRD из репозитория Ubuntu (Noble 24.04 LTS) — актуальнее пакета Debian bookworm.
# Сборка из корня репозитория: docker build -f deploy/docker/bird2/Dockerfile . # Stage bird2-base общий с evobgp-agent (одинаковые слои на speaker-VPS).
# Зеркало ECR Public вместо прямого pull с Docker Hub. ARG BASE_UBUNTU=docker.io/library/ubuntu:noble
FROM public.ecr.aws/docker/library/ubuntu:noble
RUN apt-get update \ FROM ${BASE_UBUNTU} AS bird2-base
&& apt-get install -y --no-install-recommends bird2 \ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
&& rm -rf /var/lib/apt/lists/* --mount=type=cache,target=/var/lib/apt,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean \
&& apt-get update \
&& apt-get install -y --no-install-recommends bird2 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
FROM bird2-base AS bird2
COPY deploy/bird/bird.conf /etc/bird/bird.conf COPY deploy/bird/bird.conf /etc/bird/bird.conf
RUN mkdir -p /etc/bird/bird.d RUN mkdir -p /etc/bird/bird.d
EXPOSE 179 EXPOSE 179
+110 -27
View File
@@ -1,7 +1,7 @@
# Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl). # Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl).
# context = "../.." корень репозитория (относительно этого файла). # context = "../.." корень репо. Bake резолвит context от cwd (не от HCL),
# dockerfile путь от корня репозитория (относительно context). # поэтому вызов из deploy/docker/ либо BUILDX_BAKE_FILE_RELATIVE_PATHS=1.
# Переменные: REGISTRY, IMAGE_TAG, CACHE_REF_GO, CACHE_REF_WEB # Переменные Bake читаются из окружения (см. docs.docker.com/build/bake/variables/).
variable "REGISTRY" { variable "REGISTRY" {
default = "git.shx.one/evobgp" default = "git.shx.one/evobgp"
@@ -35,14 +35,40 @@ variable "CACHE_REF_WEB" {
default = "" default = ""
} }
variable "CACHE_REF_BIRDC" {
default = ""
}
variable "BASE_GOLANG" {
default = "docker.io/library/golang:1.24-alpine"
}
variable "BASE_DEBIAN" {
default = "docker.io/library/debian:bookworm-slim"
}
variable "BASE_DISTROLESS" {
default = "gcr.io/distroless/static-debian12:nonroot"
}
variable "BASE_NODE" {
default = "docker.io/library/node:22-alpine"
}
variable "BASE_NGINX" {
default = "docker.io/library/nginx:1.27-alpine"
}
variable "BASE_UBUNTU" {
default = "docker.io/library/ubuntu:noble"
}
function "go-cache-from" { function "go-cache-from" {
params = [] params = []
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : [] result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : []
} }
# Экспорт кэша только go-build-all / web-build (один writer на ref). # Экспорт кэша один writer на ref (параллельный cache-to в один ref ломает manifest).
# Несколько target с cache-to в один ref и mode=max дают гонку в registry
# (content descriptor not found при параллельном bake).
function "go-cache-to-export" { function "go-cache-to-export" {
params = [] params = []
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO},mode=max"] : [] result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO},mode=max"] : []
@@ -58,6 +84,16 @@ function "web-cache-to-export" {
result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB},mode=max"] : [] result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB},mode=max"] : []
} }
function "birdc-cache-from" {
params = []
result = notequal("", CACHE_REF_BIRDC) ? ["type=registry,ref=${CACHE_REF_BIRDC}"] : []
}
function "birdc-cache-to-export" {
params = []
result = notequal("", CACHE_REF_BIRDC) ? ["type=registry,ref=${CACHE_REF_BIRDC},mode=max"] : []
}
group "default" { group "default" {
targets = ["go-images", "web-images", "evobgp-bird2"] targets = ["go-images", "web-images", "evobgp-bird2"]
} }
@@ -75,26 +111,38 @@ group "go-images" {
] ]
} }
# web-deps / web-build только зависимости (contexts), без tags; при --push в группе не указывать.
group "web-images" { group "web-images" {
targets = ["evobgp-web", "evobgp-web-all"] targets = ["evobgp-web", "evobgp-web-all"]
} }
target "_common" {
platforms = ["linux/amd64"]
pull = false
}
target "_go-bases" {
args = {
BASE_GOLANG = BASE_GOLANG
BASE_DEBIAN = BASE_DEBIAN
BASE_DISTROLESS = BASE_DISTROLESS
}
}
# --- Go: go mod download все cmd/* birdc (один раз) runtime-образы --- # --- Go: go mod download все cmd/* birdc (один раз) runtime-образы ---
target "go-deps" { target "go-deps" {
inherits = ["_common", "_go-bases"]
context = "../.." context = "../.."
dockerfile = "deploy/docker/gobinary/Dockerfile" dockerfile = "deploy/docker/gobinary/Dockerfile"
target = "deps" target = "deps"
platforms = ["linux/amd64"]
cache-from = go-cache-from() cache-from = go-cache-from()
} }
target "go-build-all" { target "go-build-all" {
inherits = ["_common", "_go-bases"]
context = "../.." context = "../.."
dockerfile = "deploy/docker/gobinary/Dockerfile" dockerfile = "deploy/docker/gobinary/Dockerfile"
target = "build-all" target = "build-all"
platforms = ["linux/amd64"]
args = { args = {
VERSION = VERSION VERSION = VERSION
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
@@ -108,18 +156,19 @@ target "go-build-all" {
} }
target "go-birdc" { target "go-birdc" {
inherits = ["_common", "_go-bases"]
context = "../.." context = "../.."
dockerfile = "deploy/docker/gobinary/Dockerfile" dockerfile = "deploy/docker/gobinary/Dockerfile"
target = "birdc" target = "birdc"
platforms = ["linux/amd64"] cache-from = birdc-cache-from()
cache-from = go-cache-from() cache-to = birdc-cache-to-export()
} }
target "_go-runtime" { target "_go-runtime" {
inherits = ["_common", "_go-bases"]
context = "../.." context = "../.."
dockerfile = "deploy/docker/gobinary/Dockerfile" dockerfile = "deploy/docker/gobinary/Dockerfile"
target = "runtime" target = "runtime"
platforms = ["linux/amd64"]
contexts = { contexts = {
build-all = "target:go-build-all" build-all = "target:go-build-all"
} }
@@ -127,15 +176,15 @@ target "_go-runtime" {
} }
target "_go-runtime-birdc" { target "_go-runtime-birdc" {
inherits = ["_common", "_go-bases"]
context = "../.." context = "../.."
dockerfile = "deploy/docker/gobinary/Dockerfile" dockerfile = "deploy/docker/gobinary/Dockerfile"
target = "runtime-birdc" target = "runtime-birdc"
platforms = ["linux/amd64"]
contexts = { contexts = {
build-all = "target:go-build-all" build-all = "target:go-build-all"
birdc = "target:go-birdc" birdc = "target:go-birdc"
} }
cache-from = go-cache-from() cache-from = concat(go-cache-from(), birdc-cache-from())
} }
function "image-tags" { function "image-tags" {
@@ -187,43 +236,62 @@ target "evobgp-render" {
} }
target "evobgp-deploy" { target "evobgp-deploy" {
inherits = ["_go-runtime"] inherits = ["_go-runtime-birdc"]
args = { BIN = "evobgp-deploy" } args = { BIN = "evobgp-deploy" }
tags = image-tags("evobgp-deploy") tags = image-tags("evobgp-deploy")
} }
target "evobgp-node" { target "evobgp-node" {
inherits = ["_go-runtime"] inherits = ["_go-runtime-birdc"]
args = { BIN = "evobgp-node" } args = { BIN = "evobgp-node" }
tags = image-tags("evobgp-node") tags = image-tags("evobgp-node")
} }
target "evobgp-bird2-base" {
inherits = ["_common"]
context = "../.."
dockerfile = "deploy/docker/bird2/Dockerfile"
target = "bird2-base"
args = {
BASE_UBUNTU = BASE_UBUNTU
}
}
target "evobgp-agent" { target "evobgp-agent" {
inherits = ["_common"]
context = "../.." context = "../.."
dockerfile = "deploy/docker/evobgp-agent/Dockerfile" dockerfile = "deploy/docker/evobgp-agent/Dockerfile"
platforms = ["linux/amd64"]
contexts = { contexts = {
build-all = "target:go-build-all" build-all = "target:go-build-all"
bird2-base = "target:evobgp-bird2-base"
} }
cache-from = go-cache-from() cache-from = go-cache-from()
tags = image-tags("evobgp-agent") tags = image-tags("evobgp-agent")
} }
# --- Web: npm ci (кэш) build nginx --- # --- Web ---
target "web-deps" { target "web-deps" {
inherits = ["_common"]
context = "../.." context = "../.."
dockerfile = "deploy/docker/evobgp-web/Dockerfile" dockerfile = "deploy/docker/evobgp-web/Dockerfile"
target = "deps" target = "deps"
platforms = ["linux/amd64"] args = {
BASE_NODE = BASE_NODE
BASE_NGINX = BASE_NGINX
}
cache-from = web-cache-from() cache-from = web-cache-from()
} }
target "web-build" { target "web-build" {
inherits = ["_common"]
context = "../.." context = "../.."
dockerfile = "deploy/docker/evobgp-web/Dockerfile" dockerfile = "deploy/docker/evobgp-web/Dockerfile"
target = "build" target = "build"
platforms = ["linux/amd64"] args = {
BASE_NODE = BASE_NODE
BASE_NGINX = BASE_NGINX
}
contexts = { contexts = {
deps = "target:web-deps" deps = "target:web-deps"
} }
@@ -232,34 +300,49 @@ target "web-build" {
} }
target "evobgp-web" { target "evobgp-web" {
inherits = ["_common"]
context = "../.." context = "../.."
dockerfile = "deploy/docker/evobgp-web/Dockerfile" dockerfile = "deploy/docker/evobgp-web/Dockerfile"
target = "web" target = "web"
platforms = ["linux/amd64"]
contexts = { contexts = {
web-artifacts = "target:web-build" web-artifacts = "target:web-build"
} }
args = { EVOBGP_UPSTREAM = "evobgp-api" } args = {
EVOBGP_UPSTREAM = "evobgp-api"
BASE_NODE = BASE_NODE
BASE_NGINX = BASE_NGINX
}
cache-from = web-cache-from() cache-from = web-cache-from()
tags = image-tags("evobgp-web") tags = image-tags("evobgp-web")
} }
target "evobgp-web-all" { target "evobgp-web-all" {
inherits = ["_common"]
context = "../.." context = "../.."
dockerfile = "deploy/docker/evobgp-web/Dockerfile" dockerfile = "deploy/docker/evobgp-web/Dockerfile"
target = "web" target = "web"
platforms = ["linux/amd64"]
contexts = { contexts = {
web-artifacts = "target:web-build" web-artifacts = "target:web-build"
} }
args = { EVOBGP_UPSTREAM = "evobgp-all" } args = {
EVOBGP_UPSTREAM = "evobgp-all"
BASE_NODE = BASE_NODE
BASE_NGINX = BASE_NGINX
}
cache-from = web-cache-from() cache-from = web-cache-from()
tags = image-tags("evobgp-web-all") tags = image-tags("evobgp-web-all")
} }
target "evobgp-bird2" { target "evobgp-bird2" {
inherits = ["_common"]
context = "../.." context = "../.."
dockerfile = "deploy/docker/bird2/Dockerfile" dockerfile = "deploy/docker/bird2/Dockerfile"
platforms = ["linux/amd64"] target = "bird2"
tags = image-tags("evobgp-bird2") args = {
BASE_UBUNTU = BASE_UBUNTU
}
contexts = {
bird2-base = "target:evobgp-bird2-base"
}
tags = image-tags("evobgp-bird2")
} }
+2 -5
View File
@@ -1,9 +1,6 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# Агент: бинарь из общего build-all (docker-bake.hcl → contexts.build-all), bird2 из apt. # Агент: бинарь из bake context build-all, runtime = тот же bird2-base, что evobgp-bird2.
FROM public.ecr.aws/docker/library/ubuntu:noble FROM bird2-base
RUN apt-get update \
&& apt-get install -y --no-install-recommends bird2 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ARG BIN=evobgp-agent ARG BIN=evobgp-agent
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp-agent COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp-agent
WORKDIR /etc/bird WORKDIR /etc/bird
+8 -5
View File
@@ -1,9 +1,10 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# React + Vite статическая панель EvoBGP + nginx. # React + Vite статическая панель EvoBGP + nginx.
# Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build). # Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build).
# Сборка ведётся из корня репозитория (context = "../.." в docker-bake.hcl). ARG BASE_NODE=docker.io/library/node:22-alpine
ARG BASE_NGINX=docker.io/library/nginx:1.27-alpine
FROM public.ecr.aws/docker/library/node:22-alpine AS deps FROM ${BASE_NODE} AS deps
WORKDIR /repo WORKDIR /repo
RUN corepack enable && corepack prepare [email protected] --activate RUN corepack enable && corepack prepare [email protected] --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
@@ -16,11 +17,13 @@ FROM deps AS build
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
COPY apps/web/ ./apps/web/ COPY apps/web/ ./apps/web/
COPY packages/ui/ ./packages/ui/ COPY packages/ui/ ./packages/ui/
RUN pnpm --filter @evobgp/web run build RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm --filter @evobgp/web run build
FROM public.ecr.aws/docker/library/nginx:1.27-alpine AS web FROM ${BASE_NGINX} AS web
ARG EVOBGP_UPSTREAM=evobgp-api ARG EVOBGP_UPSTREAM=evobgp-api
COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf
RUN sed -e "s/evobgp-api/${EVOBGP_UPSTREAM}/g" /tmp/nginx-default.conf > /etc/nginx/conf.d/default.conf \ RUN sed -e "s/evobgp-api/${EVOBGP_UPSTREAM}/g" /tmp/nginx-default.conf > /etc/nginx/conf.d/default.conf \
&& rm -f /tmp/nginx-default.conf && rm -f /tmp/nginx-default.conf \
&& sed -i 's/^[[:space:]]*worker_processes[[:space:]]*auto;/worker_processes 1;/' /etc/nginx/nginx.conf
COPY --from=web-artifacts /repo/apps/web/dist /usr/share/nginx/html COPY --from=web-artifacts /repo/apps/web/dist /usr/share/nginx/html
+1
View File
@@ -4,6 +4,7 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
gzip on; gzip on;
gzip_types text/css application/javascript application/json image/svg+xml; gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 256;
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте # Docker embedded DNS: без resolver nginx кэширует IP upstream при старте
# после recreate evobgp-all остаётся 502 (connection refused на старый IP). # после recreate evobgp-all остаётся 502 (connection refused на старый IP).
+31 -21
View File
@@ -1,19 +1,23 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# Универсальная сборка бинарей cmd/* (ARG BIN) или всех сразу (target build-all). # Универсальная сборка бинарей cmd/* (ARG BIN) или всех сразу (target build-all).
# INSTALL_BIRDC=1 — birdc из stage birdc (собирается один раз, переиспользуется api/all). # Воркеры (runtime): distroless static. api/all/deploy/node (runtime-birdc): debian-slim + bird + birdc.
# CI: docker buildx bake -f deploy/docker/docker-bake.hcl # CI: docker buildx bake -f deploy/docker/docker-bake.hcl
FROM public.ecr.aws/docker/library/golang:1.24-bookworm AS deps ARG BASE_GOLANG=docker.io/library/golang:1.24-alpine
ARG BASE_DEBIAN=docker.io/library/debian:bookworm-slim
ARG BASE_DISTROLESS=gcr.io/distroless/static-debian12:nonroot
FROM ${BASE_GOLANG} AS deps
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
go mod download go mod download
FROM deps AS build-all FROM deps AS build-all
COPY . .
ARG VERSION=dev ARG VERSION=dev
ARG GIT_SHA=unknown ARG GIT_SHA=unknown
ARG BUILD_TIME= ARG BUILD_TIME=
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ RUN --mount=type=bind,target=. \
--mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \ --mount=type=cache,target=/root/.cache/go-build,sharing=locked \
set -eux; \ set -eux; \
mkdir -p /out; \ mkdir -p /out; \
@@ -29,12 +33,12 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
# Один бинарь (локальная сборка); в CI — build-all + runtime. # Один бинарь (локальная сборка); в CI — build-all + runtime.
FROM deps AS build FROM deps AS build
COPY . .
ARG BIN=evobgp-api ARG BIN=evobgp-api
ARG VERSION=dev ARG VERSION=dev
ARG GIT_SHA=unknown ARG GIT_SHA=unknown
ARG BUILD_TIME= ARG BUILD_TIME=
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ RUN --mount=type=bind,target=. \
--mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \ --mount=type=cache,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=0 go build -trimpath \ CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w \ -ldflags="-s -w \
@@ -43,29 +47,35 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \ -X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
-o /out/evobgp "./cmd/${BIN}" -o /out/evobgp "./cmd/${BIN}"
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS birdc FROM ${BASE_DEBIAN} AS birdc
ARG BIRD_VERSION=2.14 ARG BIRD_VERSION=2.14
COPY deploy/docker/bird/bird-from-source.sh /tmp/bird-from-source.sh COPY deploy/docker/bird/bird-from-source.sh /tmp/bird-from-source.sh
RUN chmod +x /tmp/bird-from-source.sh \ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean \
&& chmod +x /tmp/bird-from-source.sh \
&& BIRD_VERSION="${BIRD_VERSION}" /tmp/bird-from-source.sh \ && BIRD_VERSION="${BIRD_VERSION}" /tmp/bird-from-source.sh \
&& rm -f /tmp/bird-from-source.sh && rm -f /tmp/bird-from-source.sh
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS runtime-base # scheduler / ingest / render — static Go, без shell.
RUN apt-get update \ FROM ${BASE_DISTROLESS} AS runtime
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
FROM runtime-base AS runtime
ARG BIN=evobgp-api ARG BIN=evobgp-api
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
COPY scripts/firewall /opt/evobgp/scripts/firewall
ENV EVOBGP_FIREWALL_SCRIPTS=/opt/evobgp/scripts/firewall
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/evobgp"] ENTRYPOINT ["/usr/local/bin/evobgp"]
FROM runtime AS runtime-birdc # api / all / deploy / node — bird -p (parse-check) + birdc configure.
# birdc: динамическая линковка readline + ncurses (debian bookworm). # Демон BIRD в этом контейнере не запускается; процесс bird — в образе evobgp-bird2.
RUN apt-get update \ FROM ${BASE_DEBIAN} AS runtime-birdc
&& apt-get install -y --no-install-recommends libreadline8 libncurses6 \ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates libreadline8 libncurses6 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=birdc /usr/local/sbin/bird /usr/local/sbin/birdc /usr/local/sbin/ ARG BIN=evobgp-api
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
COPY --from=birdc /usr/local/sbin/bird /usr/local/sbin/bird
COPY --from=birdc /usr/local/sbin/birdc /usr/local/sbin/birdc
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/evobgp"]
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env sh
# Copy-by-digest зеркало базовых образов в Gitea Container Registry (без rebuild).
# Требует: docker login в REGISTRY host, docker buildx.
# REGISTRY=git.shx.one/<owner> (без суффикса /evobgp-buildcache)
#
# Источник library-образов — Docker Hub (docker.io), не public.ecr.aws (429 Too Many Requests).
# Если тег уже есть в Gitea — skip (без повторного pull).
# Копируем только linux/amd64 — bake platforms совпадает, multi-arch индекс не нужен.
# Шаг CD не должен падать: неуспешный copy оставляет bake на Docker Hub FROM для этой базы.
set -eu
REGISTRY="${REGISTRY:?REGISTRY required (git.shx.one/<owner>)}"
CACHE_REPO="${REGISTRY}/evobgp-buildcache"
ENV_FILE="${MIRROR_ENV_FILE:-}"
PLATFORM="${MIRROR_PLATFORM:-linux/amd64}"
RETRIES="${MIRROR_RETRIES:-4}"
tmp="$(mktemp)"
cleanup() { rm -f "$tmp"; }
trap cleanup EXIT
dest_exists() {
docker buildx imagetools inspect "$1" >/dev/null 2>&1
}
copy_retry() {
src="$1"
dest="$2"
n=0
while [ "$n" -lt "$RETRIES" ]; do
n=$((n + 1))
if docker buildx imagetools create --platform "$PLATFORM" --tag "$dest" "$src"; then
return 0
fi
delay=$((n * 25))
echo "mirror retry ${n}/${RETRIES}, sleep ${delay}s: ${dest}"
sleep "$delay"
done
return 1
}
mirror() {
src="$1"
tag="$2"
var="$3"
dest="${CACHE_REPO}:${tag}"
if dest_exists "$dest"; then
echo "skip (already in registry): ${dest}"
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
return 0
fi
echo "mirror ${src} -> ${dest} (${PLATFORM})"
if copy_retry "$src" "$dest"; then
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
return 0
fi
echo "warn: ${dest} not mirrored — bake uses Docker Hub FROM for ${var}"
return 0
}
mirror "docker.io/library/golang:1.24-alpine" "base-golang-1.24-alpine" BASE_GOLANG
sleep 8
mirror "docker.io/library/debian:bookworm-slim" "base-debian-bookworm-slim" BASE_DEBIAN
sleep 8
mirror "docker.io/library/node:22-alpine" "base-node-22-alpine" BASE_NODE
sleep 8
mirror "docker.io/library/nginx:1.27-alpine" "base-nginx-1.27-alpine" BASE_NGINX
sleep 8
mirror "docker.io/library/ubuntu:noble" "base-ubuntu-noble" BASE_UBUNTU
sleep 8
mirror "gcr.io/distroless/static-debian12:nonroot" "base-distroless-static-debian12-nonroot" BASE_DISTROLESS
echo "----- mirrored BASE_* -----"
cat "$tmp"
if [ -n "$ENV_FILE" ]; then
env_dir="$(dirname "$ENV_FILE")"
if [ -d "$env_dir" ]; then
cp "$tmp" "$ENV_FILE"
echo "wrote ${ENV_FILE}"
else
echo "warn: MIRROR_ENV_FILE dir missing (${env_dir}) — skip write"
fi
fi
+25 -25
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Writes docker-bake.override.hcl for CI (buildx without --var support). # Optional helper for local bake when env-based Bake variables are inconvenient.
# CI задаёт те же имена переменных в окружении и вызывает bake без этого файла.
set -eu set -eu
OUT="${1:-docker-bake.override.hcl}" OUT="${1:-docker-bake.override.hcl}"
REGISTRY="${REGISTRY:?REGISTRY required}" REGISTRY="${REGISTRY:?REGISTRY required}"
@@ -10,30 +11,29 @@ VERSION="${VERSION:-dev}"
BUILD_TIME="${BUILD_TIME:-}" BUILD_TIME="${BUILD_TIME:-}"
CACHE_REF_GO="${CACHE_REF_GO:-}" CACHE_REF_GO="${CACHE_REF_GO:-}"
CACHE_REF_WEB="${CACHE_REF_WEB:-}" CACHE_REF_WEB="${CACHE_REF_WEB:-}"
CACHE_REF_BIRDC="${CACHE_REF_BIRDC:-}"
BASE_GOLANG="${BASE_GOLANG:-}"
BASE_DEBIAN="${BASE_DEBIAN:-}"
BASE_DISTROLESS="${BASE_DISTROLESS:-}"
BASE_NODE="${BASE_NODE:-}"
BASE_NGINX="${BASE_NGINX:-}"
BASE_UBUNTU="${BASE_UBUNTU:-}"
cat >"$OUT" <<EOF cat >"$OUT" <<EOF
# Generated by deploy/docker/write-bake-override.sh — do not commit. # Generated by deploy/docker/write-bake-override.sh — do not commit.
variable "REGISTRY" { variable "REGISTRY" { default = "${REGISTRY}" }
default = "${REGISTRY}" variable "IMAGE_TAG" { default = "${IMAGE_TAG}" }
} variable "SHORT_SHA" { default = "${SHORT_SHA}" }
variable "IMAGE_TAG" { variable "SHA_FULL" { default = "${SHA_FULL}" }
default = "${IMAGE_TAG}" variable "VERSION" { default = "${VERSION}" }
} variable "BUILD_TIME" { default = "${BUILD_TIME}" }
variable "SHORT_SHA" { variable "CACHE_REF_GO" { default = "${CACHE_REF_GO}" }
default = "${SHORT_SHA}" variable "CACHE_REF_WEB" { default = "${CACHE_REF_WEB}" }
} variable "CACHE_REF_BIRDC" { default = "${CACHE_REF_BIRDC}" }
variable "SHA_FULL" {
default = "${SHA_FULL}"
}
variable "VERSION" {
default = "${VERSION}"
}
variable "BUILD_TIME" {
default = "${BUILD_TIME}"
}
variable "CACHE_REF_GO" {
default = "${CACHE_REF_GO}"
}
variable "CACHE_REF_WEB" {
default = "${CACHE_REF_WEB}"
}
EOF EOF
# BASE_* — только если заданы (иначе bake берёт default из docker-bake.hcl).
if [ -n "$BASE_GOLANG" ]; then printf 'variable "BASE_GOLANG" { default = "%s" }\n' "$BASE_GOLANG" >>"$OUT"; fi
if [ -n "$BASE_DEBIAN" ]; then printf 'variable "BASE_DEBIAN" { default = "%s" }\n' "$BASE_DEBIAN" >>"$OUT"; fi
if [ -n "$BASE_DISTROLESS" ]; then printf 'variable "BASE_DISTROLESS" { default = "%s" }\n' "$BASE_DISTROLESS" >>"$OUT"; fi
if [ -n "$BASE_NODE" ]; then printf 'variable "BASE_NODE" { default = "%s" }\n' "$BASE_NODE" >>"$OUT"; fi
if [ -n "$BASE_NGINX" ]; then printf 'variable "BASE_NGINX" { default = "%s" }\n' "$BASE_NGINX" >>"$OUT"; fi
if [ -n "$BASE_UBUNTU" ]; then printf 'variable "BASE_UBUNTU" { default = "%s" }\n' "$BASE_UBUNTU" >>"$OUT"; fi
+4 -4
View File
@@ -118,10 +118,10 @@ EvoBGP управляет генерацией и применением BGP-к
| Маршрут | Назначение | | Маршрут | Назначение |
|---------|------------| |---------|------------|
| `/settings` | Только браузер: API-токен, тема (localStorage). Tenant KV здесь **не** редактируются. | | `/settings?tab=ui` | Настройки UI: API-токен, сессия, тема. |
| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии**, **Файловые логи** (автоочистка FS), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. | | `/settings?tab=bird` | Настройки BIRD: control plane, ревизии, файловые логи, дополнительные ключи `/v1/settings`. Старый `/tenant-settings` редиректит сюда. |
| `/network` → Control plane | Краткая сводка BIRD + ссылка на `/tenant-settings?tab=bird`. | | `/network` → Control plane | Краткая сводка BIRD. |
| `/operations` | Ревизии, diff, jobs; вкладка «Система» перенесена в `/tenant-settings`. | | `/operations` | Ревизии, diff, jobs. |
### Web UI: файловые runtime-логи ### Web UI: файловые runtime-логи
+2 -2
View File
@@ -11,7 +11,7 @@
## Готовые образы без сборки (Container Registry Gitea) ## Готовые образы без сборки (Container Registry Gitea)
После успешного CI (push в `main` или `master`) образы публикуются в **Container Registry** вашего Gitea. В workflow зафиксирован хост реестра **`git.shx.one`**; имя владельца в пути образа — **в нижнем регистре**, как у `github.repository_owner` в CI (например, пользователь `Denozord` → префикс `denozord`). После успешного **CD** (push в `main` или `master`, job **publish**) образы публикуются в **Container Registry** вашего Gitea. В workflow зафиксирован хост реестра **`git.shx.one`**; имя владельца в пути образа — **в нижнем регистре**, как у `github.repository_owner` в CI (например, пользователь `Denozord` → префикс `denozord`).
### Шаблон имени и теги ### Шаблон имени и теги
@@ -39,7 +39,7 @@ docker login git.shx.one
| Образ | Назначение | Страница пакета (пример) | Pull | | Образ | Назначение | Страница пакета (пример) | Pull |
|--------|------------|--------------------------|------| |--------|------------|--------------------------|------|
| `evobgp-api` | HTTP API (с `birdc` в образе) | [packages/…/evobgp-api](https://git.shx.one/denozord/-/packages/container/evobgp-api/latest) | `docker pull git.shx.one/denozord/evobgp-api:latest` | | `evobgp-api` | HTTP API (с `bird`/`birdc` в образе для parse-check) | [packages/…/evobgp-api](https://git.shx.one/denozord/-/packages/container/evobgp-api/latest) | `docker pull git.shx.one/denozord/evobgp-api:latest` |
| `evobgp-all` | Монолит microVPS: API + in-process воркеры scheduler/ingest/render/deploy | [packages/…/evobgp-all](https://git.shx.one/denozord/-/packages/container/evobgp-all/latest) | `docker pull git.shx.one/denozord/evobgp-all:latest` | | `evobgp-all` | Монолит microVPS: API + in-process воркеры scheduler/ingest/render/deploy | [packages/…/evobgp-all](https://git.shx.one/denozord/-/packages/container/evobgp-all/latest) | `docker pull git.shx.one/denozord/evobgp-all:latest` |
| `evobgp-scheduler` | Планировщик (reference) | [packages/…/evobgp-scheduler](https://git.shx.one/denozord/-/packages/container/evobgp-scheduler/latest) | `docker pull git.shx.one/denozord/evobgp-scheduler:latest` | | `evobgp-scheduler` | Планировщик (reference) | [packages/…/evobgp-scheduler](https://git.shx.one/denozord/-/packages/container/evobgp-scheduler/latest) | `docker pull git.shx.one/denozord/evobgp-scheduler:latest` |
| `evobgp-ingest` | Ingest CDN / ETag | [packages/…/evobgp-ingest](https://git.shx.one/denozord/-/packages/container/evobgp-ingest/latest) | `docker pull git.shx.one/denozord/evobgp-ingest:latest` | | `evobgp-ingest` | Ingest CDN / ETag | [packages/…/evobgp-ingest](https://git.shx.one/denozord/-/packages/container/evobgp-ingest/latest) | `docker pull git.shx.one/denozord/evobgp-ingest:latest` |
+13 -12
View File
@@ -23,21 +23,22 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc). Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
## CI-пайплайн (один push в main) ## CI-пайплайн (push в main)
```text ```text
push/merge в main push/merge в main
CI: openapi, web, go, bird2 (параллельно) workflow CD: quality (openapi, web, go, bird2)
→ job release (в том же workflow, после quality gates): → job publish:
→ semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit) → semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
→ Gitea Release + CHANGELOG.md как attachment → Gitea Release + CHANGELOG.md как attachment
docker buildx bake с VERSION=X.Y.Z зеркало base-образов в evobgp-buildcache:base-*
→ docker buildx bake с VERSION=X.Y.Z (pull=false, named builder evobgp)
→ образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA → образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA
``` ```
Pull request: только quality gates + commitlint; релиз и образы **не** публикуются. Pull request: workflow **CI** quality gates + commitlint; релиз и образы **не** публикуются.
Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release**). Workflows: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml), [.gitea/workflows/cd.yaml](../.gitea/workflows/cd.yaml), reusable [.gitea/workflows/quality.yaml](../.gitea/workflows/quality.yaml).
Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий). Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий).
@@ -51,7 +52,7 @@ Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release
| releases | Gitea Release + notes | | releases | Gitea Release + notes |
| packages (Container Registry) | push образов | | packages (Container Registry) | push образов |
Fallback: **`gitea.token`** (нужны права на releases и packages). Fallback для **git tag**: `github.token`, если PAT недоступен. Push образов в Container Registry — **только `ACTIONS_PAT`** (у job token Gitea нет права packages).
## Источник правды для версии в runtime ## Источник правды для версии в runtime
@@ -75,15 +76,15 @@ Web UI показывает версию из API (footer sidebar, страни
Правило: **один scope** из таблицы в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) (`web`, `httpapi`, `api`, …). Правило: **один scope** из таблицы в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) (`web`, `httpapi`, `api`, …).
На push в `main` job **release** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах. На push в `main` job **publish** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `refactor(web): …`). Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `refactor(web): …`).
## Перезапуск упавшего job release ## Перезапуск упавшего job publish
semantic-release пишет `.release-version` только в `successCmd` при **новом** релизе. Если тег `vX.Y.Z` уже создан, а `docker buildx bake` упал, повторный run того же SHA делает semantic-release no-op (файла нет). Job **release** тогда берёт версию из git-тега на `HEAD` и публикует образы. semantic-release пишет `.release-version` только в `successCmd` при **новом** релизе. Если тег `vX.Y.Z` уже создан, а `docker buildx bake` упал, повторный run того же SHA делает semantic-release no-op (файла нет). Job **publish** тогда берёт версию из git-тега на `HEAD` и публикует образы.
Перезапускать нужно **весь job release**, не отдельный шаг bake: checkout + semantic-release + detect + bake идут подряд. Перезапускать нужно **весь job publish**, не отдельный шаг bake: checkout + semantic-release + detect + bake идут подряд.
## CHANGELOG ## CHANGELOG
@@ -91,7 +92,7 @@ Release notes — в Gitea Release; файл `CHANGELOG.md` генерирует
## Проверка после релиза ## Проверка после релиза
1. Один run workflow **CI** на push в main: job **release** зелёный. 1. Один run workflow **CD** на push в main: job **publish** зелёный.
2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes. 2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes.
3. Container Registry: `evobgp-api:vX.Y.Z`, `evobgp-api:X.Y.Z`, `evobgp-api:latest`. 3. Container Registry: `evobgp-api:vX.Y.Z`, `evobgp-api:X.Y.Z`, `evobgp-api:latest`.
4. `curl http://localhost:8080/version``"version":"X.Y.Z"`. 4. `curl http://localhost:8080/version``"version":"X.Y.Z"`.

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