Compare commits

...
12 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
83 changed files with 3074 additions and 790 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`)
Лицензия и условия использования — по политике владельца репозитория. Лицензия и условия использования — по политике владельца репозитория.
@@ -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}`}
@@ -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>
@@ -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',
@@ -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)
@@ -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"
+5 -5
View File
@@ -71,7 +71,7 @@ const NAV_GROUPS: NavGroup[] = [
to: '/dashboard', to: '/dashboard',
label: 'Панель', label: 'Панель',
icon: LayoutDashboard, icon: LayoutDashboard,
description: 'KPI, модули и активность', description: 'Метрики, модули и активность',
}, },
], ],
}, },
@@ -81,15 +81,15 @@ 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' } },
], ],
}, },
{ {
@@ -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)}
@@ -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
} }
/> />
) )
@@ -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: 'Расписание' },
@@ -90,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
@@ -31,8 +31,8 @@ const RUNTIME_LOGS_ENABLED_ITEMS = [
] as const ] as const
const RUNTIME_LOGS_MODE_ITEMS = [ const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'обнулить (truncate)' }, { value: 'truncate', label: 'Обнулить файл' },
{ value: 'delete', label: 'удалить файл (delete)' }, { value: 'delete', label: 'Удалить файл' },
] as const ] as const
const BIRD_LABELS: Record<BirdSettingKey, string> = { const BIRD_LABELS: Record<BirdSettingKey, string> = {
@@ -40,12 +40,12 @@ const BIRD_LABELS: Record<BirdSettingKey, string> = {
bird_local_ipv4: 'Локальный IPv4', bird_local_ipv4: 'Локальный IPv4',
bird_local_ipv6: 'Локальный IPv6', bird_local_ipv6: 'Локальный IPv6',
bird_local_asn: 'Локальный ASN', bird_local_asn: 'Локальный ASN',
bird_bgp_source_ipv4: 'BGP source IPv4', bird_bgp_source_ipv4: 'Исходный IPv4 BGP',
bird_bgp_source_ipv6: 'BGP source IPv6', bird_bgp_source_ipv6: 'Исходный IPv6 BGP',
peer_discovery_enabled: 'Автообнаружение пиров', peer_discovery_enabled: 'Автообнаружение пиров',
peer_discovery_ranges_v4: 'Discovery CIDR IPv4', peer_discovery_ranges_v4: 'CIDR автообнаружения IPv4',
peer_discovery_ranges_v6: 'Discovery CIDR IPv6', peer_discovery_ranges_v6: 'CIDR автообнаружения IPv6',
peer_discovery_require_external: 'Только external ASN', peer_discovery_require_external: 'Только внешние ASN',
} }
const BIRD_BOOL_ITEMS = [ const BIRD_BOOL_ITEMS = [
@@ -55,10 +55,10 @@ const BIRD_BOOL_ITEMS = [
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = { const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
peer_discovery_enabled: peer_discovery_enabled:
'Dynamic neighbor range в BIRD (карантин import/export none). Требует CIDR.', 'Динамический диапазон соседей в BIRD (карантин import/export none). Нужны CIDR.',
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24', peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32', peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
peer_discovery_require_external: 'neighbor range … external (любой чужой ASN)', peer_discovery_require_external: 'Диапазон соседей с внешним (любым чужим) ASN',
} }
export function BirdSettingsTab() { export function BirdSettingsTab() {
@@ -103,8 +103,8 @@ export function BirdSettingsTab() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<SettingsCard <SettingsCard
title="BIRD control plane" title="Плоскость управления BIRD"
description="Глобальные параметры BIRD для pipeline refresh/apply. Сохранение — роль operator." description="Глобальные параметры BIRD для обновления модулей и применения конфигурации. Сохранение доступно оператору."
footer={ footer={
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}> <LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
<Save /> <Save />
@@ -184,11 +184,11 @@ export function BirdSettingsTab() {
> >
{() => ( {() => (
<SettingsFieldGroup <SettingsFieldGroup
legend="Retention" legend="Хранение"
description="Срок хранения ревизий в минутах." description="Срок хранения ревизий в минутах."
> >
<SettingsSettingField <SettingsSettingField
title="Retention" title="Срок хранения"
description="Сколько минут хранить ревизии конфигурации." description="Сколько минут хранить ревизии конфигурации."
labelFor="revision_retention_minutes" labelFor="revision_retention_minutes"
stacked stacked
@@ -63,7 +63,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
<SettingsCard <SettingsCard
title="Подключение к API" title="Подключение к API"
description="Токен хранится только в этом браузере (localStorage)" description="Токен хранится только в этом браузере"
footer={ footer={
<div className="flex w-full min-w-0 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}>
@@ -84,7 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
title="Токен для запросов" title="Токен для запросов"
description={ description={
<> <>
Ключ для заголовка Authorization. Управление ключами tenant в разделе{' '} Ключ для заголовка Authorization. Управление ключами арендатора в разделе{' '}
<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,7 +93,7 @@ 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"
@@ -28,25 +28,25 @@ const ADMIN_SECTIONS = [
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, 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, 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
@@ -54,8 +54,8 @@ export function SectionsSettingsTab() {
return ( return (
<div className="flex flex-col gap-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) => (
@@ -36,7 +36,7 @@ export function SessionSettingsTab() {
return ( return (
<div className="flex flex-col gap-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}
@@ -65,7 +65,7 @@ export function SessionSettingsTab() {
</Badge> </Badge>
</ItemTitle> </ItemTitle>
<ItemDescription className="leading-5"> <ItemDescription className="leading-5">
Tenant:{' '} Арендатор:{' '}
<code className="text-foreground font-mono text-xs break-all"> <code className="text-foreground font-mono text-xs break-all">
{session.tenant_id} {session.tenant_id}
</code> </code>
@@ -84,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">
@@ -101,7 +101,7 @@ export function SessionSettingsTab() {
> >
{sessionQ.data ? ( {sessionQ.data ? (
<p className="text-muted-foreground text-sm break-words"> <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 break-all">{sessionQ.data.tenant_id}</code>. <code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
</p> </p>
) : ( ) : (
+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 ноды` },
] ]
@@ -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({
+9 -9
View File
@@ -52,7 +52,7 @@ function AccessComponent() {
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>
), ),
}, },
@@ -89,16 +89,16 @@ 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 || '—'
})() })()
@@ -107,7 +107,7 @@ function AccessComponent() {
<div className="flex min-w-0 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}>
@@ -121,7 +121,7 @@ function AccessComponent() {
{session ? ( {session ? (
<SettingsCard <SettingsCard
title="Текущая сессия" title="Текущая сессия"
description="Tenant и права текущего Bearer (API-ключ или portal JWT)." description="Арендатор и права текущей сессии (API-ключ или JWT портала)."
> >
<ItemGroup className="gap-0"> <ItemGroup className="gap-0">
<Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5"> <Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
@@ -141,7 +141,7 @@ function AccessComponent() {
</div> </div>
<ItemDescription className="flex min-w-0 flex-col gap-1 leading-5"> <ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
<span className="break-all"> <span className="break-all">
Tenant:{' '} Арендатор:{' '}
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code> <code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
</span> </span>
<span className="break-words">Доступ: {sessionAccessLabel}</span> <span className="break-words">Доступ: {sessionAccessLabel}</span>
@@ -188,8 +188,8 @@ function AccessComponent() {
) : session ? ( ) : session ? (
<SettingsCard title="API-ключи"> <SettingsCard title="API-ключи">
<p className="text-muted-foreground px-5 py-4 text-sm break-words"> <p className="text-muted-foreground px-5 py-4 text-sm break-words">
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT Управление API-ключами доступно роли <strong>оператор</strong> (API-ключ) или JWT портала
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>. с правом администратора / <code className="text-xs">bgp:access:admin</code>.
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>. Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
</p> </p>
</SettingsCard> </SettingsCard>
+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_* и управляются в Настройках BIRD." 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
+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"`.
+10 -1
View File
@@ -10,6 +10,7 @@ import (
func TestParallelModuleRefresh_CoalescesDeployApply(t *testing.T) { func TestParallelModuleRefresh_CoalescesDeployApply(t *testing.T) {
t.Setenv("EVOBGP_ASN_RESOLVE", "0") t.Setenv("EVOBGP_ASN_RESOLVE", "0")
t.Setenv("EVOBGP_BIRD_ACTIVE_DIR", "") // skip bird binary path in deploy_apply t.Setenv("EVOBGP_BIRD_ACTIVE_DIR", "") // skip bird binary path in deploy_apply
t.Setenv("EVOBGP_JOB_MAX_CONCURRENT", "8")
m := store.NewMemory() m := store.NewMemory()
m.SeedDemo() m.SeedDemo()
@@ -35,8 +36,15 @@ func TestParallelModuleRefresh_CoalescesDeployApply(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
// Hold workers until both jobs are enqueued so inflightRefresh=2 before either
// finishModuleRefreshSuccess. Otherwise a fast ingest can finalize+deploy before
// the second Enqueue — sequential refreshes correctly produce two deploy_apply jobs.
start := make(chan struct{})
wk := &Worker{Store: m} wk := &Worker{Store: m}
reg := NewRegistry(wk.Process) reg := NewRegistry(func(j *Job) {
<-start
wk.Process(j)
})
wk.Registry = reg wk.Registry = reg
mid1 := modIP mid1 := modIP
@@ -47,6 +55,7 @@ func TestParallelModuleRefresh_CoalescesDeployApply(t *testing.T) {
if _, _, err := reg.Enqueue(tenant, KindModuleRefresh, nil, &mid2, map[string]any{"module_id": mod2.ID}); err != nil { if _, _, err := reg.Enqueue(tenant, KindModuleRefresh, nil, &mid2, map[string]any{"module_id": mod2.ID}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
close(start)
waitSucceededJobsByKindCount(t, reg, tenant, KindModuleRefresh, 2) waitSucceededJobsByKindCount(t, reg, tenant, KindModuleRefresh, 2)
+25 -1
View File
@@ -414,6 +414,9 @@ func (m *Memory) ListModules(tenantID string) []*Module {
} }
return out[i].Name < out[j].Name return out[i].Name < out[j].Name
}) })
for i := range out {
out[i] = cloneModule(out[i])
}
return out return out
} }
@@ -446,7 +449,7 @@ func (m *Memory) GetModule(tenantID, moduleID string) (*Module, error) {
if mod.TenantID != tenantID { if mod.TenantID != tenantID {
return nil, ErrTenantScope return nil, ErrTenantScope
} }
return mod, nil return cloneModule(mod), nil
} }
func (m *Memory) GetRevision(tenantID, revisionID string) (*Revision, error) { func (m *Memory) GetRevision(tenantID, revisionID string) (*Revision, error) {
@@ -684,3 +687,24 @@ func (m *Memory) ListRevisions(tenantID, moduleID string, cursor string, limit i
} }
return page, nextCursor, hasMore return page, nextCursor, hasMore
} }
func cloneStringPtr(s *string) *string {
if s == nil {
return nil
}
v := *s
return &v
}
func cloneModule(m *Module) *Module {
if m == nil {
return nil
}
cp := *m
cp.DefaultCommunityID = cloneStringPtr(m.DefaultCommunityID)
cp.DohProfileID = cloneStringPtr(m.DohProfileID)
cp.DohProfileIDs = append([]string(nil), m.DohProfileIDs...)
cp.LastRefreshedAt = cloneTime(m.LastRefreshedAt)
cp.DeletedAt = cloneTime(m.DeletedAt)
return &cp
}
+5 -3
View File
@@ -38,7 +38,7 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
} }
NormalizeModuleDoh(mod) NormalizeModuleDoh(mod)
m.modules[id] = mod m.modules[id] = mod
return mod, nil return cloneModule(mod), nil
} }
func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*Module, error) { func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*Module, error) {
@@ -74,12 +74,14 @@ func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*M
mod.DefaultCommunityID = &v mod.DefaultCommunityID = &v
} }
} }
ApplyModuleDohPatch(mod, patch) if patch.DohProfileIDs != nil || patch.DohProfileID != nil || patch.DohResolverPolicy != nil {
ApplyModuleDohPatch(mod, patch)
}
if patch.LastRefreshedAt != nil { if patch.LastRefreshedAt != nil {
t := patch.LastRefreshedAt.UTC() t := patch.LastRefreshedAt.UTC()
mod.LastRefreshedAt = &t mod.LastRefreshedAt = &t
} }
return mod, nil return cloneModule(mod), nil
} }
func (m *Memory) SoftDeleteModule(tenantID, moduleID string) error { func (m *Memory) SoftDeleteModule(tenantID, moduleID string) error {
+2
View File
@@ -13,10 +13,12 @@
"@commitlint/cli": "^19.8.1", "@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1", "@commitlint/config-conventional": "^19.8.1",
"@markwylde/semantic-release-gitea": "^2.2.0", "@markwylde/semantic-release-gitea": "^2.2.0",
"@redocly/cli": "1.34.5",
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/exec": "^7.0.0", "@semantic-release/exec": "^7.0.0",
"@semantic-release/release-notes-generator": "^14.0.3", "@semantic-release/release-notes-generator": "^14.0.3",
"conventional-commits-parser": "^6.4.0",
"semantic-release": "^25.0.2" "semantic-release": "^25.0.2"
} }
} }
+1312
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env sh
# Абсолютные пути для actions/cache: на act_runner «~» часто не раскрывается → cache miss.
set -eu
: "${GITHUB_ENV:?GITHUB_ENV required (Gitea/GitHub Actions)}"
HOME_DIR="${HOME:-}"
if [ -z "$HOME_DIR" ]; then
HOME_DIR="$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6 || true)"
fi
HOME_DIR="${HOME_DIR:-/root}"
if command -v go >/dev/null 2>&1; then
GOMODCACHE="$(go env GOMODCACHE)"
GOCACHE="$(go env GOCACHE)"
GOPATH="$(go env GOPATH)"
else
GOMODCACHE="${HOME_DIR}/go/pkg/mod"
GOCACHE="${HOME_DIR}/.cache/go-build"
GOPATH="${HOME_DIR}/go"
fi
PNPM_STORE_DIR="${HOME_DIR}/.pnpm-store"
COREPACK_HOME="${HOME_DIR}/.cache/node/corepack"
GOBIN="${GOPATH}/bin"
GOLANGCI_LINT_CACHE="${HOME_DIR}/.cache/golangci-lint"
mkdir -p "$PNPM_STORE_DIR" "$COREPACK_HOME" "$GOMODCACHE" "$GOCACHE" "$GOBIN" "$GOLANGCI_LINT_CACHE"
{
echo "HOME_DIR=${HOME_DIR}"
echo "PNPM_STORE_DIR=${PNPM_STORE_DIR}"
echo "COREPACK_HOME=${COREPACK_HOME}"
echo "GOMODCACHE=${GOMODCACHE}"
echo "GOCACHE=${GOCACHE}"
echo "GOPATH=${GOPATH}"
echo "GOBIN=${GOBIN}"
echo "GOLANGCI_LINT_CACHE=${GOLANGCI_LINT_CACHE}"
} >> "$GITHUB_ENV"
echo "cache-env HOME_DIR=${HOME_DIR}"
echo "cache-env PNPM_STORE_DIR=${PNPM_STORE_DIR}"
echo "cache-env GOMODCACHE=${GOMODCACHE}"
echo "cache-env GOCACHE=${GOCACHE}"
echo "cache-env GOBIN=${GOBIN}"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env sh
# Бинарь golangci-lint в GOBIN (кэш actions/cache), без goinstall и без hashFiles.
set -eu
VER="${GOLANGCI_LINT_VERSION:-v1.64.8}"
: "${GOBIN:?GOBIN required — run scripts/ci/export-cache-env.sh after setup-go}"
mkdir -p "$GOBIN"
export PATH="${GOBIN}:${PATH}"
if [ -n "${GOLANGCI_LINT_CACHE:-}" ]; then
mkdir -p "$GOLANGCI_LINT_CACHE"
export GOLANGCI_LINT_CACHE
fi
if [ ! -x "${GOBIN}/golangci-lint" ]; then
echo "install golangci-lint ${VER} -> ${GOBIN}"
curl -sSfL "https://raw.githubusercontent.com/golangci/golangci-lint/${VER}/install.sh" \
| sh -s -- -b "$GOBIN" "$VER"
fi
golangci-lint version
golangci-lint run
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
# pnpm install из кэша Gitea (store + node_modules). Без повторного download при hit.
set -eu
: "${PNPM_STORE_DIR:?PNPM_STORE_DIR required — run scripts/ci/export-cache-env.sh first}"
export COREPACK_HOME="${COREPACK_HOME:-${HOME:-/root}/.cache/node/corepack}"
mkdir -p "$PNPM_STORE_DIR" "$COREPACK_HOME"
corepack enable
pnpm config set store-dir "$PNPM_STORE_DIR"
echo "pnpm store: $(pnpm store path)"
echo "PNPM_CACHE_HIT=${PNPM_CACHE_HIT:-}"
if [ "${PNPM_CACHE_HIT:-}" = "true" ]; then
if pnpm install --frozen-lockfile --offline; then
echo "pnpm install --offline (cache hit)"
exit 0
fi
echo "offline install failed — prefer-offline"
fi
pnpm install --frozen-lockfile --prefer-offline
+11 -3
View File
@@ -4,7 +4,9 @@
* Exit 0 always semantic-release still decides release/no-op. * Exit 0 always semantic-release still decides release/no-op.
*/ */
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import parser from 'conventional-commits-parser'; import { CommitParser } from 'conventional-commits-parser';
const parser = new CommitParser();
const RELEASABLE = new Set(['feat', 'fix', 'perf', 'ci', 'refactor']); const RELEASABLE = new Set(['feat', 'fix', 'perf', 'ci', 'refactor']);
@@ -32,8 +34,14 @@ const unparseable = [];
const releasable = []; const releasable = [];
for (const { hash, subject } of commits) { for (const { hash, subject } of commits) {
const parsed = parser.sync(subject); let parsed;
if (!parsed.type) { try {
parsed = parser.parse(subject);
} catch {
unparseable.push({ hash: hash.slice(0, 7), subject });
continue;
}
if (!parsed?.type) {
unparseable.push({ hash: hash.slice(0, 7), subject }); unparseable.push({ hash: hash.slice(0, 7), subject });
continue; continue;
} }