Compare commits

..
3 Commits
Author SHA1 Message Date
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
65 changed files with 2416 additions and 719 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
+19 -16
View File
@@ -1,38 +1,39 @@
# 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` с ключом `sha256sum` lockfile (`go.sum` / `pnpm-lock.yaml`). `hashFiles` в Gitea не используем.
## Release (job в ci.yaml) Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR) и **publish** (CD).
После успешных quality gates на **push в main** job **release**: ## CD (job publish)
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main). После успешных 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`).
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 +47,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`.
Пример: Пример:
+141
View File
@@ -0,0 +1,141 @@
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: Enable pnpm via corepack
run: corepack enable
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: Install release tooling
run: pnpm install --frozen-lockfile
- 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'
id: mirror
continue-on-error: true
env:
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
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"
run: |
set -euxo pipefail
cache="git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache"
if [ "${{ steps.mirror.outcome }}" = "success" ]; then
export BASE_GOLANG="${cache}:base-golang-1.24-alpine"
export BASE_DEBIAN="${cache}:base-debian-bookworm-slim"
export BASE_NODE="${cache}:base-node-22-alpine"
export BASE_NGINX="${cache}:base-nginx-1.27-alpine"
export BASE_UBUNTU="${cache}:base-ubuntu-noble"
export BASE_DISTROLESS="${cache}:base-distroless-static-debian12-nonroot"
fi
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
-f deploy/docker/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
+384
View File
@@ -0,0 +1,384 @@
# 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: Enable pnpm via corepack
run: corepack enable
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, Redocly, codegen check
run: |
set -euxo pipefail
pnpm install --frozen-lockfile
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: Enable pnpm via corepack
run: corepack enable
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: "1.24"
cache: false
- id: go-hash
run: echo "key=$(sha256sum go.sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ runner.os }}-${{ steps.go-hash.outputs.key }}
restore-keys: |
go-${{ runner.os }}-
- 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
- name: golangci-lint
uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6.5.2
with:
version: v1.64.8
skip-cache: true
- 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@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: Enable pnpm via corepack
run: corepack enable
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.local/share/pnpm/store
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 }}
run: |
set -euxo pipefail
pnpm install --frozen-lockfile
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"
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"
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',
@@ -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',
@@ -22,8 +22,8 @@ const ACTIONS: QuickActionItem[] = [
}, },
{ {
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',
@@ -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 />,
@@ -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 {
@@ -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"
@@ -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)}
@@ -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: "Некорректное значение",
}, },
} }
@@ -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">
@@ -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
} }
+31 -4
View File
@@ -61,18 +61,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'),
}) })
} }
+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()}
+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>
+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, deploy, node | `gcr.io/distroless/static-debian12:nonroot` | static Go (`CGO_ENABLED=0`), без shell |
| api, all | `debian:bookworm-slim` + `birdc` | только клиент birdc, без демона `bird` |
| 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-образов (`mirror-base-images.sh`), без rebuild
`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 = "../.."` (корень репо), `dockerfile = "deploy/docker/…"` (путь от корня репо).
Один образ (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=public.ecr.aws/docker/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
+106 -24
View File
@@ -1,7 +1,6 @@
# Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl). # Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl).
# context = "../.." корень репозитория (относительно этого файла). # context = "../.." корень репозитория (относительно этого файла).
# dockerfile путь от корня репозитория (относительно context). # Переменные Bake читаются из окружения (см. docs.docker.com/build/bake/variables/).
# Переменные: REGISTRY, IMAGE_TAG, CACHE_REF_GO, CACHE_REF_WEB
variable "REGISTRY" { variable "REGISTRY" {
default = "git.shx.one/evobgp" default = "git.shx.one/evobgp"
@@ -35,14 +34,40 @@ variable "CACHE_REF_WEB" {
default = "" default = ""
} }
variable "CACHE_REF_BIRDC" {
default = ""
}
variable "BASE_GOLANG" {
default = "public.ecr.aws/docker/library/golang:1.24-alpine"
}
variable "BASE_DEBIAN" {
default = "public.ecr.aws/docker/library/debian:bookworm-slim"
}
variable "BASE_DISTROLESS" {
default = "gcr.io/distroless/static-debian12:nonroot"
}
variable "BASE_NODE" {
default = "public.ecr.aws/docker/library/node:22-alpine"
}
variable "BASE_NGINX" {
default = "public.ecr.aws/docker/library/nginx:1.27-alpine"
}
variable "BASE_UBUNTU" {
default = "public.ecr.aws/docker/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 +83,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 +110,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 +155,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 +175,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" {
@@ -198,32 +246,51 @@ target "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 +299,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=public.ecr.aws/docker/library/node:22-alpine
ARG BASE_NGINX=public.ecr.aws/docker/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).
+29 -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 (runtime-birdc): debian-slim + 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=public.ecr.aws/docker/library/golang:1.24-alpine
ARG BASE_DEBIAN=public.ecr.aws/docker/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,33 @@ 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 / deploy / node — 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 — birdc (readline + ncurses). Только клиент birdc, без демона bird.
# birdc: динамическая линковка readline + ncurses (debian bookworm). FROM ${BASE_DEBIAN} AS runtime-birdc
RUN apt-get update \ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
&& apt-get install -y --no-install-recommends libreadline8 libncurses6 \ --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/birdc /usr/local/sbin/birdc
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/evobgp"]
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env sh
# Copy-by-digest зеркало базовых образов в Gitea Container Registry (без rebuild).
# Требует: docker login в REGISTRY host, docker buildx.
# REGISTRY=git.shx.one/<owner> (без суффикса /evobgp-buildcache)
set -eu
REGISTRY="${REGISTRY:?REGISTRY required (git.shx.one/<owner>)}"
CACHE_REPO="${REGISTRY}/evobgp-buildcache"
mirror() {
src="$1"
tag="$2"
echo "mirror ${src} -> ${CACHE_REPO}:${tag}"
docker buildx imagetools create --tag "${CACHE_REPO}:${tag}" "${src}"
}
mirror "public.ecr.aws/docker/library/golang:1.24-alpine" "base-golang-1.24-alpine"
mirror "public.ecr.aws/docker/library/debian:bookworm-slim" "base-debian-bookworm-slim"
mirror "public.ecr.aws/docker/library/node:22-alpine" "base-node-22-alpine"
mirror "public.ecr.aws/docker/library/nginx:1.27-alpine" "base-nginx-1.27-alpine"
mirror "public.ecr.aws/docker/library/ubuntu:noble" "base-ubuntu-noble"
mirror "gcr.io/distroless/static-debian12:nonroot" "base-distroless-static-debian12-nonroot"
echo "BASE_GOLANG=${CACHE_REPO}:base-golang-1.24-alpine"
echo "BASE_DEBIAN=${CACHE_REPO}:base-debian-bookworm-slim"
echo "BASE_NODE=${CACHE_REPO}:base-node-22-alpine"
echo "BASE_NGINX=${CACHE_REPO}:base-nginx-1.27-alpine"
echo "BASE_UBUNTU=${CACHE_REPO}:base-ubuntu-noble"
echo "BASE_DISTROLESS=${CACHE_REPO}:base-distroless-static-debian12-nonroot"
+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
+1 -1
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`).
### Шаблон имени и теги ### Шаблон имени и теги
+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"`.
+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
+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;
} }