Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e55d2c5aba | ||
|
|
f63e9b5fd0 | ||
|
|
0148d4ca37 | ||
|
|
e0ecabb22f | ||
|
|
da301b1a94 | ||
|
|
653bc6cc91 | ||
|
|
e0d695f2a4 | ||
|
|
dfc33dcb99 | ||
|
|
44f70443c3 | ||
|
|
357ed4ce7b |
@@ -28,7 +28,7 @@ alwaysApply: true
|
||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||
|------------|-------------|------------------|-------|
|
||||
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `npx @redocly/cli lint` |
|
||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `pnpm exec redocly lint` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ alwaysApply: true
|
||||
## Dependency Management
|
||||
|
||||
**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-копирование без явного решения в репозитории.
|
||||
*Проверка:* review.
|
||||
@@ -123,10 +123,10 @@ alwaysApply: true
|
||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||
*Проверка:* 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.
|
||||
|
||||
**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`.
|
||||
|
||||
---
|
||||
@@ -229,7 +229,7 @@ alwaysApply: true
|
||||
```powershell
|
||||
go vet ./...
|
||||
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
|
||||
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||
|
||||
@@ -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
@@ -1,38 +1,39 @@
|
||||
# Gitea Actions
|
||||
|
||||
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
||||
|
||||
| Job | PR | push в main |
|
||||
|-----|-----|-------------|
|
||||
| changes, openapi, web, go, bird2 | quality gates | quality gates |
|
||||
| commitlint | да | — |
|
||||
| **release** | — | semantic-release + docker push (один run) |
|
||||
| Workflow | Когда | Что |
|
||||
|----------|--------|-----|
|
||||
| [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 |
|
||||
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, go, bird2, commitlint, docker-check |
|
||||
|
||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||
|
||||
## 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).
|
||||
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
|
||||
3. Зеркало base-образов в `evobgp-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evobgp` (`cleanup: false`).
|
||||
|
||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||
|
||||
Повтор упавшего **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-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`.
|
||||
|
||||
Пример:
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
run: sh deploy/docker/mirror-base-images.sh
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
IMAGE_TAG: latest
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||
SHA_FULL: ${{ github.sha }}
|
||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||
CACHE_REF_BIRDC: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:birdc-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
working-directory: deploy/docker
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${MIRROR_ENV_FILE}"
|
||||
set +a
|
||||
fi
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||
-f docker-bake.hcl default --push
|
||||
+16
-380
@@ -1,387 +1,23 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
||||
# ---------------------------------------------------------------------------
|
||||
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:
|
||||
- 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
|
||||
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: true
|
||||
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
# 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"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||
- name: bake (no push)
|
||||
if: inputs.allow_registry_login
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
CACHE_REF_GO: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:web-buildcache
|
||||
CACHE_REF_BIRDC: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:birdc-buildcache
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
export CACHE_REF_GO="git.shx.one/${owner_lc}/evobgp-buildcache:go-buildcache"
|
||||
export CACHE_REF_WEB="git.shx.one/${owner_lc}/evobgp-buildcache:web-buildcache"
|
||||
export CACHE_REF_BIRDC="git.shx.one/${owner_lc}/evobgp-buildcache:birdc-buildcache"
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
|
||||
@@ -50,6 +50,6 @@ docker compose --profile reference up -d
|
||||
|
||||
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
||||
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
|
||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CD run**, что и релиз (job `publish`)
|
||||
|
||||
Лицензия и условия использования — по политике владельца репозитория.
|
||||
|
||||
@@ -49,7 +49,8 @@ export function AccessApiKeysCard({
|
||||
<>
|
||||
<FrameDataGrid
|
||||
title="API-ключи"
|
||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
||||
description="Полный токен показывается только при создании и ротации."
|
||||
className="min-w-0"
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { ApiKey } from '@/types/api'
|
||||
|
||||
@@ -34,7 +35,14 @@ export function AccessApiKeysGrid({
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.name}
|
||||
accent="primary"
|
||||
subtitle={`${row.original.prefix}…`}
|
||||
/>
|
||||
),
|
||||
minSize: 160,
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
@@ -45,14 +53,6 @@ export function AccessApiKeysGrid({
|
||||
),
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'prefix',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}…</span>
|
||||
),
|
||||
meta: { headerTitle: 'Префикс' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
@@ -77,11 +77,11 @@ export function AccessApiKeysGrid({
|
||||
{
|
||||
id: 'last_used_at',
|
||||
accessorFn: (row) => row.last_used_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Последнее использование' },
|
||||
meta: { headerTitle: 'Использован' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -90,7 +90,7 @@ export function AccessApiKeysGrid({
|
||||
cell: ({ row }) => {
|
||||
const k = row.original
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
@@ -149,6 +149,7 @@ export function AccessApiKeysGrid({
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ключей"
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск API-ключей…"
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@evobgp/ui/components/chart'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
@@ -43,6 +45,7 @@ export function ChartDonutMetric({
|
||||
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
|
||||
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="count"
|
||||
|
||||
@@ -27,10 +27,10 @@ export function MonitoringHealthCard({
|
||||
return (
|
||||
<DonutBreakdownCard
|
||||
title="Доступность системы"
|
||||
description="GET /v1/health · GET /v1/ready"
|
||||
description="Проверки живучести и готовности"
|
||||
slices={slices}
|
||||
centerLabel="Проверки"
|
||||
badge={healthOk ? 'API OK' : undefined}
|
||||
badge={healthOk ? 'API в норме' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,15 +40,15 @@ export function NetworkOverviewAnalyticsCard({
|
||||
/>
|
||||
<SegmentedProgressCard
|
||||
title="Спикеры"
|
||||
description="Доступность live-агентов"
|
||||
description="Доступность агентов"
|
||||
primary={{
|
||||
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
label: 'Online',
|
||||
label: 'В сети',
|
||||
percent: speakersPct,
|
||||
}}
|
||||
secondary={{
|
||||
value: net.speakersTotal - net.speakersOnline,
|
||||
label: 'Offline',
|
||||
label: 'Не в сети',
|
||||
percent: 100 - speakersPct,
|
||||
}}
|
||||
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||
|
||||
@@ -41,14 +41,17 @@ export function BadgeTabs({
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
orientation="horizontal"
|
||||
className={cn('w-full', className)}
|
||||
className={cn('w-full min-w-0', className)}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
|
||||
className={cn(
|
||||
'mb-3.5 w-full min-w-0 justify-start gap-4',
|
||||
listClassName,
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
||||
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{item.count !== undefined ? (
|
||||
|
||||
@@ -50,7 +50,7 @@ export function DashboardActivityTimeline({
|
||||
<DashboardFramePanel
|
||||
title="Недавняя активность"
|
||||
description="Задачи, ревизии и сетевые события"
|
||||
className="h-full"
|
||||
className="h-full min-w-0"
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
||||
@@ -82,7 +82,7 @@ export function DashboardActivityTimeline({
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="min-w-0 pb-1 text-foreground">
|
||||
<TimelineTitle className="text-sm leading-snug font-normal">
|
||||
<TimelineTitle className="text-sm leading-snug font-normal break-words">
|
||||
{item.message}
|
||||
</TimelineTitle>
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -100,7 +100,7 @@ function buildKpis({
|
||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'онлайн'}
|
||||
{loading ? '…' : 'в сети'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ function buildMetrics({
|
||||
{
|
||||
id: 'bgp',
|
||||
title: 'BGP готовность',
|
||||
label: 'Established / включённые',
|
||||
label: 'Установлено / включено',
|
||||
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
|
||||
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
|
||||
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
|
||||
@@ -62,13 +62,13 @@ function buildMetrics({
|
||||
{
|
||||
id: 'speakers',
|
||||
title: 'Спикеры',
|
||||
label: 'Online / всего',
|
||||
label: 'В сети / всего',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
delta:
|
||||
loading || network.speakersTotal === 0
|
||||
? '…'
|
||||
: network.speakersOnline === network.speakersTotal
|
||||
? 'все online'
|
||||
? 'все в сети'
|
||||
: 'частично',
|
||||
deltaVariant:
|
||||
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
||||
|
||||
@@ -119,9 +119,10 @@ export function DashboardModulesGrid({
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
||||
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" />
|
||||
<DataGridPrimaryCell title={row.original.name} accent="primary" />
|
||||
</div>
|
||||
),
|
||||
minSize: 180,
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
@@ -164,6 +165,7 @@ export function DashboardModulesGrid({
|
||||
<DashboardFramePanel
|
||||
title="Модули"
|
||||
description="Поиск, сортировка и быстрый переход к настройке"
|
||||
className="min-w-0"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
||||
<PlusIcon />
|
||||
@@ -183,14 +185,14 @@ export function DashboardModulesGrid({
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
width: 'fixed',
|
||||
width: 'auto',
|
||||
}}
|
||||
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<InputGroup className="w-full min-w-40 sm:max-w-xs">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
|
||||
<InputGroup className="w-full min-w-0 @3xl:max-w-xs">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
|
||||
</InputGroupAddon>
|
||||
|
||||
@@ -3,13 +3,16 @@ import { useMemo, useState } from 'react'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, ModuleRow } from '@/types/api'
|
||||
|
||||
type FlowMode = 'jobs' | 'modules'
|
||||
|
||||
/** chart-13 inspired donut breakdown (Card surface). */
|
||||
/**
|
||||
* Donut distribution — chart-27 DNA: Frame + one legend (inside ChartDonutMetric).
|
||||
* @see https://reui.io/preview/base/chart-27
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function DashboardOperationsBreakdown({
|
||||
jobs,
|
||||
modules,
|
||||
@@ -32,7 +35,7 @@ export function DashboardOperationsBreakdown({
|
||||
return (
|
||||
<PanelCard
|
||||
title="Поток операций"
|
||||
description="Распределение задач и типов модулей"
|
||||
description="Доли статусов задач или типов модулей"
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
@@ -43,45 +46,16 @@ export function DashboardOperationsBreakdown({
|
||||
]}
|
||||
/>
|
||||
}
|
||||
className="h-full"
|
||||
className="min-w-0"
|
||||
contentClassName="px-5 py-4"
|
||||
>
|
||||
<div className="p-4">
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
||||
{slices.length > 0 ? (
|
||||
<ul className="mt-4 flex min-w-0 flex-col">
|
||||
{slices.map((slice, index) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<li key={slice.key}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
||||
style={{ backgroundColor: slice.color }}
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">{slice.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
|
||||
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
||||
)}
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'lookup',
|
||||
title: 'Проверка IP/домена',
|
||||
description: 'Проверка IP в списках и BGP-сообществах.',
|
||||
description: 'Проверка IP в списках и BGP community.',
|
||||
to: '/lookup',
|
||||
icon: <Search aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
@@ -22,8 +22,8 @@ const ACTIONS: QuickActionItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'communities',
|
||||
title: 'BGP-сообщества',
|
||||
description: 'Справочник BGP-сообществ для политик экспорта.',
|
||||
title: 'BGP community',
|
||||
description: 'Справочник BGP community для политик экспорта.',
|
||||
to: '/directories',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
@@ -49,7 +49,7 @@ export function DashboardRecentJobsGrid({
|
||||
[nameById],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
const { table, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => {
|
||||
@@ -63,16 +63,13 @@ export function DashboardRecentJobsGrid({
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск задач…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
@@ -42,7 +42,7 @@ export function DashboardRecentRevisionsGrid({
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
const { table, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => row.id,
|
||||
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ревизий"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск ревизий…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function DataGridShell<TData extends object>({
|
||||
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||
tableLayout={tableLayout}
|
||||
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
|
||||
className={className}
|
||||
className={cn('min-w-0', className)}
|
||||
onRowClick={onRowClick}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function CommunityFormDialog({
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={editTarget ? 'Редактировать сообщество' : 'Новое сообщество BGP'}
|
||||
title={editTarget ? 'Редактировать community' : 'Новое BGP community'}
|
||||
description="Тег для префиксов в фильтрах BIRD"
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
|
||||
@@ -73,10 +73,10 @@ export function DirectoriesCommunitiesGrid({
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет сообществ"
|
||||
emptyMessage="Нет community"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск сообществ…"
|
||||
searchPlaceholder="Поиск community…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function DohProfileFormDialog({
|
||||
if (timeoutMs.trim() !== '') {
|
||||
const ms = Number(timeoutMs)
|
||||
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
|
||||
toast.error('Timeout должен быть целым числом > 0')
|
||||
toast.error('Таймаут должен быть целым числом больше 0')
|
||||
return
|
||||
}
|
||||
timeout = ms
|
||||
@@ -136,7 +136,7 @@ export function DohProfileFormDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-timeout">Timeout, мс (опционально)</Label>
|
||||
<Label htmlFor="doh-timeout">Таймаут, мс (необязательно)</Label>
|
||||
<Input
|
||||
id="doh-timeout"
|
||||
type="number"
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Settings,
|
||||
BookText,
|
||||
KeyRound,
|
||||
ServerCog,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -72,7 +71,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
to: '/dashboard',
|
||||
label: 'Панель',
|
||||
icon: LayoutDashboard,
|
||||
description: 'KPI, модули и активность',
|
||||
description: 'Метрики, модули и активность',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -82,23 +81,22 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
||||
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
||||
{ 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: 'Операции',
|
||||
items: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Система',
|
||||
items: [
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
|
||||
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -91,7 +91,7 @@ export function AppsMenu() {
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настройки
|
||||
|
||||
@@ -136,7 +136,7 @@ export function NavUser() {
|
||||
setApiToken(null)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||
window.location.assign('/settings?tab=connection&reason=token-required')
|
||||
window.location.assign('/settings?tab=ui&reason=token-required')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,10 +190,10 @@ export function NavUser() {
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||
>
|
||||
<SettingsIcon aria-hidden />
|
||||
Настройки UI
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
{authOn ? (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -94,8 +94,8 @@ export function SystemMonitorPopover() {
|
||||
() => [
|
||||
{
|
||||
id: 'api',
|
||||
label: 'API health',
|
||||
value: healthOk ? 'OK' : '—',
|
||||
label: 'Состояние API',
|
||||
value: healthOk ? 'норма' : '—',
|
||||
unit: '',
|
||||
percent: healthOk ? 100 : 0,
|
||||
icon: <HeartPulse aria-hidden />,
|
||||
|
||||
@@ -14,8 +14,8 @@ import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
|
||||
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
|
||||
|
||||
const CDN_KIND_ITEMS = [
|
||||
{ value: 'plaintext', label: 'plaintext' },
|
||||
{ value: 'json', label: 'json' },
|
||||
{ value: 'plaintext', label: 'Текст' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
] as const
|
||||
|
||||
interface ModuleCdnSourceDialogProps {
|
||||
|
||||
@@ -160,7 +160,7 @@ export function ModuleEditDialog({
|
||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<Label htmlFor="mod-enabled">Включён</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выключенный модуль не участвует в refresh и apply.
|
||||
Выключенный модуль не участвует в обновлении и применении.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { formatDateTime } from '@/lib/modules/display'
|
||||
import { communityLabel } from '@/lib/modules/helpers'
|
||||
import { cdnSourceKindRu } from '@/lib/ui-labels'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type {
|
||||
AsEntry,
|
||||
@@ -145,7 +146,7 @@ export function ModuleEntriesGrid({
|
||||
accessorKey: 'source_kind',
|
||||
header: 'Тип',
|
||||
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 {
|
||||
if (s.role === 'master') {
|
||||
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)}…`
|
||||
}
|
||||
@@ -51,7 +51,7 @@ const filterFields: FilterFieldConfig[] = [
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-48',
|
||||
placeholder: 'IP или Neighbor ID…',
|
||||
placeholder: 'IP или ID соседа…',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -94,7 +94,7 @@ export function NetworkDiscoveredPeersCard({
|
||||
id: 'neighbor_id',
|
||||
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Neighbor ID" />
|
||||
<DataGridColumnHeader column={column} title="ID соседа" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
@@ -103,7 +103,7 @@ export function NetworkDiscoveredPeersCard({
|
||||
accent="mono"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Neighbor ID' },
|
||||
meta: { headerTitle: 'ID соседа' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'remote_asn',
|
||||
@@ -212,7 +212,7 @@ export function NetworkDiscoveredPeersCard({
|
||||
title="Одобрить пира"
|
||||
description={
|
||||
approveTarget
|
||||
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
||||
? `ID соседа ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
||||
: undefined
|
||||
}
|
||||
className="sm:max-w-sm"
|
||||
|
||||
@@ -55,7 +55,7 @@ export function NetworkKpi({
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'онлайн'}
|
||||
{loading ? '…' : 'в сети'}
|
||||
</Badge>
|
||||
),
|
||||
to: '/network',
|
||||
|
||||
@@ -39,11 +39,11 @@ const PEER_TABS = [
|
||||
|
||||
const SESSION_STATE_OPTIONS = [
|
||||
{ value: 'Established', label: 'Установлена' },
|
||||
{ value: 'Idle', label: 'Idle' },
|
||||
{ value: 'Active', label: 'Active' },
|
||||
{ value: 'Connect', label: 'Connect' },
|
||||
{ value: 'OpenSent', label: 'OpenSent' },
|
||||
{ value: 'OpenConfirm', label: 'OpenConfirm' },
|
||||
{ value: 'Idle', label: 'Простой' },
|
||||
{ value: 'Active', label: 'Поиск' },
|
||||
{ value: 'Connect', label: 'Соединение' },
|
||||
{ value: 'OpenSent', label: 'Open отправлен' },
|
||||
{ value: 'OpenConfirm', label: 'Open подтверждён' },
|
||||
]
|
||||
|
||||
function createDefaultPeerFilters(): Filter[] {
|
||||
|
||||
@@ -31,14 +31,14 @@ function speakerDeleteLabel(s: SpeakerRow): string {
|
||||
|
||||
const SPEAKER_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'online', label: 'Online' },
|
||||
{ id: 'offline', label: 'Offline' },
|
||||
{ id: 'online', label: 'В сети' },
|
||||
{ id: 'offline', label: 'Не в сети' },
|
||||
]
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'primary', label: 'primary' },
|
||||
{ value: 'secondary', label: 'secondary' },
|
||||
{ value: 'speaker', label: 'speaker' },
|
||||
{ value: 'primary', label: 'Основной' },
|
||||
{ value: 'secondary', label: 'Резервный' },
|
||||
{ value: 'speaker', label: 'Спикер' },
|
||||
]
|
||||
|
||||
function createDefaultSpeakerFilters(): Filter[] {
|
||||
@@ -52,7 +52,7 @@ const speakerFilterFields: FilterFieldConfig[] = [
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'endpoint…',
|
||||
placeholder: 'Адрес агента…',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
@@ -128,7 +128,7 @@ export function NetworkSpeakersCard({
|
||||
<>
|
||||
<ResourcePage
|
||||
title="Спикеры"
|
||||
description="BIRD-агенты на нодах tenant"
|
||||
description="BIRD-агенты на нодах арендатора"
|
||||
tabs={SPEAKER_TABS}
|
||||
tabFilter={speakerTabFilter}
|
||||
filterFields={speakerFilterFields}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
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'
|
||||
|
||||
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||
@@ -20,7 +20,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
||||
cell: ({ row }) => <CategoryBadge>{speakerRoleRu(row.original.role)}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ interface PeerFormDialogProps {
|
||||
function speakerLabel(s: SpeakerRow): string {
|
||||
if (s.role === 'master') {
|
||||
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)}…`
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
const ep =
|
||||
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
|
||||
if (!ep) {
|
||||
toast.error('Укажите endpoint или agent domain')
|
||||
toast.error('Укажите конечную точку или домен агента')
|
||||
return
|
||||
}
|
||||
const body: BgpSpeakerCreate = {
|
||||
@@ -95,7 +95,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новый спикер"
|
||||
description="BIRD-агент на ноде реплики или control plane"
|
||||
description="BIRD-агент на ноде реплики или плоскости управления"
|
||||
className="sm:max-w-md"
|
||||
footer={
|
||||
<>
|
||||
@@ -122,7 +122,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
label="Роль"
|
||||
items={[
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер (CP)' },
|
||||
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
@@ -146,7 +146,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
id="speaker-bgp-source"
|
||||
placeholder="203.0.113.10"
|
||||
|
||||
@@ -77,7 +77,7 @@ export function OperationsRevisionsGrid({
|
||||
</Button>
|
||||
}
|
||||
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
|
||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
||||
confirmLabel="Откатить"
|
||||
destructive
|
||||
onConfirm={() => rollbackMutation.mutate(row.original.id)}
|
||||
|
||||
@@ -8,12 +8,16 @@ interface PageHeaderProps {
|
||||
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
{description ? (
|
||||
<p className="text-muted-foreground max-w-prose text-sm break-words">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function PanelCard({
|
||||
<Frame
|
||||
dense
|
||||
spacing={spacing}
|
||||
className={cn(panelCardClassName, className)}
|
||||
className={cn(panelCardClassName, 'min-w-0', className)}
|
||||
>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
|
||||
@@ -29,10 +29,10 @@ export function FrameDataGrid({
|
||||
}) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
||||
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3 border-b">
|
||||
<FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? (
|
||||
|
||||
@@ -24,4 +24,4 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { FrameDataGrid } from './frame-data-grid'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
export { SettingsShell } from './settings-shell'
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
|
||||
/**
|
||||
* Shared grid column classes for hybrid KPI / Quick Actions tiles.
|
||||
* Container queries only — never 5–6 columns on laptop width (tiles overlap).
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4'
|
||||
if (count <= 6) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
|
||||
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4'
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ export function KpiStatGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
|
||||
@@ -27,7 +20,7 @@ interface OpsDashboardProps {
|
||||
}
|
||||
|
||||
const rootClassName =
|
||||
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
||||
'text-foreground @container flex w-full min-w-0 flex-col gap-4 md:gap-6'
|
||||
|
||||
function OpsDashboardSkeleton() {
|
||||
return (
|
||||
@@ -37,7 +30,7 @@ function OpsDashboardSkeleton() {
|
||||
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||
</header>
|
||||
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||
<div className="grid gap-2 @3xl:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
@@ -47,9 +40,11 @@ function OpsDashboardSkeleton() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ops dashboard: KPI → optional Quick Actions → charts → attention queue.
|
||||
* Ops dashboard: KPI → optional Quick Actions → charts → queue.
|
||||
* Queue is a sibling Frame grid — never wrap FrameDataGrid inside another Frame.
|
||||
* @see https://reui.io/preview/base/dashboard-1
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function OpsDashboard({
|
||||
title = 'Панель управления',
|
||||
@@ -77,21 +72,18 @@ export function OpsDashboard({
|
||||
|
||||
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
|
||||
|
||||
<section
|
||||
aria-label="Аналитика"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
>
|
||||
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label={queueTitle}>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>{queueTitle}</FrameTitle>
|
||||
<FrameDescription>{queueDescription}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>{queue}</FramePanel>
|
||||
</Frame>
|
||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||
{queueDescription ? (
|
||||
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{queue}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,125 +1,17 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
|
||||
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
|
||||
export interface SettingsTabConfig {
|
||||
id: string
|
||||
to: '/settings' | '/tenant-settings'
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
/** Default nav for EvoBGP settings routes (`/settings`, `/tenant-settings`). */
|
||||
const DEFAULT_TABS: SettingsTabConfig[] = [
|
||||
{
|
||||
id: 'ui',
|
||||
to: '/settings',
|
||||
label: 'Настройки UI',
|
||||
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
id: 'tenant',
|
||||
to: '/tenant-settings',
|
||||
label: 'Настройки BIRD',
|
||||
icon: <ServerCogIcon className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsShellProps {
|
||||
title?: string
|
||||
description?: string
|
||||
tabs?: SettingsTabConfig[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings shell — Frame surface, no hairline Separator under header.
|
||||
* @see https://reui.io/preview/base/settings-16
|
||||
* @see https://reui.io/preview/base/settings-3
|
||||
* Settings layout — page chrome only.
|
||||
* Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
|
||||
* @see https://reui.io/preview/base/settings-7
|
||||
* @see https://reui.io/blocks
|
||||
*/
|
||||
export function SettingsShell({
|
||||
title = 'Настройки',
|
||||
description,
|
||||
tabs = DEFAULT_TABS,
|
||||
}: SettingsShellProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const headerDescription =
|
||||
description ??
|
||||
(pathname.startsWith('/tenant-settings')
|
||||
? 'Глобальные параметры BIRD и плоскости управления'
|
||||
: 'Подключение UI и параметры плоскости управления')
|
||||
|
||||
export function SettingsShell() {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
||||
<PageHeader title={title} description={headerDescription} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-5',
|
||||
isMobile ? 'flex-col' : 'flex-row items-start',
|
||||
)}
|
||||
>
|
||||
{tabs.length > 1 ? (
|
||||
<nav
|
||||
aria-label="Разделы настроек"
|
||||
className={cn(
|
||||
'flex gap-1',
|
||||
isMobile
|
||||
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
||||
: 'w-44 shrink-0 flex-col',
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = pathname.startsWith(tab.to)
|
||||
const className = cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
isMobile && 'shrink-0',
|
||||
!isMobile && 'w-full',
|
||||
isActive
|
||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||
)
|
||||
if (tab.to === '/tenant-settings') {
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to="/tenant-settings"
|
||||
search={{ tab: 'bird' }}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={className}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to="/settings"
|
||||
search={{ tab: 'connection' }}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={className}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge variant="secondary" className="px-1 font-normal">
|
||||
{selectedValues.size} selected
|
||||
{selectedValues.size} выбрано
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
@@ -94,7 +94,7 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
No results found.
|
||||
Ничего не найдено.
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
@@ -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"
|
||||
>
|
||||
Clear filters
|
||||
Сбросить фильтры
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -39,11 +39,11 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
info: "{from} - {to} of {count}",
|
||||
info: "{from}–{to} из {count}",
|
||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||
rowsPerPageLabel: "Rows per page",
|
||||
previousPageLabel: "Go to previous page",
|
||||
nextPageLabel: "Go to next page",
|
||||
rowsPerPageLabel: "Строк на странице",
|
||||
previousPageLabel: "Предыдущая страница",
|
||||
nextPageLabel: "Следующая страница",
|
||||
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",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
aria-label="Перетащить строку"
|
||||
disabled
|
||||
>
|
||||
<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",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
aria-label="Перетащить строку"
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
|
||||
@@ -105,7 +105,7 @@ function DataGridTableDndHeader<TData>({
|
||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
aria-label="Перетащить для изменения порядка"
|
||||
>
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
@@ -397,9 +397,9 @@ function DataGridTableVirtual<TData>({
|
||||
|
||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||
const loadingMoreMessage =
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Загрузка…"
|
||||
const allRowsLoadedMessage =
|
||||
props.allRowsLoadedMessage || "All records loaded"
|
||||
props.allRowsLoadedMessage || "Все записи загружены"
|
||||
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
|
||||
@@ -1241,7 +1241,7 @@ function DataGridTableEmpty() {
|
||||
colSpan={Math.max(visibleColumnCount, 1)}
|
||||
className="text-muted-foreground py-6 text-center text-sm"
|
||||
>
|
||||
{props.emptyMessage || "No data available"}
|
||||
{props.emptyMessage || "Нет данных"}
|
||||
</td>
|
||||
</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="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" />
|
||||
{props.loadingMessage || "Loading..."}
|
||||
{props.loadingMessage || "Загрузка…"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1266,7 +1266,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isPinned ? "Unpin row" : "Pin row"}
|
||||
aria-label={isPinned ? "Открепить строку" : "Закрепить строку"}
|
||||
onClick={() => {
|
||||
if (isPinned) {
|
||||
row.pin(false)
|
||||
@@ -1322,7 +1322,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
aria-label="Выбрать строку"
|
||||
className="align-[inherit]"
|
||||
/>
|
||||
</>
|
||||
@@ -1341,7 +1341,7 @@ function DataGridTableRowSelectAll() {
|
||||
indeterminate={isSomeSelected && !isAllSelected}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
aria-label="Выбрать все"
|
||||
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"
|
||||
></path>
|
||||
</svg>
|
||||
{props.loadingMessage || "Loading..."}
|
||||
{props.loadingMessage || "Загрузка…"}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -123,75 +123,70 @@ export interface FilterI18nConfig {
|
||||
|
||||
// Default English i18n configuration
|
||||
export const DEFAULT_I18N: FilterI18nConfig = {
|
||||
// UI Labels
|
||||
addFilter: "Filter",
|
||||
searchFields: "Filter...",
|
||||
noFieldsFound: "No filters found.",
|
||||
noResultsFound: "No results found.",
|
||||
select: "Select...",
|
||||
true: "True",
|
||||
false: "False",
|
||||
min: "Min",
|
||||
max: "Max",
|
||||
to: "to",
|
||||
typeAndPressEnter: "Type and press Enter to add tag",
|
||||
selected: "selected",
|
||||
selectedCount: "selected",
|
||||
addFilter: "Фильтр",
|
||||
searchFields: "Фильтр…",
|
||||
noFieldsFound: "Поля не найдены.",
|
||||
noResultsFound: "Ничего не найдено.",
|
||||
select: "Выбрать…",
|
||||
true: "Да",
|
||||
false: "Нет",
|
||||
min: "Мин.",
|
||||
max: "Макс.",
|
||||
to: "—",
|
||||
typeAndPressEnter: "Введите и нажмите Enter",
|
||||
selected: "выбрано",
|
||||
selectedCount: "выбрано",
|
||||
percent: "%",
|
||||
defaultCurrency: "$",
|
||||
defaultColor: "#000000",
|
||||
addFilterTitle: "Add filter",
|
||||
addFilterTitle: "Добавить фильтр",
|
||||
|
||||
// Operators
|
||||
operators: {
|
||||
is: "is",
|
||||
isNot: "is not",
|
||||
isAnyOf: "is any of",
|
||||
isNotAnyOf: "is not any of",
|
||||
includesAll: "includes all",
|
||||
excludesAll: "excludes all",
|
||||
before: "before",
|
||||
after: "after",
|
||||
between: "between",
|
||||
notBetween: "not between",
|
||||
contains: "contains",
|
||||
notContains: "does not contain",
|
||||
startsWith: "starts with",
|
||||
endsWith: "ends with",
|
||||
isExactly: "is exactly",
|
||||
equals: "equals",
|
||||
notEquals: "not equals",
|
||||
greaterThan: "greater than",
|
||||
lessThan: "less than",
|
||||
overlaps: "overlaps",
|
||||
includes: "includes",
|
||||
excludes: "excludes",
|
||||
includesAllOf: "includes all of",
|
||||
includesAnyOf: "includes any of",
|
||||
empty: "is empty",
|
||||
notEmpty: "is not empty",
|
||||
is: "равно",
|
||||
isNot: "не равно",
|
||||
isAnyOf: "любое из",
|
||||
isNotAnyOf: "кроме",
|
||||
includesAll: "включает все",
|
||||
excludesAll: "исключает все",
|
||||
before: "до",
|
||||
after: "после",
|
||||
between: "между",
|
||||
notBetween: "вне диапазона",
|
||||
contains: "содержит",
|
||||
notContains: "не содержит",
|
||||
startsWith: "начинается с",
|
||||
endsWith: "заканчивается на",
|
||||
isExactly: "точно",
|
||||
equals: "равно",
|
||||
notEquals: "не равно",
|
||||
greaterThan: "больше",
|
||||
lessThan: "меньше",
|
||||
overlaps: "пересекается",
|
||||
includes: "включает",
|
||||
excludes: "исключает",
|
||||
includesAllOf: "включает все",
|
||||
includesAnyOf: "включает любое",
|
||||
empty: "пусто",
|
||||
notEmpty: "не пусто",
|
||||
},
|
||||
|
||||
// Placeholders
|
||||
placeholders: {
|
||||
enterField: (fieldType: string) => `Enter ${fieldType}...`,
|
||||
selectField: "Select...",
|
||||
searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`,
|
||||
enterKey: "Enter key...",
|
||||
enterValue: "Enter value...",
|
||||
enterField: (fieldType: string) => `Введите ${fieldType}…`,
|
||||
selectField: "Выбрать…",
|
||||
searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}…`,
|
||||
enterKey: "Введите ключ…",
|
||||
enterValue: "Введите значение…",
|
||||
},
|
||||
|
||||
// Helper functions
|
||||
helpers: {
|
||||
formatOperator: (operator: string) => operator.replace(/_/g, " "),
|
||||
},
|
||||
|
||||
// Validation
|
||||
validation: {
|
||||
invalidEmail: "Invalid email format",
|
||||
invalidUrl: "Invalid URL format",
|
||||
invalidTel: "Invalid phone format",
|
||||
invalid: "Invalid input format",
|
||||
invalidEmail: "Некорректный email",
|
||||
invalidUrl: "Некорректный URL",
|
||||
invalidTel: "Некорректный телефон",
|
||||
invalid: "Некорректное значение",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ export function ScheduleAgendaPanel({
|
||||
return (
|
||||
<PanelCard
|
||||
title="Календарь задач"
|
||||
description="Задачи refresh и apply по дням"
|
||||
description="Задачи обновления и применения по дням"
|
||||
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ScheduleModulesGrid({
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{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>
|
||||
),
|
||||
meta: { headerTitle: 'Расписание' },
|
||||
|
||||
@@ -44,7 +44,7 @@ export function AppearanceSettingsTab() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard
|
||||
title="Тема интерфейса"
|
||||
description="Быстрый переключатель также доступен в боковой панели"
|
||||
@@ -61,6 +61,7 @@ export function AppearanceSettingsTab() {
|
||||
мгновенно
|
||||
</Badge>
|
||||
}
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<ToggleGroup
|
||||
@@ -72,7 +73,7 @@ export function AppearanceSettingsTab() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Тема интерфейса"
|
||||
className="w-full sm:w-auto"
|
||||
className="w-full min-w-0"
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = option.icon
|
||||
@@ -89,16 +90,16 @@ export function AppearanceSettingsTab() {
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Дашборд"
|
||||
title="Обзор"
|
||||
description="Блоки на экране «Обзор»"
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Быстрые действия"
|
||||
description="Показывать KPI-like плитки быстрых переходов под метриками."
|
||||
description="Показывать плитки быстрых переходов под метриками."
|
||||
>
|
||||
<SettingRow
|
||||
title="Быстрые действия"
|
||||
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
|
||||
description="Блок с частыми переходами (модули, сеть, деплой) на экране «Обзор»."
|
||||
last
|
||||
>
|
||||
<Switch
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Save } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
import { SettingsCard } from '@/components/settings/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
|
||||
import {
|
||||
BIRD_SETTING_KEYS,
|
||||
REVISION_SETTING_KEYS,
|
||||
RUNTIME_LOGS_SETTING_KEYS,
|
||||
buildPayload,
|
||||
partitionSettings,
|
||||
settingsKeys,
|
||||
settingsQueryOptions,
|
||||
type BirdSettingKey,
|
||||
} from '@/queries/settings'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
|
||||
const RUNTIME_LOGS_ENABLED_ITEMS = [
|
||||
{ value: 'true', label: 'Вкл' },
|
||||
{ value: 'false', label: 'Выкл' },
|
||||
] as const
|
||||
|
||||
const RUNTIME_LOGS_MODE_ITEMS = [
|
||||
{ value: 'truncate', label: 'Обнулить файл' },
|
||||
{ value: 'delete', label: 'Удалить файл' },
|
||||
] as const
|
||||
|
||||
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||
bird_router_id: 'Router ID',
|
||||
bird_local_ipv4: 'Локальный IPv4',
|
||||
bird_local_ipv6: 'Локальный IPv6',
|
||||
bird_local_asn: 'Локальный ASN',
|
||||
bird_bgp_source_ipv4: 'Исходный IPv4 BGP',
|
||||
bird_bgp_source_ipv6: 'Исходный IPv6 BGP',
|
||||
peer_discovery_enabled: 'Автообнаружение пиров',
|
||||
peer_discovery_ranges_v4: 'CIDR автообнаружения IPv4',
|
||||
peer_discovery_ranges_v6: 'CIDR автообнаружения IPv6',
|
||||
peer_discovery_require_external: 'Только внешние ASN',
|
||||
}
|
||||
|
||||
const BIRD_BOOL_ITEMS = [
|
||||
{ value: 'true', label: 'Вкл' },
|
||||
{ value: 'false', label: 'Выкл' },
|
||||
] as const
|
||||
|
||||
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
||||
peer_discovery_enabled:
|
||||
'Динамический диапазон соседей в BIRD (карантин import/export none). Нужны CIDR.',
|
||||
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
|
||||
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
|
||||
peer_discovery_require_external: 'Диапазон соседей с внешним (любым чужим) ASN',
|
||||
}
|
||||
|
||||
export function BirdSettingsTab() {
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const qc = useQueryClient()
|
||||
|
||||
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
||||
|
||||
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
||||
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
||||
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (partitioned) {
|
||||
setBirdForm({ ...partitioned.bird })
|
||||
setRevisionForm({ ...partitioned.revision })
|
||||
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsQ.data])
|
||||
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
||||
apiMutate('/v1/settings', 'PATCH', payload),
|
||||
onSuccess: () => {
|
||||
toast.success('Параметры сохранены')
|
||||
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
function saveBird() {
|
||||
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
||||
}
|
||||
function saveRevision() {
|
||||
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
||||
}
|
||||
function saveRuntimeLogs() {
|
||||
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard
|
||||
title="Плоскость управления BIRD"
|
||||
description="Глобальные параметры BIRD для обновления модулей и применения конфигурации. Сохранение доступно оператору."
|
||||
footer={
|
||||
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-64" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="BIRD"
|
||||
description="Параметры демона и автообнаружения пиров."
|
||||
>
|
||||
{BIRD_SETTING_KEYS.map((key, index) => (
|
||||
<SettingsSettingField
|
||||
key={key}
|
||||
title={BIRD_LABELS[key]}
|
||||
description={BIRD_HINTS[key]}
|
||||
labelFor={key}
|
||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||
stacked
|
||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||
>
|
||||
{key === 'peer_discovery_enabled' ||
|
||||
key === 'peer_discovery_require_external' ? (
|
||||
<SelectMenu
|
||||
id={key}
|
||||
items={[...BIRD_BOOL_ITEMS]}
|
||||
value={birdForm[key] || 'false'}
|
||||
onValueChange={(v) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||
}
|
||||
placeholder="Выкл"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={key}
|
||||
className="w-full min-w-0"
|
||||
value={birdForm[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||
}
|
||||
placeholder={BIRD_LABELS[key]}
|
||||
/>
|
||||
)}
|
||||
</SettingsSettingField>
|
||||
))}
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Ревизии"
|
||||
description="Время хранения ревизий в БД"
|
||||
footer={
|
||||
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="Хранение"
|
||||
description="Срок хранения ревизий в минутах."
|
||||
>
|
||||
<SettingsSettingField
|
||||
title="Срок хранения"
|
||||
description="Сколько минут хранить ревизии конфигурации."
|
||||
labelFor="revision_retention_minutes"
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<Input
|
||||
id="revision_retention_minutes"
|
||||
type="number"
|
||||
className="w-full min-w-0"
|
||||
value={revisionForm.revision_retention_minutes ?? ''}
|
||||
onChange={(e) =>
|
||||
setRevisionForm((s) => ({
|
||||
...s,
|
||||
revision_retention_minutes: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Файловые логи"
|
||||
description="Автоматическая очистка логов"
|
||||
footer={
|
||||
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-48" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="Авто-очистка"
|
||||
description="Расписание и лимиты файловых логов runtime."
|
||||
>
|
||||
<SettingsSettingField
|
||||
title="Авто-очистка"
|
||||
description="Включить периодическую очистку файловых логов."
|
||||
stacked
|
||||
>
|
||||
<SelectMenu
|
||||
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||
placeholder="Выберите"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_enabled: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
<SettingsSettingField
|
||||
title="Макс. размер файла"
|
||||
description="Порог в мегабайтах, после которого срабатывает очистка."
|
||||
labelFor="runtime_logs_max_file_mb"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="runtime_logs_max_file_mb"
|
||||
type="number"
|
||||
className="w-full min-w-0"
|
||||
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_max_file_mb: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
<SettingsSettingField
|
||||
title="Расписание"
|
||||
description="Cron-выражение для авто-очистки."
|
||||
labelFor="runtime_logs_auto_schedule"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="runtime_logs_auto_schedule"
|
||||
className="w-full min-w-0"
|
||||
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_schedule: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
<SettingsSettingField
|
||||
title="Режим очистки"
|
||||
description="Обнулить файл или удалить его."
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<SelectMenu
|
||||
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||
placeholder="Выберите"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_mode: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
|
||||
<FrameDataGrid
|
||||
title="Дополнительные параметры"
|
||||
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned?.additional ?? []}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
empty={(partitioned?.additional ?? []).length === 0}
|
||||
emptyTitle="Нет дополнительных параметров"
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<SettingsKvGrid
|
||||
items={items}
|
||||
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</FrameDataGrid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Save } from 'lucide-react'
|
||||
import { KeyRound, Save } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SettingRow } from '@/components/settings/setting-row'
|
||||
import { SettingsCard } from '@/components/settings/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
DEV_API_TOKEN,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
TOKEN_STORAGE_KEY,
|
||||
} from '@/lib/api-client'
|
||||
import { authKeys } from '@/queries/auth'
|
||||
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
|
||||
@@ -50,21 +50,22 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
{tokenRequired ? (
|
||||
<Alert>
|
||||
<Alert variant="warning">
|
||||
<KeyRound aria-hidden />
|
||||
<AlertTitle>Нужен API-токен</AlertTitle>
|
||||
<AlertDescription>
|
||||
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
|
||||
dev».
|
||||
Сохраните токен ниже или нажмите «Использовать dev».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<SettingsCard
|
||||
title="Подключение к API"
|
||||
description="Токен хранится только в этом браузере (localStorage)"
|
||||
description="Токен хранится только в этом браузере"
|
||||
footer={
|
||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||
<div className="flex w-full min-w-0 flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||
Использовать dev
|
||||
</Button>
|
||||
@@ -83,8 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
||||
title="Токен для запросов"
|
||||
description={
|
||||
<>
|
||||
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
|
||||
ключами tenant — в разделе{' '}
|
||||
Ключ для заголовка Authorization. Управление ключами арендатора — в разделе{' '}
|
||||
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||
Права доступа
|
||||
</Link>
|
||||
@@ -93,16 +93,18 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
||||
}
|
||||
titleAddon={
|
||||
<Badge variant="outline" size="sm">
|
||||
localStorage
|
||||
браузер
|
||||
</Badge>
|
||||
}
|
||||
labelFor="settings-token"
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<Input
|
||||
id="settings-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className="w-full min-w-0"
|
||||
value={token}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
placeholder="dev или API-ключ"
|
||||
|
||||
@@ -23,42 +23,45 @@ import {
|
||||
const ADMIN_SECTIONS = [
|
||||
{
|
||||
id: 'tenant-settings',
|
||||
to: '/tenant-settings' as const,
|
||||
to: '/settings' as const,
|
||||
search: { tab: 'bird' as const },
|
||||
title: 'Параметры арендатора',
|
||||
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
||||
badge: { label: 'оператор', variant: 'warning-light' as const },
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
to: '/access' as const,
|
||||
search: undefined,
|
||||
title: 'Права доступа',
|
||||
description: 'API-ключи tenant, роли и управление доступом.',
|
||||
description: 'API-ключи арендатора, роли и управление доступом.',
|
||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
||||
badge: { label: 'оператор', variant: 'warning-light' as const },
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
to: '/monitoring' as const,
|
||||
search: undefined,
|
||||
title: 'Мониторинг',
|
||||
description: 'Метрики, состояние jobs и observability control plane.',
|
||||
description: 'Метрики, состояние задач и наблюдаемость плоскости управления.',
|
||||
icon: <ActivityIcon aria-hidden="true" />,
|
||||
badge: { label: 'viewer+', variant: 'info-light' as const },
|
||||
badge: { label: 'просмотр+', variant: 'info-light' as const },
|
||||
},
|
||||
] as const
|
||||
|
||||
export function SectionsSettingsTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard
|
||||
title="Разделы control plane"
|
||||
description="Параметры tenant и операции — отдельно от настроек браузера"
|
||||
title="Разделы плоскости управления"
|
||||
description="Параметры арендатора и операции — отдельно от настроек браузера"
|
||||
>
|
||||
<ItemGroup className="gap-0">
|
||||
{ADMIN_SECTIONS.map((section, index) => (
|
||||
<Fragment key={section.id}>
|
||||
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
||||
<Item className="min-h-0 min-w-0 flex-wrap items-center gap-4 px-5 py-3.5">
|
||||
<ItemMedia className="self-center!">
|
||||
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||
{section.icon}
|
||||
@@ -66,7 +69,7 @@ export function SectionsSettingsTab() {
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||
<ItemTitle className="gap-2 leading-5">
|
||||
<ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
|
||||
{section.title}
|
||||
<Badge variant={section.badge.variant} size="xs">
|
||||
{section.badge.label}
|
||||
@@ -76,7 +79,17 @@ export function SectionsSettingsTab() {
|
||||
</ItemContent>
|
||||
|
||||
<ItemActions className="shrink-0 justify-end self-center">
|
||||
<Button variant="outline" size="sm" render={<Link to={section.to} />}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={
|
||||
section.to === '/settings' ? (
|
||||
<Link to="/settings" search={{ tab: 'bird' }} />
|
||||
) : (
|
||||
<Link to={section.to} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
</ItemActions>
|
||||
|
||||
@@ -35,8 +35,8 @@ export function SessionSettingsTab() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard title="Текущая сессия" description="Проверка токена через API сессии">
|
||||
<QueryState
|
||||
data={sessionQ.data}
|
||||
isLoading={sessionQ.isLoading && hasStoredToken}
|
||||
@@ -58,15 +58,17 @@ export function SessionSettingsTab() {
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||
<ItemTitle className="gap-2 leading-5">
|
||||
<ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
|
||||
Этот браузер
|
||||
<Badge variant="success-light" size="xs">
|
||||
Активна
|
||||
</Badge>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="leading-5">
|
||||
Tenant:{' '}
|
||||
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
||||
Арендатор:{' '}
|
||||
<code className="text-foreground font-mono text-xs break-all">
|
||||
{session.tenant_id}
|
||||
</code>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
@@ -82,7 +84,7 @@ export function SessionSettingsTab() {
|
||||
>
|
||||
<SettingRow
|
||||
title="Роль"
|
||||
description="Определяет доступ к операциям control plane и CRUD."
|
||||
description="Определяет доступ к операциям плоскости управления и изменению данных."
|
||||
titleAddon={
|
||||
sessionQ.data ? (
|
||||
<Badge variant="info-light" size="sm">
|
||||
@@ -94,12 +96,13 @@ export function SessionSettingsTab() {
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
stacked
|
||||
last
|
||||
>
|
||||
{sessionQ.data ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
|
||||
<code className="font-mono text-xs">{sessionQ.data.tenant_id}</code>.
|
||||
<p className="text-muted-foreground text-sm break-words">
|
||||
Ключ с ролью <strong>{ROLE_LABELS[sessionQ.data.role] ?? sessionQ.data.role}</strong> у арендатора{' '}
|
||||
<code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -10,31 +10,59 @@ import {
|
||||
FieldTitle,
|
||||
} from '@evobgp/ui/components/field'
|
||||
|
||||
interface SettingRowProps {
|
||||
export interface SettingRowProps {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
|
||||
separated?: boolean
|
||||
compact?: boolean
|
||||
stacked?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
className?: string
|
||||
titleAddon?: ReactNode
|
||||
}
|
||||
|
||||
/** Settings row — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */
|
||||
/**
|
||||
* Settings row — settings-3 / settings-16 DNA.
|
||||
* Control is flex-1 + min-w-0 (never a fixed w-78) so label and input do not overlap.
|
||||
* @see https://reui.io/preview/base/settings-3
|
||||
* @see https://reui.io/preview/base/settings-16
|
||||
*/
|
||||
export function SettingRow({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
last,
|
||||
separated = false,
|
||||
compact,
|
||||
stacked,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
className,
|
||||
titleAddon,
|
||||
}: SettingRowProps) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-5 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Field
|
||||
orientation={stacked ? 'vertical' : 'responsive'}
|
||||
className={cn(
|
||||
'min-w-0 gap-4 px-5 py-3.5',
|
||||
!stacked &&
|
||||
'@md/field-group:items-start @md/field-group:*:min-w-0 @md/field-group:*:max-w-full',
|
||||
!last && !separated && 'border-border/40 border-b',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 flex-col gap-0.5',
|
||||
!stacked && '@md/field-group:max-w-sm',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
@@ -44,20 +72,33 @@ export function SettingRow({
|
||||
</div>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
<FieldDescription className="text-sm break-words">{description}</FieldDescription>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
className={cn('min-w-0 @md/field-group:w-78', contentClassName)}
|
||||
className={cn(
|
||||
'w-full min-w-0 @md/field-group:flex-1',
|
||||
stacked
|
||||
? 'max-w-none'
|
||||
: compact
|
||||
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
|
||||
: '@md/field-group:max-w-[min(100%,34rem)]',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full justify-start @md/field-group:justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-w-0 justify-start',
|
||||
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
{separated && !last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SettingsCard({
|
||||
footerClassName,
|
||||
}: SettingsCardProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
||||
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
<FrameHeader className="gap-0 border-b px-5 py-3">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
|
||||
@@ -1,45 +1,138 @@
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@evobgp/ui/components/tabs'
|
||||
|
||||
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
||||
import { BirdSettingsTab } from './bird-settings-tab'
|
||||
import { ConnectionSettingsTab } from './connection-settings-tab'
|
||||
import { SectionsSettingsTab } from './sections-settings-tab'
|
||||
import { SessionSettingsTab } from './session-settings-tab'
|
||||
import {
|
||||
SETTINGS_TAB_ITEMS,
|
||||
type SettingsTab,
|
||||
type SettingsSection,
|
||||
type SettingsTabItem,
|
||||
} from './settings-tabs-data'
|
||||
|
||||
function UiSettingsPanel({ tokenRequired }: { tokenRequired: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
||||
<SessionSettingsTab />
|
||||
<AppearanceSettingsTab />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsNavigation({
|
||||
isMobile,
|
||||
activeValue,
|
||||
items,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
activeValue: string
|
||||
items: SettingsTabItem[]
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0', isMobile ? 'w-full' : 'w-40 shrink-0')}>
|
||||
{isMobile ? (
|
||||
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
|
||||
{items.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(
|
||||
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
|
||||
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
) : (
|
||||
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
|
||||
{items.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(
|
||||
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
|
||||
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified settings — settings-7 AccountSettings 1:1 (header + vertical Tabs).
|
||||
* Surface of cards stays Frame (`SettingsCard`), not shadcn Card.
|
||||
* @see https://reui.io/preview/base/settings-7
|
||||
* @see https://reui.io/blocks
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function SettingsPageShell({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
tokenRequired,
|
||||
showBird,
|
||||
}: {
|
||||
activeTab: SettingsTab
|
||||
onTabChange: (tab: SettingsTab) => void
|
||||
activeTab: SettingsSection
|
||||
onTabChange: (tab: SettingsSection) => void
|
||||
tokenRequired: boolean
|
||||
showBird: boolean
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const items = showBird
|
||||
? SETTINGS_TAB_ITEMS
|
||||
: SETTINGS_TAB_ITEMS.filter((tab) => tab.value === 'ui')
|
||||
const resolvedTab = activeTab === 'bird' && showBird ? 'bird' : 'ui'
|
||||
|
||||
return (
|
||||
<BadgeTabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => onTabChange(value as SettingsTab)}
|
||||
items={SETTINGS_TAB_ITEMS.map((tab) => ({
|
||||
value: tab.value,
|
||||
label: tab.label,
|
||||
icon: tab.icon,
|
||||
}))}
|
||||
>
|
||||
<TabsContent value="connection" className="mt-0">
|
||||
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
||||
</TabsContent>
|
||||
<TabsContent value="session" className="mt-0">
|
||||
<SessionSettingsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="appearance" className="mt-0">
|
||||
<AppearanceSettingsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="sections" className="mt-0">
|
||||
<SectionsSettingsTab />
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
|
||||
<header className="px-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">Настройки</h1>
|
||||
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||
Интерфейс UI и глобальные параметры BIRD в одном разделе.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
value={resolvedTab}
|
||||
onValueChange={(value) => onTabChange(value as SettingsSection)}
|
||||
orientation={isMobile ? 'horizontal' : 'vertical'}
|
||||
className="w-full gap-4 lg:gap-8"
|
||||
>
|
||||
<SettingsNavigation
|
||||
isMobile={isMobile}
|
||||
activeValue={resolvedTab}
|
||||
items={items}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<TabsContent value="ui" className="mt-0">
|
||||
<UiSettingsPanel tokenRequired={tokenRequired} />
|
||||
</TabsContent>
|
||||
{showBird ? (
|
||||
<TabsContent value="bird" className="mt-0">
|
||||
<BirdSettingsTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { type ComponentProps, type ReactNode } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from '@evobgp/ui/components/field'
|
||||
import { SettingRow } from '@/components/settings/setting-row'
|
||||
|
||||
/** settings-3 row pattern for tenant/UI settings. */
|
||||
/** Tenant settings row — same SettingRow DNA as UI settings. */
|
||||
export function SettingsSettingField({
|
||||
title,
|
||||
description,
|
||||
@@ -20,38 +12,34 @@ export function SettingsSettingField({
|
||||
last,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
stacked,
|
||||
}: {
|
||||
title: string
|
||||
description: ReactNode
|
||||
description?: ReactNode
|
||||
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
stacked?: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
{badge ? (
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<FieldDescription className="text-xs">{description}</FieldDescription>
|
||||
</div>
|
||||
<FieldContent className={cn('min-w-0 @md/field-group:w-78', contentClassName)}>
|
||||
{children}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
<SettingRow
|
||||
title={title}
|
||||
description={description}
|
||||
last={last}
|
||||
labelFor={labelFor}
|
||||
contentClassName={contentClassName}
|
||||
stacked={stacked}
|
||||
titleAddon={
|
||||
badge ? (
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,49 +1,32 @@
|
||||
import {
|
||||
KeyRoundIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
PaletteIcon,
|
||||
SlidersHorizontalIcon,
|
||||
} from 'lucide-react'
|
||||
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
|
||||
export type SettingsSection = 'ui' | 'bird'
|
||||
|
||||
export type SettingsTabItem = {
|
||||
value: SettingsTab
|
||||
value: SettingsSection
|
||||
label: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
/** Side rail items — settings-7 AccountSettings DNA. */
|
||||
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||
{
|
||||
value: 'connection',
|
||||
label: 'Подключение',
|
||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||
value: 'ui',
|
||||
label: 'Настройки UI',
|
||||
icon: <SettingsIcon aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'session',
|
||||
label: 'Сессия',
|
||||
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'appearance',
|
||||
label: 'Оформление',
|
||||
icon: <PaletteIcon aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'sections',
|
||||
label: 'Разделы',
|
||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||
value: 'bird',
|
||||
label: 'Настройки BIRD',
|
||||
icon: <ServerCogIcon aria-hidden="true" />,
|
||||
},
|
||||
]
|
||||
|
||||
export function parseSettingsTab(value: unknown): SettingsTab {
|
||||
if (
|
||||
value === 'session' ||
|
||||
value === 'appearance' ||
|
||||
value === 'sections'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
return 'connection'
|
||||
const LEGACY_BIRD = new Set(['bird', 'revision', 'runtime-logs', 'additional'])
|
||||
|
||||
/** Maps current and legacy `?tab=` values onto the unified settings rail. */
|
||||
export function parseSettingsSection(value: unknown): SettingsSection {
|
||||
if (typeof value === 'string' && LEGACY_BIRD.has(value)) return 'bird'
|
||||
return 'ui'
|
||||
}
|
||||
|
||||
@@ -39,11 +39,11 @@ export function AnalyticsDashboardSkeleton() {
|
||||
</div>
|
||||
|
||||
<div className={dashboardMainSidebarClassName}>
|
||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-8" />
|
||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-4" />
|
||||
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
|
||||
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className={chartPanelGridClassName}>
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { apiKeyRoleRu } from '@/lib/ui-labels'
|
||||
|
||||
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
|
||||
{ value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
|
||||
{ value: 'editor', label: `${apiKeyRoleRu('editor')} — CRUD без применения` },
|
||||
{ value: 'editor', label: `${apiKeyRoleRu('editor')} — изменение данных без применения` },
|
||||
{ value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
|
||||
{ value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
|
||||
]
|
||||
|
||||
@@ -346,11 +346,17 @@ export function permissionForPath(pathname: string): string | null {
|
||||
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
|
||||
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
|
||||
if (pathname.startsWith('/tenant-settings') || pathname.startsWith('/settings')) {
|
||||
return canOpenSettings() ? null : 'bgp:settings:read'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Unified `/settings` — UI (`settings:read`) or BIRD (`tenant_settings:admin`). */
|
||||
export function canOpenSettings(): boolean {
|
||||
return can('bgp:settings:read') || can('bgp:tenant_settings:admin')
|
||||
}
|
||||
|
||||
const FALLBACK_PATH = '/dashboard'
|
||||
|
||||
/** First path in the sidebar the current user may open. */
|
||||
@@ -365,7 +371,6 @@ export function firstAllowedPath(): string {
|
||||
'/schedule',
|
||||
'/monitoring',
|
||||
'/access',
|
||||
'/tenant-settings',
|
||||
'/settings',
|
||||
]
|
||||
for (const path of candidates) {
|
||||
|
||||
@@ -44,6 +44,7 @@ export const DATA_GRID_MESSAGES_RU = {
|
||||
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
|
||||
...DATA_GRID_TABLE_LAYOUT,
|
||||
dense: true,
|
||||
width: 'auto',
|
||||
}
|
||||
|
||||
export function createTextGlobalFilter<TData extends object>(
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('isReadyCheckOk', () => {
|
||||
|
||||
describe('readyCheckStatusLabel', () => {
|
||||
it('labels memory and failures', () => {
|
||||
expect(readyCheckStatusLabel('memory', true)).toBe('Memory')
|
||||
expect(readyCheckStatusLabel('memory', true)).toBe('В памяти')
|
||||
expect(readyCheckStatusLabel('ok', true)).toBe('В норме')
|
||||
expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно')
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ export function readyCheckStatusLabel(value: ReadyCheckValue, ok: boolean): stri
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
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) return value
|
||||
}
|
||||
|
||||
@@ -61,18 +61,45 @@ const JOB_STATUS_RU: Record<string, string> = {
|
||||
paused: 'Приостановлен',
|
||||
disabled: 'Выключен',
|
||||
archived: 'В архиве',
|
||||
block: 'block',
|
||||
accept: 'accept',
|
||||
block: 'Блокировать',
|
||||
accept: 'Принимать',
|
||||
}
|
||||
|
||||
export function jobStatusRu(status: string): string {
|
||||
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 {
|
||||
if (!state) return '—'
|
||||
if (state === 'Established') return 'Установлена'
|
||||
return state
|
||||
return BGP_SESSION_STATE_RU[state] ?? 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 {
|
||||
|
||||
@@ -30,7 +30,13 @@ export const kpiGridClassName =
|
||||
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
|
||||
|
||||
/** Two-column chart panel row (monitoring / network overview). */
|
||||
export const chartPanelGridClassName = 'grid grid-cols-1 gap-4 @5xl:grid-cols-2'
|
||||
export const chartPanelGridClassName =
|
||||
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
|
||||
|
||||
/** Main + sidebar layout (8+4) used on dashboard. */
|
||||
export const dashboardMainSidebarClassName = 'grid gap-4 xl:grid-cols-12 xl:items-start'
|
||||
/**
|
||||
* Main + sidebar: stack until the dashboard container is wide enough.
|
||||
* Do not nest this inside another 2-col grid — that squeezes modules/timeline.
|
||||
* @see https://reui.io/preview/base/dashboard-1
|
||||
*/
|
||||
export const dashboardMainSidebarClassName =
|
||||
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
|
||||
|
||||
@@ -41,11 +41,11 @@ export function useCreateCommunityMutation() {
|
||||
mutationFn: (body: BgpCommunityCreate) =>
|
||||
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Сообщество создано')
|
||||
toast.success('Community создано')
|
||||
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
||||
},
|
||||
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 }) =>
|
||||
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Сообщество обновлено')
|
||||
toast.success('Community обновлено')
|
||||
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось обновить сообщество'),
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось обновить community'),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export const Route = createFileRoute('/_auth')({
|
||||
if (!raw || !normalizeApiToken(raw)) {
|
||||
throw redirect({
|
||||
to: '/settings',
|
||||
search: { tab: 'connection', reason: 'token-required' },
|
||||
search: { tab: 'ui', reason: 'token-required' },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
||||
import { type SettingsTab } from '@/components/settings/settings-tabs-data'
|
||||
|
||||
const settingsSearchSchema = z.object({
|
||||
tab: z
|
||||
.enum(['connection', 'session', 'appearance', 'sections'])
|
||||
.catch('connection'),
|
||||
reason: z.enum(['token-required']).optional(),
|
||||
})
|
||||
import {
|
||||
parseSettingsSection,
|
||||
type SettingsSection,
|
||||
} from '@/components/settings/settings-tabs-data'
|
||||
import { can } from '@/lib/auth'
|
||||
|
||||
export const Route = createFileRoute('/_auth/_settings/settings')({
|
||||
component: SettingsComponent,
|
||||
validateSearch: (search) => settingsSearchSchema.parse(search),
|
||||
validateSearch: (search: Record<string, unknown>): {
|
||||
tab: SettingsSection
|
||||
reason?: 'token-required'
|
||||
} => ({
|
||||
tab: parseSettingsSection(search.tab),
|
||||
...(search.reason === 'token-required'
|
||||
? { reason: 'token-required' as const }
|
||||
: {}),
|
||||
}),
|
||||
})
|
||||
|
||||
function SettingsComponent() {
|
||||
const navigate = Route.useNavigate()
|
||||
const { tab, reason } = Route.useSearch()
|
||||
const tokenRequired = reason === 'token-required'
|
||||
const showBird = !tokenRequired && can('bgp:tenant_settings:admin')
|
||||
const activeTab: SettingsSection =
|
||||
tokenRequired || (tab === 'bird' && !showBird) ? 'ui' : tab
|
||||
|
||||
function handleTabChange(nextTab: SettingsTab) {
|
||||
function handleTabChange(nextTab: SettingsSection) {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
tab: nextTab,
|
||||
reason: nextTab === 'ui' ? prev.reason : undefined,
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
@@ -33,9 +41,10 @@ function SettingsComponent() {
|
||||
|
||||
return (
|
||||
<SettingsPageShell
|
||||
activeTab={tab}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
tokenRequired={tokenRequired}
|
||||
showBird={showBird}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,358 +1,8 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Save } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
|
||||
import {
|
||||
BIRD_SETTING_KEYS,
|
||||
REVISION_SETTING_KEYS,
|
||||
RUNTIME_LOGS_SETTING_KEYS,
|
||||
buildPayload,
|
||||
partitionSettings,
|
||||
settingsKeys,
|
||||
settingsQueryOptions,
|
||||
type BirdSettingKey,
|
||||
} from '@/queries/settings'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
|
||||
component: TenantSettingsComponent,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
|
||||
? search.tab
|
||||
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
|
||||
}),
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/settings', search: { tab: 'bird' } })
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
|
||||
const RUNTIME_LOGS_ENABLED_ITEMS = [
|
||||
{ value: 'true', label: 'Вкл' },
|
||||
{ value: 'false', label: 'Выкл' },
|
||||
] as const
|
||||
|
||||
const RUNTIME_LOGS_MODE_ITEMS = [
|
||||
{ value: 'truncate', label: 'обнулить (truncate)' },
|
||||
{ value: 'delete', label: 'удалить файл (delete)' },
|
||||
] as const
|
||||
|
||||
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||
bird_router_id: 'Router ID',
|
||||
bird_local_ipv4: 'Локальный IPv4',
|
||||
bird_local_ipv6: 'Локальный IPv6',
|
||||
bird_local_asn: 'Локальный ASN',
|
||||
bird_bgp_source_ipv4: 'BGP source IPv4',
|
||||
bird_bgp_source_ipv6: 'BGP source IPv6',
|
||||
peer_discovery_enabled: 'Автообнаружение пиров',
|
||||
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
|
||||
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
|
||||
peer_discovery_require_external: 'Только external ASN',
|
||||
}
|
||||
|
||||
const BIRD_BOOL_ITEMS = [
|
||||
{ value: 'true', label: 'Вкл' },
|
||||
{ value: 'false', label: 'Выкл' },
|
||||
] as const
|
||||
|
||||
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
||||
peer_discovery_enabled:
|
||||
'Dynamic neighbor range в BIRD (карантин import/export none). Требует CIDR.',
|
||||
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
|
||||
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
|
||||
peer_discovery_require_external: 'neighbor range … external (любой чужой ASN)',
|
||||
}
|
||||
|
||||
function TenantSettingsComponent() {
|
||||
const search = useSearch({ from: '/_auth/_settings/tenant-settings' })
|
||||
const navigate = Route.useNavigate()
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const qc = useQueryClient()
|
||||
|
||||
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
||||
|
||||
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
||||
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
||||
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (partitioned) {
|
||||
setBirdForm({ ...partitioned.bird })
|
||||
setRevisionForm({ ...partitioned.revision })
|
||||
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsQ.data])
|
||||
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
||||
apiMutate('/v1/settings', 'PATCH', payload),
|
||||
onSuccess: () => {
|
||||
toast.success('Параметры сохранены')
|
||||
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
function saveBird() {
|
||||
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
||||
}
|
||||
function saveRevision() {
|
||||
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
||||
}
|
||||
function saveRuntimeLogs() {
|
||||
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<BadgeTabs
|
||||
value={search.tab}
|
||||
onValueChange={(tab) =>
|
||||
navigate({
|
||||
search: { tab: tab as 'bird' | 'revision' | 'runtime-logs' | 'additional' },
|
||||
})
|
||||
}
|
||||
items={[
|
||||
{ value: 'bird', label: 'BIRD' },
|
||||
{ value: 'revision', label: 'Ревизии' },
|
||||
{ value: 'runtime-logs', label: 'Файловые логи' },
|
||||
{ value: 'additional', label: 'Дополнительно' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="bird" className="mt-0">
|
||||
<PanelCard
|
||||
title="BIRD control plane"
|
||||
description={
|
||||
<>
|
||||
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
|
||||
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
|
||||
</>
|
||||
}
|
||||
contentClassName="space-y-4 py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-64" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<div className="flex flex-col">
|
||||
{BIRD_SETTING_KEYS.map((key, index) => (
|
||||
<SettingsSettingField
|
||||
key={key}
|
||||
title={BIRD_LABELS[key]}
|
||||
description={BIRD_HINTS[key] ?? key}
|
||||
labelFor={key}
|
||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||
>
|
||||
{key === 'peer_discovery_enabled' ||
|
||||
key === 'peer_discovery_require_external' ? (
|
||||
<SelectField
|
||||
id={key}
|
||||
items={[...BIRD_BOOL_ITEMS]}
|
||||
value={birdForm[key] || 'false'}
|
||||
onValueChange={(v) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||
}
|
||||
placeholder="Выкл"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={key}
|
||||
value={birdForm[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||
}
|
||||
placeholder={BIRD_LABELS[key]}
|
||||
/>
|
||||
)}
|
||||
</SettingsSettingField>
|
||||
))}
|
||||
<div className="px-4 py-4">
|
||||
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</PanelCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="revision" className="mt-0">
|
||||
<PanelCard
|
||||
title="Ревизии"
|
||||
description="Время хранения ревизий в БД"
|
||||
contentClassName="space-y-4 py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<div className="flex max-w-sm flex-col gap-1.5">
|
||||
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label>
|
||||
<Input
|
||||
id="revision_retention_minutes"
|
||||
type="number"
|
||||
value={revisionForm.revision_retention_minutes ?? ''}
|
||||
onChange={(e) =>
|
||||
setRevisionForm((s) => ({
|
||||
...s,
|
||||
revision_retention_minutes: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
revision_retention_minutes
|
||||
</p>
|
||||
<LoadingButton
|
||||
className="mt-2 w-fit"
|
||||
onClick={saveRevision}
|
||||
loading={patchMutation.isPending}
|
||||
>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</PanelCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runtime-logs" className="mt-0">
|
||||
<PanelCard
|
||||
title="Файловые логи"
|
||||
description="Автоматическая очистка логов"
|
||||
contentClassName="space-y-4 py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-48" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SelectField
|
||||
label="Авто-очистка включена"
|
||||
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||
placeholder="Выберите"
|
||||
description="runtime_logs_auto_enabled"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_enabled: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
|
||||
<Input
|
||||
id="runtime_logs_max_file_mb"
|
||||
type="number"
|
||||
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_max_file_mb: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
runtime_logs_max_file_mb
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label>
|
||||
<Input
|
||||
id="runtime_logs_auto_schedule"
|
||||
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_schedule: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
runtime_logs_auto_schedule
|
||||
</p>
|
||||
</div>
|
||||
<SelectField
|
||||
label="Режим очистки"
|
||||
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||
placeholder="Выберите"
|
||||
description="runtime_logs_auto_mode"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_mode: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</PanelCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="additional" className="mt-0">
|
||||
<FrameDataGrid
|
||||
title="Дополнительные параметры"
|
||||
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned?.additional ?? []}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
empty={(partitioned?.additional ?? []).length === 0}
|
||||
emptyTitle="Нет дополнительных параметров"
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<SettingsKvGrid
|
||||
items={items}
|
||||
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</FrameDataGrid>
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
|
||||
import { KeyRound, MonitorIcon, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@evobgp/ui/components/item'
|
||||
|
||||
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import { SettingsCard } from '@/components/settings/settings-card'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { sessionCanManageApiKeys } from '@/lib/auth'
|
||||
import { authSessionQueryOptions } from '@/queries/auth'
|
||||
@@ -38,16 +46,18 @@ function AccessComponent() {
|
||||
const kpiItems: KpiStatItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'keys-total',
|
||||
label: 'Всего ключей',
|
||||
value: keys.length,
|
||||
icon: <KeyRound className="size-4" />,
|
||||
footer: (
|
||||
<Badge variant="primary-light" size="sm">
|
||||
в tenant
|
||||
в арендаторе
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'keys-active',
|
||||
label: 'Активных',
|
||||
value: activeCount,
|
||||
icon: <ShieldCheck className="size-4" />,
|
||||
@@ -58,6 +68,7 @@ function AccessComponent() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'keys-revoked',
|
||||
label: 'Отозванных',
|
||||
value: revokedCount,
|
||||
icon: <ShieldOff className="size-4" />,
|
||||
@@ -78,25 +89,25 @@ function AccessComponent() {
|
||||
}
|
||||
|
||||
const sessionKindLabel =
|
||||
session?.kind === 'jwt' ? 'Portal JWT' : session?.kind === 'apikey' ? 'API-ключ' : null
|
||||
session?.kind === 'jwt' ? 'JWT портала' : session?.kind === 'apikey' ? 'API-ключ' : null
|
||||
|
||||
const sessionAccessLabel = (() => {
|
||||
if (!session) return null
|
||||
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'
|
||||
return session.permissions?.length
|
||||
? session.permissions.slice(0, 3).join(', ')
|
||||
: 'без access:admin'
|
||||
: 'без права access:admin'
|
||||
}
|
||||
return session.role || '—'
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Права доступа"
|
||||
description="API-ключи control plane и текущая сессия Bearer-токена."
|
||||
description="API-ключи плоскости управления и текущая сессия."
|
||||
actions={
|
||||
canManageKeys ? (
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
@@ -108,47 +119,55 @@ function AccessComponent() {
|
||||
/>
|
||||
|
||||
{session ? (
|
||||
<PanelCard
|
||||
<SettingsCard
|
||||
title="Текущая сессия"
|
||||
description="Tenant и права текущего Bearer (API-ключ или portal JWT)."
|
||||
contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
|
||||
description="Арендатор и права текущей сессии (API-ключ или JWT портала)."
|
||||
>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Tenant</p>
|
||||
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Доступ</p>
|
||||
<p className="font-mono text-xs">{sessionAccessLabel}</p>
|
||||
</div>
|
||||
{sessionKindLabel ? (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Тип</p>
|
||||
<p className="font-mono text-xs">{sessionKindLabel}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{session.email ? (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Email</p>
|
||||
<p className="break-all text-xs">{session.email}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</PanelCard>
|
||||
<ItemGroup className="gap-0">
|
||||
<Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
|
||||
<ItemMedia className="self-start">
|
||||
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||
<MonitorIcon aria-hidden="true" />
|
||||
</Item>
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0 gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<ItemTitle className="leading-5">Этот браузер</ItemTitle>
|
||||
{sessionKindLabel ? (
|
||||
<Badge variant="info-light" size="sm">
|
||||
{sessionKindLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
|
||||
<span className="break-all">
|
||||
Арендатор:{' '}
|
||||
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
||||
</span>
|
||||
<span className="break-words">Доступ: {sessionAccessLabel}</span>
|
||||
{session.email ? <span className="break-all">Email: {session.email}</span> : null}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
||||
Не удалось определить сессию. Укажите токен в{' '}
|
||||
<Link
|
||||
to="/settings"
|
||||
search={{ tab: 'connection' }}
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
настройках
|
||||
</Link>{' '}
|
||||
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
||||
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
|
||||
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
|
||||
) : null}
|
||||
</PanelCard>
|
||||
<SettingsCard title="Текущая сессия">
|
||||
<p className="text-muted-foreground px-5 py-4 text-sm">
|
||||
Не удалось определить сессию. Укажите токен в{' '}
|
||||
<Link
|
||||
to="/settings"
|
||||
search={{ tab: 'ui' }}
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
настройках
|
||||
</Link>{' '}
|
||||
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
||||
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
|
||||
<span className="text-destructive mt-2 block">{sessionQuery.error.message}</span>
|
||||
) : null}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{canManageKeys ? (
|
||||
@@ -167,11 +186,13 @@ function AccessComponent() {
|
||||
/>
|
||||
</>
|
||||
) : session ? (
|
||||
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
||||
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
|
||||
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
|
||||
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
|
||||
</PanelCard>
|
||||
<SettingsCard title="API-ключи">
|
||||
<p className="text-muted-foreground px-5 py-4 text-sm break-words">
|
||||
Управление API-ключами доступно роли <strong>оператор</strong> (API-ключ) или JWT портала
|
||||
с правом администратора / <code className="text-xs">bgp:access:admin</code>.
|
||||
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
|
||||
</p>
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Button } from '@evobgp/ui/components/button'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
|
||||
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
||||
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
||||
@@ -15,7 +14,7 @@ import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-o
|
||||
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
|
||||
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
|
||||
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
|
||||
import { OpsDashboard } from '@/components/reui-kit'
|
||||
import { FrameDataGrid, OpsDashboard } from '@/components/reui-kit'
|
||||
import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface'
|
||||
import {
|
||||
moduleNameById,
|
||||
@@ -37,7 +36,9 @@ function parseShowQuickActions(value: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard — OpsDashboard kit (KPI → QuickActions → charts → queue).
|
||||
* Dashboard — OpsDashboard kit (KPI → QuickActions → modules → activity/health → queue).
|
||||
* Charts slot is a vertical stack: modules stay full-width; activity shares a row
|
||||
* with BGP widgets only at @5xl (container), never nested 8+4 inside a 2-col parent.
|
||||
* @see https://reui.io/preview/base/dashboard-1
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
@@ -99,51 +100,47 @@ function DashboardComponent() {
|
||||
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
|
||||
charts={
|
||||
<>
|
||||
<div className={dashboardMainSidebarClassName}>
|
||||
<div className="xl:col-span-8">
|
||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
||||
</div>
|
||||
<div className="xl:col-span-4">
|
||||
<DashboardActivityTimeline
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
||||
</div>
|
||||
<div className={chartPanelGridClassName}>
|
||||
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
|
||||
<div className={dashboardMainSidebarClassName}>
|
||||
<DashboardActivityTimeline
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
/>
|
||||
<div className="grid min-w-0 items-start gap-4">
|
||||
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
queueTitle="Недавняя активность"
|
||||
queueDescription="Задачи и ревизии плоскости управления"
|
||||
queueTitle="Задачи и ревизии"
|
||||
queueDescription="Последние фоновые операции и история конфигураций"
|
||||
queue={
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<DashboardFramePanel
|
||||
<div className={chartPanelGridClassName}>
|
||||
<FrameDataGrid
|
||||
title="Недавние задачи"
|
||||
description="Последние фоновые операции"
|
||||
className="h-full"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-4 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
|
||||
<DashboardRecentJobsGrid jobs={jobs.slice(0, 8)} nameById={nameById} isLoading={refreshing} />
|
||||
)}
|
||||
</DashboardFramePanel>
|
||||
<DashboardFramePanel
|
||||
</FrameDataGrid>
|
||||
<FrameDataGrid
|
||||
title="Последние ревизии"
|
||||
description="История конфигураций"
|
||||
className="h-full"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-4 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
||||
)}
|
||||
</DashboardFramePanel>
|
||||
</FrameDataGrid>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -45,7 +45,7 @@ function DirectoriesComponent() {
|
||||
|
||||
const items: KpiStatItem[] = [
|
||||
{
|
||||
label: 'Сообщества BGP',
|
||||
label: 'BGP community',
|
||||
value: communities.length,
|
||||
icon: <Tags className="size-4" />,
|
||||
footer: (
|
||||
@@ -70,7 +70,7 @@ function DirectoriesComponent() {
|
||||
icon: <BookText className="size-4" />,
|
||||
footer: (
|
||||
<Badge variant="outline" size="sm">
|
||||
все модули tenant
|
||||
все модули арендатора
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -114,7 +114,7 @@ function DirectoriesComponent() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Справочники"
|
||||
description="Сообщества BGP и DoH-профили для резолвинга доменов"
|
||||
description="BGP community и DoH-профили для резолвинга доменов"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -136,13 +136,13 @@ function DirectoriesComponent() {
|
||||
<BadgeTabs
|
||||
defaultValue="communities"
|
||||
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' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="communities" className="mt-0">
|
||||
<FrameDataGrid
|
||||
title="Сообщества BGP"
|
||||
title="BGP community"
|
||||
description="Теги для префиксов в фильтрах BIRD"
|
||||
actions={addCommunityButton}
|
||||
>
|
||||
@@ -152,7 +152,7 @@ function DirectoriesComponent() {
|
||||
isError={communitiesQ.isError}
|
||||
error={communitiesQ.error}
|
||||
empty={communities.length === 0}
|
||||
emptyTitle="Нет сообществ"
|
||||
emptyTitle="Нет community"
|
||||
emptyAction={addCommunityButton}
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => communitiesQ.refetch()}
|
||||
|
||||
@@ -127,7 +127,7 @@ function MonitoringComponent() {
|
||||
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
|
||||
<FrameDataGrid
|
||||
title="Доступность и готовность"
|
||||
description="GET /v1/health · GET /v1/ready"
|
||||
description="Проверки живучести и готовности (/v1/health, /v1/ready)"
|
||||
>
|
||||
<QueryState
|
||||
data={readyQ.data}
|
||||
@@ -148,7 +148,7 @@ function MonitoringComponent() {
|
||||
BGP на API-хосте
|
||||
</span>
|
||||
}
|
||||
description="GET /v1/bird/status"
|
||||
description="Статус BIRD на хосте API"
|
||||
className="h-full"
|
||||
contentClassName="px-5 py-4"
|
||||
>
|
||||
@@ -169,7 +169,7 @@ function MonitoringComponent() {
|
||||
<div className="flex flex-col gap-2 md:gap-3">
|
||||
<SegmentedProgressCard
|
||||
title="Задачи"
|
||||
description="Последние 100 задач · GET /v1/jobs"
|
||||
description="Последние 100 задач"
|
||||
primary={{
|
||||
value: jobs.filter((j) => j.status === 'running' || j.status === 'queued').length,
|
||||
label: 'Активных',
|
||||
@@ -230,11 +230,10 @@ function MonitoringComponent() {
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
|
||||
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
|
||||
и <code className="text-xs">jobs</code> в проверках.
|
||||
<code className="text-xs">PostgreSQL</code>, затем хранилище и очередь задач в проверках.
|
||||
</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>, затем состояние пиров в Сети.
|
||||
</li>
|
||||
<li>
|
||||
@@ -250,7 +249,7 @@ function MonitoringComponent() {
|
||||
<IllustratedEmptyState
|
||||
icon={Database}
|
||||
title="PostgreSQL"
|
||||
description="Статус соединения и пул отображаются в readiness-проверке на вкладке «Система» (check postgres)."
|
||||
description="Состояние соединения и пула видно в проверке готовности на вкладке «Система»."
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -258,7 +257,7 @@ function MonitoringComponent() {
|
||||
<IllustratedEmptyState
|
||||
icon={FileText}
|
||||
title="Файловые логи"
|
||||
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в tenant-settings."
|
||||
description="Логи API и конвейера задаются переменной EVOBGP_LOG_* и управляются в Настройках BIRD."
|
||||
/>
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+36
-23
@@ -2,29 +2,38 @@
|
||||
|
||||
## 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 для локальной отладки.
|
||||
|
||||
| Было | Стало |
|
||||
|------|--------|
|
||||
| 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` |
|
||||
BuildKit кэширует `/go/pkg/mod`, `~/.cache/go-build` и pnpm store через `RUN --mount=type=cache`. На CI mounts живут, пока named builder `evobgp` не удаляют (`cleanup: false`).
|
||||
|
||||
Кэш registry (переменные bake):
|
||||
### Слои и runtime
|
||||
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache` — **запись** только из target `go-build-all`
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache` — **запись** только из target `web-build`
|
||||
| Образ | Runtime base | Заметка |
|
||||
|-------|----------------|---------|
|
||||
| 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 с **Docker Hub** (`docker.io/library/…`; distroless — `gcr.io`). Только `linux/amd64`; skip если тег уже в Gitea. Неуспешный copy не валит CD — bake берёт Docker Hub FROM для этой базы.
|
||||
|
||||
`pull = false` в bake: не перекачивать FROM, если слой уже в builder. Не запускайте `docker system prune -a` на runner.
|
||||
|
||||
Параллельный `cache-to` в один ref ломает manifest (`content descriptor … not found`).
|
||||
|
||||
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evobgp-buildcache:*` в registry и пересоберите.
|
||||
|
||||
## Локальная сборка
|
||||
|
||||
@@ -37,20 +46,24 @@ export IMAGE_TAG=latest
|
||||
export SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
export VERSION=dev
|
||||
export BUILD_TIME=
|
||||
sh write-bake-override.sh
|
||||
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 -f docker-bake.override.hcl web-images
|
||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl go-images
|
||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl web-images
|
||||
```
|
||||
|
||||
В `docker-bake.hcl`: `context = "../.."` (корень репо), `dockerfile = "deploy/docker/…"` (путь от корня репо). **CI:** `write-bake-override.sh` + `--allow=fs.read=$GITHUB_WORKSPACE` в `.gitea/workflows/ci.yaml`.
|
||||
Проверка манифеста без сборки:
|
||||
|
||||
Один образ (legacy):
|
||||
```bash
|
||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl --print default
|
||||
```
|
||||
|
||||
В `docker-bake.hcl`: `context = "../.."` резолвится **от cwd** (каталог `deploy/docker/`). `dockerfile` — путь от этого context (корень репо). Из корня репо не вызывать bake с `-f deploy/docker/docker-bake.hcl`: получится `lstat ../../deploy`. Можно задать `BUILDX_BAKE_FILE_RELATIVE_PATHS=1`.
|
||||
|
||||
Один образ (legacy, target `build-all`):
|
||||
|
||||
```bash
|
||||
docker build -f deploy/docker/gobinary/Dockerfile \
|
||||
--target build-all \
|
||||
--build-arg BIN=evobgp-api \
|
||||
-t evobgp-api:local .
|
||||
-t evobgp-build-all:local .
|
||||
```
|
||||
|
||||
Runtime-образы в bake ожидают stage `build-all` (через bake contexts), не отдельный `--build-arg BIN` на старый target `build`.
|
||||
Runtime-образы в bake ожидают stage `build-all` (через bake contexts).
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# BIRD из репозитория Ubuntu (Noble 24.04 LTS) — актуальнее пакета Debian bookworm.
|
||||
# Сборка из корня репозитория: docker build -f deploy/docker/bird2/Dockerfile .
|
||||
# Зеркало ECR Public вместо прямого pull с Docker Hub.
|
||||
FROM public.ecr.aws/docker/library/ubuntu:noble
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bird2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Stage bird2-base общий с evobgp-agent (одинаковые слои на speaker-VPS).
|
||||
ARG BASE_UBUNTU=docker.io/library/ubuntu:noble
|
||||
|
||||
FROM ${BASE_UBUNTU} AS bird2-base
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bird2 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM bird2-base AS bird2
|
||||
COPY deploy/bird/bird.conf /etc/bird/bird.conf
|
||||
RUN mkdir -p /etc/bird/bird.d
|
||||
EXPOSE 179
|
||||
|
||||
+108
-25
@@ -1,7 +1,7 @@
|
||||
# Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl).
|
||||
# context = "../.." — корень репозитория (относительно этого файла).
|
||||
# dockerfile — путь от корня репозитория (относительно context).
|
||||
# Переменные: REGISTRY, IMAGE_TAG, CACHE_REF_GO, CACHE_REF_WEB
|
||||
# context = "../.." — корень репо. Bake резолвит context от cwd (не от HCL),
|
||||
# поэтому вызов из deploy/docker/ либо BUILDX_BAKE_FILE_RELATIVE_PATHS=1.
|
||||
# Переменные Bake читаются из окружения (см. docs.docker.com/build/bake/variables/).
|
||||
|
||||
variable "REGISTRY" {
|
||||
default = "git.shx.one/evobgp"
|
||||
@@ -35,14 +35,40 @@ variable "CACHE_REF_WEB" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "CACHE_REF_BIRDC" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "BASE_GOLANG" {
|
||||
default = "docker.io/library/golang:1.24-alpine"
|
||||
}
|
||||
|
||||
variable "BASE_DEBIAN" {
|
||||
default = "docker.io/library/debian:bookworm-slim"
|
||||
}
|
||||
|
||||
variable "BASE_DISTROLESS" {
|
||||
default = "gcr.io/distroless/static-debian12:nonroot"
|
||||
}
|
||||
|
||||
variable "BASE_NODE" {
|
||||
default = "docker.io/library/node:22-alpine"
|
||||
}
|
||||
|
||||
variable "BASE_NGINX" {
|
||||
default = "docker.io/library/nginx:1.27-alpine"
|
||||
}
|
||||
|
||||
variable "BASE_UBUNTU" {
|
||||
default = "docker.io/library/ubuntu:noble"
|
||||
}
|
||||
|
||||
function "go-cache-from" {
|
||||
params = []
|
||||
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : []
|
||||
}
|
||||
|
||||
# Экспорт кэша — только go-build-all / web-build (один writer на ref).
|
||||
# Несколько target с cache-to в один ref и mode=max дают гонку в registry
|
||||
# (content descriptor not found при параллельном bake).
|
||||
# Экспорт кэша — один writer на ref (параллельный cache-to в один ref ломает manifest).
|
||||
function "go-cache-to-export" {
|
||||
params = []
|
||||
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO},mode=max"] : []
|
||||
@@ -58,6 +84,16 @@ function "web-cache-to-export" {
|
||||
result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB},mode=max"] : []
|
||||
}
|
||||
|
||||
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" {
|
||||
targets = ["go-images", "web-images", "evobgp-bird2"]
|
||||
}
|
||||
@@ -75,26 +111,38 @@ group "go-images" {
|
||||
]
|
||||
}
|
||||
|
||||
# web-deps / web-build — только зависимости (contexts), без tags; при --push в группе не указывать.
|
||||
group "web-images" {
|
||||
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-образы ---
|
||||
|
||||
target "go-deps" {
|
||||
inherits = ["_common", "_go-bases"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||
target = "deps"
|
||||
platforms = ["linux/amd64"]
|
||||
cache-from = go-cache-from()
|
||||
}
|
||||
|
||||
target "go-build-all" {
|
||||
inherits = ["_common", "_go-bases"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||
target = "build-all"
|
||||
platforms = ["linux/amd64"]
|
||||
args = {
|
||||
VERSION = VERSION
|
||||
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
||||
@@ -108,18 +156,19 @@ target "go-build-all" {
|
||||
}
|
||||
|
||||
target "go-birdc" {
|
||||
inherits = ["_common", "_go-bases"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||
target = "birdc"
|
||||
platforms = ["linux/amd64"]
|
||||
cache-from = go-cache-from()
|
||||
cache-from = birdc-cache-from()
|
||||
cache-to = birdc-cache-to-export()
|
||||
}
|
||||
|
||||
target "_go-runtime" {
|
||||
inherits = ["_common", "_go-bases"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||
target = "runtime"
|
||||
platforms = ["linux/amd64"]
|
||||
contexts = {
|
||||
build-all = "target:go-build-all"
|
||||
}
|
||||
@@ -127,15 +176,15 @@ target "_go-runtime" {
|
||||
}
|
||||
|
||||
target "_go-runtime-birdc" {
|
||||
inherits = ["_common", "_go-bases"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||
target = "runtime-birdc"
|
||||
platforms = ["linux/amd64"]
|
||||
contexts = {
|
||||
build-all = "target:go-build-all"
|
||||
birdc = "target:go-birdc"
|
||||
}
|
||||
cache-from = go-cache-from()
|
||||
cache-from = concat(go-cache-from(), birdc-cache-from())
|
||||
}
|
||||
|
||||
function "image-tags" {
|
||||
@@ -198,32 +247,51 @@ target "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" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evobgp-agent/Dockerfile"
|
||||
platforms = ["linux/amd64"]
|
||||
contexts = {
|
||||
build-all = "target:go-build-all"
|
||||
build-all = "target:go-build-all"
|
||||
bird2-base = "target:evobgp-bird2-base"
|
||||
}
|
||||
cache-from = go-cache-from()
|
||||
tags = image-tags("evobgp-agent")
|
||||
}
|
||||
|
||||
# --- Web: npm ci (кэш) → build → nginx ---
|
||||
# --- Web ---
|
||||
|
||||
target "web-deps" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||
target = "deps"
|
||||
platforms = ["linux/amd64"]
|
||||
args = {
|
||||
BASE_NODE = BASE_NODE
|
||||
BASE_NGINX = BASE_NGINX
|
||||
}
|
||||
cache-from = web-cache-from()
|
||||
}
|
||||
|
||||
target "web-build" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||
target = "build"
|
||||
platforms = ["linux/amd64"]
|
||||
args = {
|
||||
BASE_NODE = BASE_NODE
|
||||
BASE_NGINX = BASE_NGINX
|
||||
}
|
||||
contexts = {
|
||||
deps = "target:web-deps"
|
||||
}
|
||||
@@ -232,34 +300,49 @@ target "web-build" {
|
||||
}
|
||||
|
||||
target "evobgp-web" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||
target = "web"
|
||||
platforms = ["linux/amd64"]
|
||||
contexts = {
|
||||
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()
|
||||
tags = image-tags("evobgp-web")
|
||||
}
|
||||
|
||||
target "evobgp-web-all" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||
target = "web"
|
||||
platforms = ["linux/amd64"]
|
||||
contexts = {
|
||||
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()
|
||||
tags = image-tags("evobgp-web-all")
|
||||
}
|
||||
|
||||
target "evobgp-bird2" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/bird2/Dockerfile"
|
||||
platforms = ["linux/amd64"]
|
||||
tags = image-tags("evobgp-bird2")
|
||||
target = "bird2"
|
||||
args = {
|
||||
BASE_UBUNTU = BASE_UBUNTU
|
||||
}
|
||||
contexts = {
|
||||
bird2-base = "target:evobgp-bird2-base"
|
||||
}
|
||||
tags = image-tags("evobgp-bird2")
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Агент: бинарь из общего build-all (docker-bake.hcl → contexts.build-all), bird2 из apt.
|
||||
FROM public.ecr.aws/docker/library/ubuntu:noble
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bird2 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Агент: бинарь из bake context build-all, runtime = тот же bird2-base, что evobgp-bird2.
|
||||
FROM bird2-base
|
||||
ARG BIN=evobgp-agent
|
||||
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp-agent
|
||||
WORKDIR /etc/bird
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# React + Vite статическая панель EvoBGP + nginx.
|
||||
# Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build).
|
||||
# Сборка ведётся из корня репозитория (context = "../.." в docker-bake.hcl).
|
||||
ARG BASE_NODE=docker.io/library/node:22-alpine
|
||||
ARG BASE_NGINX=docker.io/library/nginx:1.27-alpine
|
||||
|
||||
FROM public.ecr.aws/docker/library/node:22-alpine AS deps
|
||||
FROM ${BASE_NODE} AS deps
|
||||
WORKDIR /repo
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
@@ -16,11 +17,13 @@ FROM deps AS build
|
||||
COPY tsconfig.base.json ./
|
||||
COPY apps/web/ ./apps/web/
|
||||
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
|
||||
COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf
|
||||
RUN sed -e "s/evobgp-api/${EVOBGP_UPSTREAM}/g" /tmp/nginx-default.conf > /etc/nginx/conf.d/default.conf \
|
||||
&& rm -f /tmp/nginx-default.conf
|
||||
&& 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
|
||||
|
||||
@@ -4,6 +4,7 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
|
||||
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Универсальная сборка бинарей 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
|
||||
FROM public.ecr.aws/docker/library/golang:1.24-bookworm AS deps
|
||||
ARG BASE_GOLANG=docker.io/library/golang:1.24-alpine
|
||||
ARG BASE_DEBIAN=docker.io/library/debian:bookworm-slim
|
||||
ARG BASE_DISTROLESS=gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
FROM ${BASE_GOLANG} AS deps
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
go mod download
|
||||
|
||||
FROM deps AS build-all
|
||||
COPY . .
|
||||
ARG VERSION=dev
|
||||
ARG GIT_SHA=unknown
|
||||
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 \
|
||||
set -eux; \
|
||||
mkdir -p /out; \
|
||||
@@ -29,12 +33,12 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
|
||||
# Один бинарь (локальная сборка); в CI — build-all + runtime.
|
||||
FROM deps AS build
|
||||
COPY . .
|
||||
ARG BIN=evobgp-api
|
||||
ARG VERSION=dev
|
||||
ARG GIT_SHA=unknown
|
||||
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 \
|
||||
CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w \
|
||||
@@ -43,29 +47,33 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
|
||||
-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
|
||||
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 \
|
||||
&& rm -f /tmp/bird-from-source.sh
|
||||
|
||||
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS runtime-base
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM runtime-base AS runtime
|
||||
# scheduler / ingest / render / deploy / node — static Go, без shell.
|
||||
FROM ${BASE_DISTROLESS} AS runtime
|
||||
ARG BIN=evobgp-api
|
||||
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
|
||||
ENTRYPOINT ["/usr/local/bin/evobgp"]
|
||||
|
||||
FROM runtime AS runtime-birdc
|
||||
# birdc: динамическая линковка readline + ncurses (debian bookworm).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libreadline8 libncurses6 \
|
||||
# api / all — birdc (readline + ncurses). Только клиент birdc, без демона bird.
|
||||
FROM ${BASE_DEBIAN} AS runtime-birdc
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libreadline8 libncurses6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
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"]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env sh
|
||||
# Copy-by-digest зеркало базовых образов в Gitea Container Registry (без rebuild).
|
||||
# Требует: docker login в REGISTRY host, docker buildx.
|
||||
# REGISTRY=git.shx.one/<owner> (без суффикса /evobgp-buildcache)
|
||||
#
|
||||
# Источник library-образов — Docker Hub (docker.io), не public.ecr.aws (429 Too Many Requests).
|
||||
# Если тег уже есть в Gitea — skip (без повторного pull).
|
||||
# Копируем только linux/amd64 — bake platforms совпадает, multi-arch индекс не нужен.
|
||||
# Шаг CD не должен падать: неуспешный copy оставляет bake на Docker Hub FROM для этой базы.
|
||||
set -eu
|
||||
REGISTRY="${REGISTRY:?REGISTRY required (git.shx.one/<owner>)}"
|
||||
CACHE_REPO="${REGISTRY}/evobgp-buildcache"
|
||||
ENV_FILE="${MIRROR_ENV_FILE:-}"
|
||||
PLATFORM="${MIRROR_PLATFORM:-linux/amd64}"
|
||||
RETRIES="${MIRROR_RETRIES:-4}"
|
||||
|
||||
tmp="$(mktemp)"
|
||||
cleanup() { rm -f "$tmp"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
dest_exists() {
|
||||
docker buildx imagetools inspect "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
copy_retry() {
|
||||
src="$1"
|
||||
dest="$2"
|
||||
n=0
|
||||
while [ "$n" -lt "$RETRIES" ]; do
|
||||
n=$((n + 1))
|
||||
if docker buildx imagetools create --platform "$PLATFORM" --tag "$dest" "$src"; then
|
||||
return 0
|
||||
fi
|
||||
delay=$((n * 25))
|
||||
echo "mirror retry ${n}/${RETRIES}, sleep ${delay}s: ${dest}"
|
||||
sleep "$delay"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
mirror() {
|
||||
src="$1"
|
||||
tag="$2"
|
||||
var="$3"
|
||||
dest="${CACHE_REPO}:${tag}"
|
||||
if dest_exists "$dest"; then
|
||||
echo "skip (already in registry): ${dest}"
|
||||
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
|
||||
return 0
|
||||
fi
|
||||
echo "mirror ${src} -> ${dest} (${PLATFORM})"
|
||||
if copy_retry "$src" "$dest"; then
|
||||
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
|
||||
return 0
|
||||
fi
|
||||
echo "warn: ${dest} not mirrored — bake uses Docker Hub FROM for ${var}"
|
||||
return 0
|
||||
}
|
||||
|
||||
mirror "docker.io/library/golang:1.24-alpine" "base-golang-1.24-alpine" BASE_GOLANG
|
||||
sleep 8
|
||||
mirror "docker.io/library/debian:bookworm-slim" "base-debian-bookworm-slim" BASE_DEBIAN
|
||||
sleep 8
|
||||
mirror "docker.io/library/node:22-alpine" "base-node-22-alpine" BASE_NODE
|
||||
sleep 8
|
||||
mirror "docker.io/library/nginx:1.27-alpine" "base-nginx-1.27-alpine" BASE_NGINX
|
||||
sleep 8
|
||||
mirror "docker.io/library/ubuntu:noble" "base-ubuntu-noble" BASE_UBUNTU
|
||||
sleep 8
|
||||
mirror "gcr.io/distroless/static-debian12:nonroot" "base-distroless-static-debian12-nonroot" BASE_DISTROLESS
|
||||
|
||||
echo "----- mirrored BASE_* -----"
|
||||
cat "$tmp"
|
||||
if [ -n "$ENV_FILE" ]; then
|
||||
env_dir="$(dirname "$ENV_FILE")"
|
||||
if [ -d "$env_dir" ]; then
|
||||
cp "$tmp" "$ENV_FILE"
|
||||
echo "wrote ${ENV_FILE}"
|
||||
else
|
||||
echo "warn: MIRROR_ENV_FILE dir missing (${env_dir}) — skip write"
|
||||
fi
|
||||
fi
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/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
|
||||
OUT="${1:-docker-bake.override.hcl}"
|
||||
REGISTRY="${REGISTRY:?REGISTRY required}"
|
||||
@@ -10,30 +11,29 @@ VERSION="${VERSION:-dev}"
|
||||
BUILD_TIME="${BUILD_TIME:-}"
|
||||
CACHE_REF_GO="${CACHE_REF_GO:-}"
|
||||
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
|
||||
# Generated by deploy/docker/write-bake-override.sh — do not commit.
|
||||
variable "REGISTRY" {
|
||||
default = "${REGISTRY}"
|
||||
}
|
||||
variable "IMAGE_TAG" {
|
||||
default = "${IMAGE_TAG}"
|
||||
}
|
||||
variable "SHORT_SHA" {
|
||||
default = "${SHORT_SHA}"
|
||||
}
|
||||
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}"
|
||||
}
|
||||
variable "REGISTRY" { default = "${REGISTRY}" }
|
||||
variable "IMAGE_TAG" { default = "${IMAGE_TAG}" }
|
||||
variable "SHORT_SHA" { default = "${SHORT_SHA}" }
|
||||
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}" }
|
||||
variable "CACHE_REF_BIRDC" { default = "${CACHE_REF_BIRDC}" }
|
||||
EOF
|
||||
# BASE_* — только если заданы (иначе bake берёт default из docker-bake.hcl).
|
||||
if [ -n "$BASE_GOLANG" ]; then printf 'variable "BASE_GOLANG" { default = "%s" }\n' "$BASE_GOLANG" >>"$OUT"; fi
|
||||
if [ -n "$BASE_DEBIAN" ]; then printf 'variable "BASE_DEBIAN" { default = "%s" }\n' "$BASE_DEBIAN" >>"$OUT"; fi
|
||||
if [ -n "$BASE_DISTROLESS" ]; then printf 'variable "BASE_DISTROLESS" { default = "%s" }\n' "$BASE_DISTROLESS" >>"$OUT"; fi
|
||||
if [ -n "$BASE_NODE" ]; then printf 'variable "BASE_NODE" { default = "%s" }\n' "$BASE_NODE" >>"$OUT"; fi
|
||||
if [ -n "$BASE_NGINX" ]; then printf 'variable "BASE_NGINX" { default = "%s" }\n' "$BASE_NGINX" >>"$OUT"; fi
|
||||
if [ -n "$BASE_UBUNTU" ]; then printf 'variable "BASE_UBUNTU" { default = "%s" }\n' "$BASE_UBUNTU" >>"$OUT"; fi
|
||||
|
||||
+4
-4
@@ -118,10 +118,10 @@ EvoBGP управляет генерацией и применением BGP-к
|
||||
|
||||
| Маршрут | Назначение |
|
||||
|---------|------------|
|
||||
| `/settings` | Только браузер: API-токен, тема (localStorage). Tenant KV здесь **не** редактируются. |
|
||||
| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии**, **Файловые логи** (автоочистка FS), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. |
|
||||
| `/network` → Control plane | Краткая сводка BIRD + ссылка на `/tenant-settings?tab=bird`. |
|
||||
| `/operations` | Ревизии, diff, jobs; вкладка «Система» перенесена в `/tenant-settings`. |
|
||||
| `/settings?tab=ui` | Настройки UI: API-токен, сессия, тема. |
|
||||
| `/settings?tab=bird` | Настройки BIRD: control plane, ревизии, файловые логи, дополнительные ключи `/v1/settings`. Старый `/tenant-settings` редиректит сюда. |
|
||||
| `/network` → Control plane | Краткая сводка BIRD. |
|
||||
| `/operations` | Ревизии, diff, jobs. |
|
||||
|
||||
### Web UI: файловые runtime-логи
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
## Готовые образы без сборки (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
@@ -23,21 +23,22 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
|
||||
|
||||
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
|
||||
|
||||
## CI-пайплайн (один push в main)
|
||||
## CI-пайплайн (push в main)
|
||||
|
||||
```text
|
||||
push/merge в main
|
||||
→ CI: openapi, web, go, bird2 (параллельно)
|
||||
→ job release (в том же workflow, после quality gates):
|
||||
→ workflow CD: quality (openapi, web, go, bird2)
|
||||
→ job publish:
|
||||
→ semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
|
||||
→ 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
|
||||
```
|
||||
|
||||
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 не коммитится в репозиторий).
|
||||
|
||||
@@ -51,7 +52,7 @@ Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release
|
||||
| releases | Gitea Release + notes |
|
||||
| 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
|
||||
|
||||
@@ -75,15 +76,15 @@ Web UI показывает версию из API (footer sidebar, страни
|
||||
|
||||
Правило: **один 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): …`).
|
||||
|
||||
## Перезапуск упавшего 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
|
||||
|
||||
@@ -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.
|
||||
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"`.
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
"@commitlint/cli": "^19.8.1",
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
"@markwylde/semantic-release-gitea": "^2.2.0",
|
||||
"@redocly/cli": "1.34.5",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||
"@semantic-release/exec": "^7.0.0",
|
||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||
"conventional-commits-parser": "^6.4.0",
|
||||
"semantic-release": "^25.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,13 @@ function Tabs({
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 border-b border-border bg-transparent",
|
||||
default:
|
||||
"bg-muted p-[3px] group-data-[orientation=horizontal]/tabs:h-9",
|
||||
line: "h-auto gap-1 overflow-visible border-b border-border bg-transparent p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -58,10 +59,12 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
"relative inline-flex items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=default]/tabs-list:h-[calc(100%-1px)] group-data-[variant=default]/tabs-list:flex-1 group-data-[variant=default]/tabs-list:data-active:shadow-sm",
|
||||
"group-data-[variant=line]/tabs-list:h-auto group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:rounded-none group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:py-1.5 group-data-[variant=line]/tabs-list:shadow-none group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:shadow-none dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"group-data-[variant=default]/tabs-list:data-active:bg-background group-data-[variant=default]/tabs-list:data-active:text-foreground dark:group-data-[variant=default]/tabs-list:data-active:border-input dark:group-data-[variant=default]/tabs-list:data-active:bg-input/30 dark:group-data-[variant=default]/tabs-list:data-active:text-foreground",
|
||||
"data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-0 group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Generated
+1312
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user