Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e55d2c5aba | ||
|
|
f63e9b5fd0 | ||
|
|
0148d4ca37 | ||
|
|
e0ecabb22f | ||
|
|
da301b1a94 | ||
|
|
653bc6cc91 | ||
|
|
e0d695f2a4 | ||
|
|
dfc33dcb99 | ||
|
|
44f70443c3 | ||
|
|
357ed4ce7b |
@@ -28,7 +28,7 @@ alwaysApply: true
|
|||||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||||
|------------|-------------|------------------|-------|
|
|------------|-------------|------------------|-------|
|
||||||
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
||||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `npx @redocly/cli lint` |
|
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `pnpm exec redocly lint` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ alwaysApply: true
|
|||||||
## Dependency Management
|
## Dependency Management
|
||||||
|
|
||||||
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
|
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
|
||||||
*Проверка:* `go.mod`, `.gitea/workflows/ci.yaml`.
|
*Проверка:* `go.mod`, `.gitea/workflows/quality.yaml`.
|
||||||
|
|
||||||
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
|
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
|
||||||
*Проверка:* review.
|
*Проверка:* review.
|
||||||
@@ -123,10 +123,10 @@ alwaysApply: true
|
|||||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||||
*Проверка:* CI job `bird2`.
|
*Проверка:* CI job `bird2`.
|
||||||
|
|
||||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`.
|
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/quality.yaml`.
|
||||||
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
||||||
|
|
||||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
**TEST-05** | MUST | Изменения OpenAPI — `pnpm exec redocly lint docs/openapi.yaml`.
|
||||||
*Проверка:* CI job `openapi`.
|
*Проверка:* CI job `openapi`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -229,7 +229,7 @@ alwaysApply: true
|
|||||||
```powershell
|
```powershell
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./... -race -count=1
|
go test ./... -race -count=1
|
||||||
npx @redocly/cli lint docs/openapi.yaml
|
pnpm exec redocly lint docs/openapi.yaml
|
||||||
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
||||||
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||||
|
|||||||
@@ -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
|
# Gitea Actions
|
||||||
|
|
||||||
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
| Workflow | Когда | Что |
|
||||||
|
|----------|--------|-----|
|
||||||
| Job | PR | push в main |
|
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||||
|-----|-----|-------------|
|
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
|
||||||
| changes, openapi, web, go, bird2 | quality gates | quality gates |
|
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, go, bird2, commitlint, docker-check |
|
||||||
| commitlint | да | — |
|
|
||||||
| **release** | — | semantic-release + docker push (один run) |
|
|
||||||
|
|
||||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||||
|
|
||||||
## CI (quality gates)
|
## CI (quality gates)
|
||||||
|
|
||||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` → `go` / `openapi` и т.д. (см. `ci.yaml`).
|
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/api.md` / `docs/access.md` / `docs/openapi.yaml`) quality jobs не запускают.
|
||||||
|
|
||||||
На **pull request** — **commitlint** (Conventional Commits).
|
На **pull request** — **commitlint**. При изменении `deploy/docker/**` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||||
|
|
||||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
|
Кэш зависимостей: `actions/cache` с ключом `sha256sum` lockfile (`go.sum` / `pnpm-lock.yaml`). `hashFiles` в Gitea не используем.
|
||||||
|
|
||||||
## Release (job в ci.yaml)
|
Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR) и **publish** (CD).
|
||||||
|
|
||||||
После успешных quality gates на **push в main** job **release**:
|
## CD (job publish)
|
||||||
|
|
||||||
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
После успешных quality gates на **push в main** job **publish**:
|
||||||
|
|
||||||
|
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||||
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||||
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
|
3. Зеркало base-образов в `evobgp-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||||
|
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evobgp` (`cleanup: false`).
|
||||||
|
|
||||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||||
|
|
||||||
Повтор упавшего **release** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-release).
|
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||||
|
|
||||||
### Секреты
|
### Секреты
|
||||||
|
|
||||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
|
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||||
|
|
||||||
### Теги образов
|
### Теги образов
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ git.shx.one/<owner>/<имя>:sha-<full-sha>
|
|||||||
|
|
||||||
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
||||||
|
|
||||||
|
Кэш сборки: `evobgp-buildcache:{go,web,birdc}-buildcache` и `evobgp-buildcache:base-*`.
|
||||||
|
|
||||||
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
|
|||||||
@@ -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
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main, master]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, master]
|
branches: [main, master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ---------------------------------------------------------------------------
|
quality:
|
||||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
uses: ./.gitea/workflows/quality.yaml
|
||||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
with:
|
||||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
is_pull_request: true
|
||||||
# ---------------------------------------------------------------------------
|
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||||
changes:
|
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||||
runs-on: ubuntu-latest
|
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
outputs:
|
secrets:
|
||||||
openapi: ${{ steps.detect.outputs.openapi }}
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
go: ${{ steps.detect.outputs.go }}
|
|
||||||
web: ${{ steps.detect.outputs.web }}
|
|
||||||
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
|
||||||
docker_go: ${{ steps.detect.outputs.docker_go }}
|
|
||||||
docker_web: ${{ steps.detect.outputs.docker_web }}
|
|
||||||
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- id: detect
|
|
||||||
name: Detect changed paths per module
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
openapi=false
|
|
||||||
go=false
|
|
||||||
web=false
|
|
||||||
bird_conf=false
|
|
||||||
docker_go=false
|
|
||||||
docker_web=false
|
|
||||||
docker_bird=false
|
|
||||||
|
|
||||||
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
|
|
||||||
set_all_flags_true() {
|
|
||||||
openapi=true
|
|
||||||
go=true
|
|
||||||
web=true
|
|
||||||
bird_conf=true
|
|
||||||
docker_go=true
|
|
||||||
docker_web=true
|
|
||||||
docker_bird=true
|
|
||||||
}
|
|
||||||
|
|
||||||
write_outputs() {
|
|
||||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
|
||||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
||||||
base="${{ github.event.pull_request.base.sha }}"
|
|
||||||
head="${{ github.event.pull_request.head.sha }}"
|
|
||||||
FILES="$(git diff --name-only "$base" "$head")"
|
|
||||||
else
|
|
||||||
before="${{ github.event.before }}"
|
|
||||||
after="${{ github.sha }}"
|
|
||||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
|
||||||
FILES="$(git diff --name-only "$before" "$after")"
|
|
||||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
|
||||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
|
||||||
else
|
|
||||||
set_all_flags_true
|
|
||||||
write_outputs
|
|
||||||
echo "No parent commit — full pipeline (all modules)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
|
||||||
set_all_flags_true
|
|
||||||
write_outputs
|
|
||||||
echo "Empty diff — full pipeline fallback"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
full_pipeline=false
|
|
||||||
|
|
||||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
|
||||||
[ -z "${f:-}" ] && continue
|
|
||||||
case "$f" in
|
|
||||||
# CI/CD инфраструктура — все узлы quality gates
|
|
||||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
|
||||||
full_pipeline=true
|
|
||||||
;;
|
|
||||||
docs/openapi.yaml|redocly.yaml)
|
|
||||||
openapi=true
|
|
||||||
;;
|
|
||||||
docs/api.md|docs/access.md)
|
|
||||||
openapi=true
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
|
||||||
;;
|
|
||||||
apps/web/*|packages/ui/*|packages/shared/*)
|
|
||||||
web=true
|
|
||||||
;;
|
|
||||||
deploy/bird/*)
|
|
||||||
bird_conf=true
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
deploy/compose/*|deploy/docker/*)
|
|
||||||
docker_go=true
|
|
||||||
docker_web=true
|
|
||||||
docker_bird=true
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
go.mod|go.sum|go.work)
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
migrations/*)
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
cmd/*|internal/*|*.go)
|
|
||||||
go=true
|
|
||||||
bird_conf=true
|
|
||||||
;;
|
|
||||||
docs/*)
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
|
||||||
full_pipeline=true
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done <<< "$FILES"
|
|
||||||
|
|
||||||
if $full_pipeline; then
|
|
||||||
set_all_flags_true
|
|
||||||
fi
|
|
||||||
|
|
||||||
write_outputs
|
|
||||||
|
|
||||||
echo "Changed files (first 30):"
|
|
||||||
printf '%s\n' "$FILES" | head -n 30
|
|
||||||
echo "--- flags ---"
|
|
||||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
|
||||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
openapi:
|
|
||||||
needs: [changes]
|
|
||||||
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
- name: Enable pnpm via corepack
|
|
||||||
run: corepack enable
|
|
||||||
- name: Lint OpenAPI (Redocly)
|
|
||||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
|
||||||
- name: Check OpenAPI→TS codegen is fresh
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
chmod +x scripts/check-openapi-gen.sh
|
|
||||||
sh scripts/check-openapi-gen.sh
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
web:
|
|
||||||
needs: [changes]
|
|
||||||
if: needs.changes.outputs.web == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
- name: Enable pnpm via corepack
|
|
||||||
run: corepack enable
|
|
||||||
- name: pnpm install, typecheck, lint, test, build
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm --filter @evobgp/web run typecheck
|
|
||||||
pnpm --filter @evobgp/web run lint
|
|
||||||
pnpm --filter @evobgp/web run test
|
|
||||||
pnpm --filter @evobgp/web run build
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
go:
|
|
||||||
needs: [changes]
|
|
||||||
if: needs.changes.outputs.go == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.24"
|
|
||||||
cache: true
|
|
||||||
cache-dependency-path: go.sum
|
|
||||||
- name: Vet
|
|
||||||
run: go vet ./...
|
|
||||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
|
||||||
run: sh scripts/lint-httpapi.sh
|
|
||||||
- name: Check migration pairs (DEP-03)
|
|
||||||
run: sh scripts/check-migrations-pair.sh
|
|
||||||
- name: Validate remote speaker compose
|
|
||||||
run: sh scripts/validate-remote-speaker-compose.sh
|
|
||||||
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
|
|
||||||
- name: golangci-lint
|
|
||||||
uses: golangci/golangci-lint-action@v6
|
|
||||||
with:
|
|
||||||
version: v1.64.8
|
|
||||||
install-mode: goinstall
|
|
||||||
- name: Test
|
|
||||||
run: go test ./... -race -count=1
|
|
||||||
- name: Build all commands
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
out="${RUNNER_TEMP}/evobgp-bin"
|
|
||||||
mkdir -p "$out"
|
|
||||||
for d in cmd/*/; do
|
|
||||||
name="$(basename "$d")"
|
|
||||||
go build -o "$out/$name" "./$d"
|
|
||||||
done
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
bird2:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [changes, go]
|
|
||||||
if: >-
|
|
||||||
always() &&
|
|
||||||
needs.changes.result == 'success' &&
|
|
||||||
needs.go.result != 'failure' &&
|
|
||||||
(needs.changes.outputs.go == 'true' ||
|
|
||||||
needs.changes.outputs.bird_conf == 'true' ||
|
|
||||||
needs.changes.outputs.docker_bird == 'true' ||
|
|
||||||
needs.changes.outputs.docker_go == 'true')
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
|
||||||
$SUDO apt-get update -qq
|
|
||||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
|
||||||
bird --version
|
|
||||||
- name: bird -p on all scenario bird.conf files
|
|
||||||
env:
|
|
||||||
WORKSPACE: ${{ github.workspace }}
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
WS="${WORKSPACE:-$PWD}"
|
|
||||||
cd "$WS"
|
|
||||||
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
|
||||||
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
|
||||||
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
|
||||||
echo "==> $conf"
|
|
||||||
bird -c "$WS/$conf" -p
|
|
||||||
done
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
commitlint:
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
- name: Lint commit messages
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
npm ci
|
|
||||||
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
release:
|
|
||||||
needs: [changes, openapi, web, go, bird2]
|
|
||||||
if: >-
|
|
||||||
always() &&
|
|
||||||
github.event_name == 'push' &&
|
|
||||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
|
||||||
needs.changes.result == 'success' &&
|
|
||||||
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
|
|
||||||
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
|
|
||||||
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
|
|
||||||
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
fetch-tags: true
|
|
||||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
|
||||||
persist-credentials: true
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
- name: Install release tooling
|
|
||||||
run: npm ci
|
|
||||||
- name: Verify releasable commit messages
|
|
||||||
run: node scripts/commit/verify-release-commits.mjs
|
|
||||||
- name: Semantic release
|
|
||||||
run: npx semantic-release
|
|
||||||
env:
|
|
||||||
GITEA_URL: https://git.shx.one
|
|
||||||
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
|
||||||
- name: Detect new release
|
|
||||||
id: rel
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
version=""
|
|
||||||
if [ -f .release-version ]; then
|
|
||||||
version="$(tr -d '[:space:]' < .release-version)"
|
|
||||||
echo "New release from semantic-release: $version"
|
|
||||||
else
|
|
||||||
# Re-run after a failed docker step: tag already exists, successCmd
|
|
||||||
# did not write .release-version (semantic-release is a no-op).
|
|
||||||
git fetch --tags --force origin || true
|
|
||||||
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
|
||||||
if [ -n "${tag:-}" ]; then
|
|
||||||
version="${tag#v}"
|
|
||||||
echo "Reuse existing tag $tag on HEAD (release retry)"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [ -n "${version:-}" ]; then
|
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "released=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "No releasable commits — skipping image publish"
|
|
||||||
fi
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
if: steps.rel.outputs.released == 'true'
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Prepare image metadata
|
|
||||||
if: steps.rel.outputs.released == 'true'
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
|
||||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
|
||||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
|
||||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
|
||||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Log in to Gitea Registry
|
|
||||||
if: steps.rel.outputs.released == 'true'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.shx.one
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
|
||||||
- name: Build and push images (bake)
|
|
||||||
if: steps.rel.outputs.released == 'true'
|
|
||||||
env:
|
|
||||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
|
||||||
IMAGE_TAG: latest
|
|
||||||
VERSION: ${{ steps.meta.outputs.version }}
|
|
||||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
|
||||||
SHA_FULL: ${{ github.sha }}
|
|
||||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
|
||||||
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
|
||||||
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
|
||||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
cd "${{ github.workspace }}/deploy/docker"
|
|
||||||
sh write-bake-override.sh
|
|
||||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
|
||||||
-f docker-bake.hcl -f docker-bake.override.hcl default --push
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
||||||
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
||||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
|
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CD run**, что и релиз (job `publish`)
|
||||||
|
|
||||||
Лицензия и условия использования — по политике владельца репозитория.
|
Лицензия и условия использования — по политике владельца репозитория.
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export function AccessApiKeysCard({
|
|||||||
<>
|
<>
|
||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
title="API-ключи"
|
title="API-ключи"
|
||||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
description="Полный токен показывается только при создании и ротации."
|
||||||
|
className="min-w-0"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||||
|
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type { ApiKey } from '@/types/api'
|
import type { ApiKey } from '@/types/api'
|
||||||
|
|
||||||
@@ -34,7 +35,14 @@ export function AccessApiKeysGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
|
cell: ({ row }) => (
|
||||||
|
<DataGridPrimaryCell
|
||||||
|
title={row.original.name}
|
||||||
|
accent="primary"
|
||||||
|
subtitle={`${row.original.prefix}…`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
minSize: 160,
|
||||||
meta: { headerTitle: 'Имя' },
|
meta: { headerTitle: 'Имя' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -45,14 +53,6 @@ export function AccessApiKeysGrid({
|
|||||||
),
|
),
|
||||||
meta: { headerTitle: 'Роль' },
|
meta: { headerTitle: 'Роль' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: 'prefix',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}…</span>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Префикс' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@@ -77,11 +77,11 @@ export function AccessApiKeysGrid({
|
|||||||
{
|
{
|
||||||
id: 'last_used_at',
|
id: 'last_used_at',
|
||||||
accessorFn: (row) => row.last_used_at ?? '',
|
accessorFn: (row) => row.last_used_at ?? '',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Последнее использование' },
|
meta: { headerTitle: 'Использован' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -90,7 +90,7 @@ export function AccessApiKeysGrid({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const k = row.original
|
const k = row.original
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
@@ -149,6 +149,7 @@ export function AccessApiKeysGrid({
|
|||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет ключей"
|
emptyMessage="Нет ключей"
|
||||||
|
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||||
searchValue={globalFilter}
|
searchValue={globalFilter}
|
||||||
onSearchChange={setGlobalFilter}
|
onSearchChange={setGlobalFilter}
|
||||||
searchPlaceholder="Поиск API-ключей…"
|
searchPlaceholder="Поиск API-ключей…"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
} from '@evobgp/ui/components/chart'
|
} from '@evobgp/ui/components/chart'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
@@ -43,6 +45,7 @@ export function ChartDonutMetric({
|
|||||||
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
|
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
|
||||||
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
|
||||||
<Pie
|
<Pie
|
||||||
data={data}
|
data={data}
|
||||||
dataKey="count"
|
dataKey="count"
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ export function MonitoringHealthCard({
|
|||||||
return (
|
return (
|
||||||
<DonutBreakdownCard
|
<DonutBreakdownCard
|
||||||
title="Доступность системы"
|
title="Доступность системы"
|
||||||
description="GET /v1/health · GET /v1/ready"
|
description="Проверки живучести и готовности"
|
||||||
slices={slices}
|
slices={slices}
|
||||||
centerLabel="Проверки"
|
centerLabel="Проверки"
|
||||||
badge={healthOk ? 'API OK' : undefined}
|
badge={healthOk ? 'API в норме' : undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,15 +40,15 @@ export function NetworkOverviewAnalyticsCard({
|
|||||||
/>
|
/>
|
||||||
<SegmentedProgressCard
|
<SegmentedProgressCard
|
||||||
title="Спикеры"
|
title="Спикеры"
|
||||||
description="Доступность live-агентов"
|
description="Доступность агентов"
|
||||||
primary={{
|
primary={{
|
||||||
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
||||||
label: 'Online',
|
label: 'В сети',
|
||||||
percent: speakersPct,
|
percent: speakersPct,
|
||||||
}}
|
}}
|
||||||
secondary={{
|
secondary={{
|
||||||
value: net.speakersTotal - net.speakersOnline,
|
value: net.speakersTotal - net.speakersOnline,
|
||||||
label: 'Offline',
|
label: 'Не в сети',
|
||||||
percent: 100 - speakersPct,
|
percent: 100 - speakersPct,
|
||||||
}}
|
}}
|
||||||
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||||
|
|||||||
@@ -41,14 +41,17 @@ export function BadgeTabs({
|
|||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onValueChange}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
className={cn('w-full', className)}
|
className={cn('w-full min-w-0', className)}
|
||||||
>
|
>
|
||||||
<TabsList
|
<TabsList
|
||||||
variant="line"
|
variant="line"
|
||||||
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
|
className={cn(
|
||||||
|
'mb-3.5 w-full min-w-0 justify-start gap-4',
|
||||||
|
listClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
|
||||||
{item.icon}
|
{item.icon}
|
||||||
{item.label}
|
{item.label}
|
||||||
{item.count !== undefined ? (
|
{item.count !== undefined ? (
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function DashboardActivityTimeline({
|
|||||||
<DashboardFramePanel
|
<DashboardFramePanel
|
||||||
title="Недавняя активность"
|
title="Недавняя активность"
|
||||||
description="Задачи, ревизии и сетевые события"
|
description="Задачи, ревизии и сетевые события"
|
||||||
className="h-full"
|
className="h-full min-w-0"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
||||||
@@ -82,7 +82,7 @@ export function DashboardActivityTimeline({
|
|||||||
</TimelineIndicator>
|
</TimelineIndicator>
|
||||||
</TimelineHeader>
|
</TimelineHeader>
|
||||||
<TimelineContent className="min-w-0 pb-1 text-foreground">
|
<TimelineContent className="min-w-0 pb-1 text-foreground">
|
||||||
<TimelineTitle className="text-sm leading-snug font-normal">
|
<TimelineTitle className="text-sm leading-snug font-normal break-words">
|
||||||
{item.message}
|
{item.message}
|
||||||
</TimelineTitle>
|
</TimelineTitle>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function buildKpis({
|
|||||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{loading ? '…' : 'онлайн'}
|
{loading ? '…' : 'в сети'}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function buildMetrics({
|
|||||||
{
|
{
|
||||||
id: 'bgp',
|
id: 'bgp',
|
||||||
title: 'BGP готовность',
|
title: 'BGP готовность',
|
||||||
label: 'Established / включённые',
|
label: 'Установлено / включено',
|
||||||
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
|
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
|
||||||
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
|
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
|
||||||
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
|
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
|
||||||
@@ -62,13 +62,13 @@ function buildMetrics({
|
|||||||
{
|
{
|
||||||
id: 'speakers',
|
id: 'speakers',
|
||||||
title: 'Спикеры',
|
title: 'Спикеры',
|
||||||
label: 'Online / всего',
|
label: 'В сети / всего',
|
||||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||||
delta:
|
delta:
|
||||||
loading || network.speakersTotal === 0
|
loading || network.speakersTotal === 0
|
||||||
? '…'
|
? '…'
|
||||||
: network.speakersOnline === network.speakersTotal
|
: network.speakersOnline === network.speakersTotal
|
||||||
? 'все online'
|
? 'все в сети'
|
||||||
: 'частично',
|
: 'частично',
|
||||||
deltaVariant:
|
deltaVariant:
|
||||||
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
||||||
|
|||||||
@@ -119,9 +119,10 @@ export function DashboardModulesGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
||||||
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" />
|
<DataGridPrimaryCell title={row.original.name} accent="primary" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
minSize: 180,
|
||||||
meta: { headerTitle: 'Модуль' },
|
meta: { headerTitle: 'Модуль' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -164,6 +165,7 @@ export function DashboardModulesGrid({
|
|||||||
<DashboardFramePanel
|
<DashboardFramePanel
|
||||||
title="Модули"
|
title="Модули"
|
||||||
description="Поиск, сортировка и быстрый переход к настройке"
|
description="Поиск, сортировка и быстрый переход к настройке"
|
||||||
|
className="min-w-0"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
@@ -183,14 +185,14 @@ export function DashboardModulesGrid({
|
|||||||
columnsVisibility: false,
|
columnsVisibility: false,
|
||||||
columnsResizable: false,
|
columnsResizable: false,
|
||||||
columnsMovable: false,
|
columnsMovable: false,
|
||||||
width: 'fixed',
|
width: 'auto',
|
||||||
}}
|
}}
|
||||||
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
||||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
|
||||||
<InputGroup className="w-full min-w-40 sm:max-w-xs">
|
<InputGroup className="w-full min-w-0 @3xl:max-w-xs">
|
||||||
<InputGroupAddon align="inline-start">
|
<InputGroupAddon align="inline-start">
|
||||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
|
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import { useMemo, useState } from 'react'
|
|||||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import { PanelCard } from '@/components/panel-card'
|
||||||
import { Separator } from '@evobgp/ui/components/separator'
|
|
||||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||||
import type { JobRow, ModuleRow } from '@/types/api'
|
import type { JobRow, ModuleRow } from '@/types/api'
|
||||||
|
|
||||||
type FlowMode = 'jobs' | 'modules'
|
type FlowMode = 'jobs' | 'modules'
|
||||||
|
|
||||||
/** chart-13 inspired donut breakdown (Card surface). */
|
/**
|
||||||
|
* Donut distribution — chart-27 DNA: Frame + one legend (inside ChartDonutMetric).
|
||||||
|
* @see https://reui.io/preview/base/chart-27
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
export function DashboardOperationsBreakdown({
|
export function DashboardOperationsBreakdown({
|
||||||
jobs,
|
jobs,
|
||||||
modules,
|
modules,
|
||||||
@@ -32,7 +35,7 @@ export function DashboardOperationsBreakdown({
|
|||||||
return (
|
return (
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Поток операций"
|
title="Поток операций"
|
||||||
description="Распределение задач и типов модулей"
|
description="Доли статусов задач или типов модулей"
|
||||||
actions={
|
actions={
|
||||||
<AnalyticsSegmentControl
|
<AnalyticsSegmentControl
|
||||||
value={mode}
|
value={mode}
|
||||||
@@ -43,45 +46,16 @@ export function DashboardOperationsBreakdown({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
className="h-full"
|
className="min-w-0"
|
||||||
|
contentClassName="px-5 py-4"
|
||||||
>
|
>
|
||||||
<div className="p-4">
|
{loading ? (
|
||||||
{loading ? (
|
<div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
|
||||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
Загрузка…
|
||||||
Загрузка…
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
||||||
<>
|
)}
|
||||||
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
|
||||||
{slices.length > 0 ? (
|
|
||||||
<ul className="mt-4 flex min-w-0 flex-col">
|
|
||||||
{slices.map((slice, index) => {
|
|
||||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
|
||||||
return (
|
|
||||||
<li key={slice.key}>
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
|
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
|
||||||
style={{ backgroundColor: slice.color }}
|
|
||||||
/>
|
|
||||||
<span className="truncate text-sm font-medium">{slice.label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
|
|
||||||
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
|
|
||||||
{pct}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'lookup',
|
id: 'lookup',
|
||||||
title: 'Проверка IP/домена',
|
title: 'Проверка IP/домена',
|
||||||
description: 'Проверка IP в списках и BGP-сообществах.',
|
description: 'Проверка IP в списках и BGP community.',
|
||||||
to: '/lookup',
|
to: '/lookup',
|
||||||
icon: <Search aria-hidden />,
|
icon: <Search aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
@@ -22,8 +22,8 @@ const ACTIONS: QuickActionItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'communities',
|
id: 'communities',
|
||||||
title: 'BGP-сообщества',
|
title: 'BGP community',
|
||||||
description: 'Справочник BGP-сообществ для политик экспорта.',
|
description: 'Справочник BGP community для политик экспорта.',
|
||||||
to: '/directories',
|
to: '/directories',
|
||||||
icon: <Tags aria-hidden />,
|
icon: <Tags aria-hidden />,
|
||||||
iconClassName: 'text-info',
|
iconClassName: 'text-info',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridShell } from '@/components/data-grid-shell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||||
@@ -49,7 +49,7 @@ export function DashboardRecentJobsGrid({
|
|||||||
[nameById],
|
[nameById],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
const { table, filteredCount } = useClientDataGrid({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getSearchText: (row) => {
|
getSearchText: (row) => {
|
||||||
@@ -63,16 +63,13 @@ export function DashboardRecentJobsGrid({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<DataGridShell
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет задач"
|
emptyMessage="Нет задач"
|
||||||
showPagination={false}
|
showPagination={false}
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск задач…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridShell } from '@/components/data-grid-shell'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
@@ -42,7 +42,7 @@ export function DashboardRecentRevisionsGrid({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
const { table, filteredCount } = useClientDataGrid({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getSearchText: (row) => row.id,
|
getSearchText: (row) => row.id,
|
||||||
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<DataGridShell
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет ревизий"
|
emptyMessage="Нет ревизий"
|
||||||
showPagination={false}
|
showPagination={false}
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск ревизий…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function DataGridShell<TData extends object>({
|
|||||||
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
|
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||||
tableLayout={tableLayout}
|
tableLayout={tableLayout}
|
||||||
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
|
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
|
||||||
className={className}
|
className={cn('min-w-0', className)}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
>
|
>
|
||||||
<DataGridContainer border={false}>
|
<DataGridContainer border={false}>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function CommunityFormDialog({
|
|||||||
<FormDrawer
|
<FormDrawer
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title={editTarget ? 'Редактировать сообщество' : 'Новое сообщество BGP'}
|
title={editTarget ? 'Редактировать community' : 'Новое BGP community'}
|
||||||
description="Тег для префиксов в фильтрах BIRD"
|
description="Тег для префиксов в фильтрах BIRD"
|
||||||
className="sm:max-w-sm"
|
className="sm:max-w-sm"
|
||||||
footer={
|
footer={
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ export function DirectoriesCommunitiesGrid({
|
|||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет сообществ"
|
emptyMessage="Нет community"
|
||||||
searchValue={globalFilter}
|
searchValue={globalFilter}
|
||||||
onSearchChange={setGlobalFilter}
|
onSearchChange={setGlobalFilter}
|
||||||
searchPlaceholder="Поиск сообществ…"
|
searchPlaceholder="Поиск community…"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function DohProfileFormDialog({
|
|||||||
if (timeoutMs.trim() !== '') {
|
if (timeoutMs.trim() !== '') {
|
||||||
const ms = Number(timeoutMs)
|
const ms = Number(timeoutMs)
|
||||||
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
|
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
|
||||||
toast.error('Timeout должен быть целым числом > 0')
|
toast.error('Таймаут должен быть целым числом больше 0')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
timeout = ms
|
timeout = ms
|
||||||
@@ -136,7 +136,7 @@ export function DohProfileFormDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="doh-timeout">Timeout, мс (опционально)</Label>
|
<Label htmlFor="doh-timeout">Таймаут, мс (необязательно)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="doh-timeout"
|
id="doh-timeout"
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
BookText,
|
BookText,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ServerCog,
|
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
@@ -72,7 +71,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
to: '/dashboard',
|
to: '/dashboard',
|
||||||
label: 'Панель',
|
label: 'Панель',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
description: 'KPI, модули и активность',
|
description: 'Метрики, модули и активность',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -82,23 +81,22 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
||||||
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
||||||
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
||||||
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
|
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Операции',
|
label: 'Операции',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
||||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
||||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Система',
|
label: 'Система',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
|
||||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function AppsMenu() {
|
|||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||||
className="justify-center text-sm font-medium"
|
className="justify-center text-sm font-medium"
|
||||||
>
|
>
|
||||||
Настройки
|
Настройки
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function NavUser() {
|
|||||||
setApiToken(null)
|
setApiToken(null)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||||
window.location.assign('/settings?tab=connection&reason=token-required')
|
window.location.assign('/settings?tab=ui&reason=token-required')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,10 +190,10 @@ export function NavUser() {
|
|||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||||
>
|
>
|
||||||
<SettingsIcon aria-hidden />
|
<SettingsIcon aria-hidden />
|
||||||
Настройки UI
|
Настройки
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{authOn ? (
|
{authOn ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ export function SystemMonitorPopover() {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: 'api',
|
id: 'api',
|
||||||
label: 'API health',
|
label: 'Состояние API',
|
||||||
value: healthOk ? 'OK' : '—',
|
value: healthOk ? 'норма' : '—',
|
||||||
unit: '',
|
unit: '',
|
||||||
percent: healthOk ? 100 : 0,
|
percent: healthOk ? 100 : 0,
|
||||||
icon: <HeartPulse aria-hidden />,
|
icon: <HeartPulse aria-hidden />,
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
|
|||||||
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
|
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
|
||||||
|
|
||||||
const CDN_KIND_ITEMS = [
|
const CDN_KIND_ITEMS = [
|
||||||
{ value: 'plaintext', label: 'plaintext' },
|
{ value: 'plaintext', label: 'Текст' },
|
||||||
{ value: 'json', label: 'json' },
|
{ value: 'json', label: 'JSON' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
interface ModuleCdnSourceDialogProps {
|
interface ModuleCdnSourceDialogProps {
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export function ModuleEditDialog({
|
|||||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
<Label htmlFor="mod-enabled">Включён</Label>
|
<Label htmlFor="mod-enabled">Включён</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Выключенный модуль не участвует в refresh и apply.
|
Выключенный модуль не участвует в обновлении и применении.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
|
|||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { formatDateTime } from '@/lib/modules/display'
|
import { formatDateTime } from '@/lib/modules/display'
|
||||||
import { communityLabel } from '@/lib/modules/helpers'
|
import { communityLabel } from '@/lib/modules/helpers'
|
||||||
|
import { cdnSourceKindRu } from '@/lib/ui-labels'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type {
|
import type {
|
||||||
AsEntry,
|
AsEntry,
|
||||||
@@ -145,7 +146,7 @@ export function ModuleEntriesGrid({
|
|||||||
accessorKey: 'source_kind',
|
accessorKey: 'source_kind',
|
||||||
header: 'Тип',
|
header: 'Тип',
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||||
<CategoryBadge>{row.original.source_kind}</CategoryBadge>
|
<CategoryBadge>{cdnSourceKindRu(row.original.source_kind)}</CategoryBadge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
|
|||||||
function speakerLabel(s: SpeakerRow): string {
|
function speakerLabel(s: SpeakerRow): string {
|
||||||
if (s.role === 'master') {
|
if (s.role === 'master') {
|
||||||
const host = s.agent_domain ?? s.endpoint
|
const host = s.agent_domain ?? s.endpoint
|
||||||
return host ? `CP · ${host}` : 'CP (master)'
|
return host ? `Плоскость · ${host}` : 'Плоскость управления'
|
||||||
}
|
}
|
||||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ const filterFields: FilterFieldConfig[] = [
|
|||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-48',
|
className: 'w-48',
|
||||||
placeholder: 'IP или Neighbor ID…',
|
placeholder: 'IP или ID соседа…',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
id: 'neighbor_id',
|
id: 'neighbor_id',
|
||||||
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Neighbor ID" />
|
<DataGridColumnHeader column={column} title="ID соседа" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
@@ -103,7 +103,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
accent="mono"
|
accent="mono"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Neighbor ID' },
|
meta: { headerTitle: 'ID соседа' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'remote_asn',
|
accessorKey: 'remote_asn',
|
||||||
@@ -212,7 +212,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
title="Одобрить пира"
|
title="Одобрить пира"
|
||||||
description={
|
description={
|
||||||
approveTarget
|
approveTarget
|
||||||
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
? `ID соседа ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className="sm:max-w-sm"
|
className="sm:max-w-sm"
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function NetworkKpi({
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{loading ? '…' : 'онлайн'}
|
{loading ? '…' : 'в сети'}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
to: '/network',
|
to: '/network',
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ const PEER_TABS = [
|
|||||||
|
|
||||||
const SESSION_STATE_OPTIONS = [
|
const SESSION_STATE_OPTIONS = [
|
||||||
{ value: 'Established', label: 'Установлена' },
|
{ value: 'Established', label: 'Установлена' },
|
||||||
{ value: 'Idle', label: 'Idle' },
|
{ value: 'Idle', label: 'Простой' },
|
||||||
{ value: 'Active', label: 'Active' },
|
{ value: 'Active', label: 'Поиск' },
|
||||||
{ value: 'Connect', label: 'Connect' },
|
{ value: 'Connect', label: 'Соединение' },
|
||||||
{ value: 'OpenSent', label: 'OpenSent' },
|
{ value: 'OpenSent', label: 'Open отправлен' },
|
||||||
{ value: 'OpenConfirm', label: 'OpenConfirm' },
|
{ value: 'OpenConfirm', label: 'Open подтверждён' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDefaultPeerFilters(): Filter[] {
|
function createDefaultPeerFilters(): Filter[] {
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ function speakerDeleteLabel(s: SpeakerRow): string {
|
|||||||
|
|
||||||
const SPEAKER_TABS = [
|
const SPEAKER_TABS = [
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
{ id: 'online', label: 'Online' },
|
{ id: 'online', label: 'В сети' },
|
||||||
{ id: 'offline', label: 'Offline' },
|
{ id: 'offline', label: 'Не в сети' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
{ value: 'primary', label: 'primary' },
|
{ value: 'primary', label: 'Основной' },
|
||||||
{ value: 'secondary', label: 'secondary' },
|
{ value: 'secondary', label: 'Резервный' },
|
||||||
{ value: 'speaker', label: 'speaker' },
|
{ value: 'speaker', label: 'Спикер' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDefaultSpeakerFilters(): Filter[] {
|
function createDefaultSpeakerFilters(): Filter[] {
|
||||||
@@ -52,7 +52,7 @@ const speakerFilterFields: FilterFieldConfig[] = [
|
|||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-52',
|
className: 'w-52',
|
||||||
placeholder: 'endpoint…',
|
placeholder: 'Адрес агента…',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'role',
|
key: 'role',
|
||||||
@@ -128,7 +128,7 @@ export function NetworkSpeakersCard({
|
|||||||
<>
|
<>
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Спикеры"
|
title="Спикеры"
|
||||||
description="BIRD-агенты на нодах tenant"
|
description="BIRD-агенты на нодах арендатора"
|
||||||
tabs={SPEAKER_TABS}
|
tabs={SPEAKER_TABS}
|
||||||
tabFilter={speakerTabFilter}
|
tabFilter={speakerTabFilter}
|
||||||
filterFields={speakerFilterFields}
|
filterFields={speakerFilterFields}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { speakerOnlineLabel } from '@/lib/ui-labels'
|
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
|
||||||
import type { SpeakerRow } from '@/types/api'
|
import type { SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||||
@@ -20,7 +20,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'role',
|
accessorKey: 'role',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
cell: ({ row }) => <CategoryBadge>{speakerRoleRu(row.original.role)}</CategoryBadge>,
|
||||||
meta: { headerTitle: 'Роль' },
|
meta: { headerTitle: 'Роль' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface PeerFormDialogProps {
|
|||||||
function speakerLabel(s: SpeakerRow): string {
|
function speakerLabel(s: SpeakerRow): string {
|
||||||
if (s.role === 'master') {
|
if (s.role === 'master') {
|
||||||
const host = s.agent_domain ?? s.endpoint
|
const host = s.agent_domain ?? s.endpoint
|
||||||
return host ? `CP · ${host}` : 'CP (master)'
|
return host ? `Плоскость · ${host}` : 'Плоскость управления'
|
||||||
}
|
}
|
||||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
const ep =
|
const ep =
|
||||||
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
|
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
|
||||||
if (!ep) {
|
if (!ep) {
|
||||||
toast.error('Укажите endpoint или agent domain')
|
toast.error('Укажите конечную точку или домен агента')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const body: BgpSpeakerCreate = {
|
const body: BgpSpeakerCreate = {
|
||||||
@@ -95,7 +95,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title="Новый спикер"
|
title="Новый спикер"
|
||||||
description="BIRD-агент на ноде реплики или control plane"
|
description="BIRD-агент на ноде реплики или плоскости управления"
|
||||||
className="sm:max-w-md"
|
className="sm:max-w-md"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -122,7 +122,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
label="Роль"
|
label="Роль"
|
||||||
items={[
|
items={[
|
||||||
{ value: 'replica', label: 'Реплика' },
|
{ value: 'replica', label: 'Реплика' },
|
||||||
{ value: 'master', label: 'Мастер (CP)' },
|
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||||
]}
|
]}
|
||||||
value={role}
|
value={role}
|
||||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||||
@@ -146,7 +146,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
|
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
||||||
<Input
|
<Input
|
||||||
id="speaker-bgp-source"
|
id="speaker-bgp-source"
|
||||||
placeholder="203.0.113.10"
|
placeholder="203.0.113.10"
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function OperationsRevisionsGrid({
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
||||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
|
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
||||||
confirmLabel="Откатить"
|
confirmLabel="Откатить"
|
||||||
destructive
|
destructive
|
||||||
onConfirm={() => rollbackMutation.mutate(row.original.id)}
|
onConfirm={() => rollbackMutation.mutate(row.original.id)}
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ interface PageHeaderProps {
|
|||||||
|
|
||||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
{description ? (
|
||||||
|
<p className="text-muted-foreground max-w-prose text-sm break-words">{description}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function PanelCard({
|
|||||||
<Frame
|
<Frame
|
||||||
dense
|
dense
|
||||||
spacing={spacing}
|
spacing={spacing}
|
||||||
className={cn(panelCardClassName, className)}
|
className={cn(panelCardClassName, 'min-w-0', className)}
|
||||||
>
|
>
|
||||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
{hasHeader ? (
|
{hasHeader ? (
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ export function FrameDataGrid({
|
|||||||
}) {
|
}) {
|
||||||
const hasHeader = Boolean(title || description || actions)
|
const hasHeader = Boolean(title || description || actions)
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
{hasHeader ? (
|
{hasHeader ? (
|
||||||
<FrameHeader className="flex-row items-start justify-between gap-3 border-b">
|
<FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||||
{description ? (
|
{description ? (
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
|||||||
export { OpsDashboard } from './ops-dashboard'
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
export { FrameDataGrid } from './frame-data-grid'
|
export { FrameDataGrid } from './frame-data-grid'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
export { SettingsShell } from './settings-shell'
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
|
/**
|
||||||
|
* Shared grid column classes for hybrid KPI / Quick Actions tiles.
|
||||||
|
* Container queries only — never 5–6 columns on laptop width (tiles overlap).
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
export function kpiCols(count: number): string {
|
export function kpiCols(count: number): string {
|
||||||
if (count <= 1) return 'grid-cols-1'
|
if (count <= 1) return 'grid-cols-1'
|
||||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
if (count <= 6) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
|
||||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4'
|
||||||
return 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ export function KpiStatGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
<Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
|
||||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||||
{list.map((item, index) => (
|
{list.map((item, index) => (
|
||||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
|
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
|
||||||
@@ -27,7 +20,7 @@ interface OpsDashboardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rootClassName =
|
const rootClassName =
|
||||||
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
'text-foreground @container flex w-full min-w-0 flex-col gap-4 md:gap-6'
|
||||||
|
|
||||||
function OpsDashboardSkeleton() {
|
function OpsDashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -37,7 +30,7 @@ function OpsDashboardSkeleton() {
|
|||||||
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||||
</header>
|
</header>
|
||||||
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||||
<div className="grid gap-2 @3xl:grid-cols-2">
|
<div className="flex min-w-0 flex-col gap-4">
|
||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
@@ -47,9 +40,11 @@ function OpsDashboardSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ops dashboard: KPI → optional Quick Actions → charts → attention queue.
|
* Ops dashboard: KPI → optional Quick Actions → charts → queue.
|
||||||
|
* Queue is a sibling Frame grid — never wrap FrameDataGrid inside another Frame.
|
||||||
* @see https://reui.io/preview/base/dashboard-1
|
* @see https://reui.io/preview/base/dashboard-1
|
||||||
* @see https://reui.io/preview/base/stats-12
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
*/
|
*/
|
||||||
export function OpsDashboard({
|
export function OpsDashboard({
|
||||||
title = 'Панель управления',
|
title = 'Панель управления',
|
||||||
@@ -77,21 +72,18 @@ export function OpsDashboard({
|
|||||||
|
|
||||||
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
|
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
|
||||||
|
|
||||||
<section
|
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
|
||||||
aria-label="Аналитика"
|
|
||||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
|
||||||
>
|
|
||||||
{charts}
|
{charts}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label={queueTitle}>
|
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<FrameHeader>
|
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||||
<FrameTitle>{queueTitle}</FrameTitle>
|
{queueDescription ? (
|
||||||
<FrameDescription>{queueDescription}</FrameDescription>
|
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
|
||||||
</FrameHeader>
|
) : null}
|
||||||
<FramePanel>{queue}</FramePanel>
|
</div>
|
||||||
</Frame>
|
{queue}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,125 +1,17 @@
|
|||||||
import type { ReactNode } from 'react'
|
import { Outlet } from '@tanstack/react-router'
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
|
||||||
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
|
|
||||||
export interface SettingsTabConfig {
|
|
||||||
id: string
|
|
||||||
to: '/settings' | '/tenant-settings'
|
|
||||||
label: string
|
|
||||||
icon?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default nav for EvoBGP settings routes (`/settings`, `/tenant-settings`). */
|
|
||||||
const DEFAULT_TABS: SettingsTabConfig[] = [
|
|
||||||
{
|
|
||||||
id: 'ui',
|
|
||||||
to: '/settings',
|
|
||||||
label: 'Настройки UI',
|
|
||||||
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tenant',
|
|
||||||
to: '/tenant-settings',
|
|
||||||
label: 'Настройки BIRD',
|
|
||||||
icon: <ServerCogIcon className="size-4" aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
interface SettingsShellProps {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
tabs?: SettingsTabConfig[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings shell — Frame surface, no hairline Separator under header.
|
* Settings layout — page chrome only.
|
||||||
* @see https://reui.io/preview/base/settings-16
|
* Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
|
||||||
* @see https://reui.io/preview/base/settings-3
|
* @see https://reui.io/preview/base/settings-7
|
||||||
|
* @see https://reui.io/blocks
|
||||||
*/
|
*/
|
||||||
export function SettingsShell({
|
export function SettingsShell() {
|
||||||
title = 'Настройки',
|
|
||||||
description,
|
|
||||||
tabs = DEFAULT_TABS,
|
|
||||||
}: SettingsShellProps) {
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
||||||
const headerDescription =
|
|
||||||
description ??
|
|
||||||
(pathname.startsWith('/tenant-settings')
|
|
||||||
? 'Глобальные параметры BIRD и плоскости управления'
|
|
||||||
: 'Подключение UI и параметры плоскости управления')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
<Outlet />
|
||||||
<PageHeader title={title} description={headerDescription} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex gap-5',
|
|
||||||
isMobile ? 'flex-col' : 'flex-row items-start',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tabs.length > 1 ? (
|
|
||||||
<nav
|
|
||||||
aria-label="Разделы настроек"
|
|
||||||
className={cn(
|
|
||||||
'flex gap-1',
|
|
||||||
isMobile
|
|
||||||
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
|
||||||
: 'w-44 shrink-0 flex-col',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const isActive = pathname.startsWith(tab.to)
|
|
||||||
const className = cn(
|
|
||||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
|
||||||
isMobile && 'shrink-0',
|
|
||||||
!isMobile && 'w-full',
|
|
||||||
isActive
|
|
||||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
|
||||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
|
||||||
)
|
|
||||||
if (tab.to === '/tenant-settings') {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.id}
|
|
||||||
to="/tenant-settings"
|
|
||||||
search={{ tab: 'bird' }}
|
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{tab.icon}
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.id}
|
|
||||||
to="/settings"
|
|
||||||
search={{ tab: 'connection' }}
|
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{tab.icon}
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function DataGridColumnFilter<TData, TValue>({
|
|||||||
<div className="hidden space-x-1 lg:flex">
|
<div className="hidden space-x-1 lg:flex">
|
||||||
{selectedValues.size > 2 ? (
|
{selectedValues.size > 2 ? (
|
||||||
<Badge variant="secondary" className="px-1 font-normal">
|
<Badge variant="secondary" className="px-1 font-normal">
|
||||||
{selectedValues.size} selected
|
{selectedValues.size} выбрано
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
options
|
options
|
||||||
@@ -94,7 +94,7 @@ function DataGridColumnFilter<TData, TValue>({
|
|||||||
<div className="max-h-[300px] overflow-y-auto">
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
{filteredOptions.length === 0 ? (
|
{filteredOptions.length === 0 ? (
|
||||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||||
No results found.
|
Ничего не найдено.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
@@ -170,7 +170,7 @@ function DataGridColumnFilter<TData, TValue>({
|
|||||||
}}
|
}}
|
||||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
||||||
>
|
>
|
||||||
Clear filters
|
Сбросить фильтры
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
sizes: [5, 10, 25, 50, 100],
|
sizes: [5, 10, 25, 50, 100],
|
||||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||||
moreLimit: 5,
|
moreLimit: 5,
|
||||||
info: "{from} - {to} of {count}",
|
info: "{from}–{to} из {count}",
|
||||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||||
rowsPerPageLabel: "Rows per page",
|
rowsPerPageLabel: "Строк на странице",
|
||||||
previousPageLabel: "Go to previous page",
|
previousPageLabel: "Предыдущая страница",
|
||||||
nextPageLabel: "Go to next page",
|
nextPageLabel: "Следующая страница",
|
||||||
ellipsisText: "...",
|
ellipsisText: "...",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
|||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label="Drag to reorder row"
|
aria-label="Перетащить строку"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<GripHorizontalIcon aria-hidden="true" />
|
<GripHorizontalIcon aria-hidden="true" />
|
||||||
@@ -89,7 +89,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
|||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label="Drag to reorder row"
|
aria-label="Перетащить строку"
|
||||||
{...context.attributes}
|
{...context.attributes}
|
||||||
{...context.listeners}
|
{...context.listeners}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function DataGridTableDndHeader<TData>({
|
|||||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
aria-label="Drag to reorder"
|
aria-label="Перетащить для изменения порядка"
|
||||||
>
|
>
|
||||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -397,9 +397,9 @@ function DataGridTableVirtual<TData>({
|
|||||||
|
|
||||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||||
const loadingMoreMessage =
|
const loadingMoreMessage =
|
||||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
props.fetchingMoreMessage || props.loadingMessage || "Загрузка…"
|
||||||
const allRowsLoadedMessage =
|
const allRowsLoadedMessage =
|
||||||
props.allRowsLoadedMessage || "All records loaded"
|
props.allRowsLoadedMessage || "Все записи загружены"
|
||||||
|
|
||||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
setViewportElements({
|
setViewportElements({
|
||||||
|
|||||||
@@ -1241,7 +1241,7 @@ function DataGridTableEmpty() {
|
|||||||
colSpan={Math.max(visibleColumnCount, 1)}
|
colSpan={Math.max(visibleColumnCount, 1)}
|
||||||
className="text-muted-foreground py-6 text-center text-sm"
|
className="text-muted-foreground py-6 text-center text-sm"
|
||||||
>
|
>
|
||||||
{props.emptyMessage || "No data available"}
|
{props.emptyMessage || "Нет данных"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
@@ -1254,7 +1254,7 @@ function DataGridTableLoader() {
|
|||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
<div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium">
|
<div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium">
|
||||||
<Spinner className="size-5 opacity-60" />
|
<Spinner className="size-5 opacity-60" />
|
||||||
{props.loadingMessage || "Loading..."}
|
{props.loadingMessage || "Загрузка…"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1266,7 +1266,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={isPinned ? "Unpin row" : "Pin row"}
|
aria-label={isPinned ? "Открепить строку" : "Закрепить строку"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isPinned) {
|
if (isPinned) {
|
||||||
row.pin(false)
|
row.pin(false)
|
||||||
@@ -1322,7 +1322,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
aria-label="Select row"
|
aria-label="Выбрать строку"
|
||||||
className="align-[inherit]"
|
className="align-[inherit]"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -1341,7 +1341,7 @@ function DataGridTableRowSelectAll() {
|
|||||||
indeterminate={isSomeSelected && !isAllSelected}
|
indeterminate={isSomeSelected && !isAllSelected}
|
||||||
disabled={isLoading || recordCount === 0}
|
disabled={isLoading || recordCount === 0}
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
aria-label="Select all"
|
aria-label="Выбрать все"
|
||||||
className="align-[inherit]"
|
className="align-[inherit]"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -1408,7 +1408,7 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
|
|||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{props.loadingMessage || "Loading..."}
|
{props.loadingMessage || "Загрузка…"}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -123,75 +123,70 @@ export interface FilterI18nConfig {
|
|||||||
|
|
||||||
// Default English i18n configuration
|
// Default English i18n configuration
|
||||||
export const DEFAULT_I18N: FilterI18nConfig = {
|
export const DEFAULT_I18N: FilterI18nConfig = {
|
||||||
// UI Labels
|
addFilter: "Фильтр",
|
||||||
addFilter: "Filter",
|
searchFields: "Фильтр…",
|
||||||
searchFields: "Filter...",
|
noFieldsFound: "Поля не найдены.",
|
||||||
noFieldsFound: "No filters found.",
|
noResultsFound: "Ничего не найдено.",
|
||||||
noResultsFound: "No results found.",
|
select: "Выбрать…",
|
||||||
select: "Select...",
|
true: "Да",
|
||||||
true: "True",
|
false: "Нет",
|
||||||
false: "False",
|
min: "Мин.",
|
||||||
min: "Min",
|
max: "Макс.",
|
||||||
max: "Max",
|
to: "—",
|
||||||
to: "to",
|
typeAndPressEnter: "Введите и нажмите Enter",
|
||||||
typeAndPressEnter: "Type and press Enter to add tag",
|
selected: "выбрано",
|
||||||
selected: "selected",
|
selectedCount: "выбрано",
|
||||||
selectedCount: "selected",
|
|
||||||
percent: "%",
|
percent: "%",
|
||||||
defaultCurrency: "$",
|
defaultCurrency: "$",
|
||||||
defaultColor: "#000000",
|
defaultColor: "#000000",
|
||||||
addFilterTitle: "Add filter",
|
addFilterTitle: "Добавить фильтр",
|
||||||
|
|
||||||
// Operators
|
|
||||||
operators: {
|
operators: {
|
||||||
is: "is",
|
is: "равно",
|
||||||
isNot: "is not",
|
isNot: "не равно",
|
||||||
isAnyOf: "is any of",
|
isAnyOf: "любое из",
|
||||||
isNotAnyOf: "is not any of",
|
isNotAnyOf: "кроме",
|
||||||
includesAll: "includes all",
|
includesAll: "включает все",
|
||||||
excludesAll: "excludes all",
|
excludesAll: "исключает все",
|
||||||
before: "before",
|
before: "до",
|
||||||
after: "after",
|
after: "после",
|
||||||
between: "between",
|
between: "между",
|
||||||
notBetween: "not between",
|
notBetween: "вне диапазона",
|
||||||
contains: "contains",
|
contains: "содержит",
|
||||||
notContains: "does not contain",
|
notContains: "не содержит",
|
||||||
startsWith: "starts with",
|
startsWith: "начинается с",
|
||||||
endsWith: "ends with",
|
endsWith: "заканчивается на",
|
||||||
isExactly: "is exactly",
|
isExactly: "точно",
|
||||||
equals: "equals",
|
equals: "равно",
|
||||||
notEquals: "not equals",
|
notEquals: "не равно",
|
||||||
greaterThan: "greater than",
|
greaterThan: "больше",
|
||||||
lessThan: "less than",
|
lessThan: "меньше",
|
||||||
overlaps: "overlaps",
|
overlaps: "пересекается",
|
||||||
includes: "includes",
|
includes: "включает",
|
||||||
excludes: "excludes",
|
excludes: "исключает",
|
||||||
includesAllOf: "includes all of",
|
includesAllOf: "включает все",
|
||||||
includesAnyOf: "includes any of",
|
includesAnyOf: "включает любое",
|
||||||
empty: "is empty",
|
empty: "пусто",
|
||||||
notEmpty: "is not empty",
|
notEmpty: "не пусто",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Placeholders
|
|
||||||
placeholders: {
|
placeholders: {
|
||||||
enterField: (fieldType: string) => `Enter ${fieldType}...`,
|
enterField: (fieldType: string) => `Введите ${fieldType}…`,
|
||||||
selectField: "Select...",
|
selectField: "Выбрать…",
|
||||||
searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`,
|
searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}…`,
|
||||||
enterKey: "Enter key...",
|
enterKey: "Введите ключ…",
|
||||||
enterValue: "Enter value...",
|
enterValue: "Введите значение…",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
helpers: {
|
helpers: {
|
||||||
formatOperator: (operator: string) => operator.replace(/_/g, " "),
|
formatOperator: (operator: string) => operator.replace(/_/g, " "),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Validation
|
|
||||||
validation: {
|
validation: {
|
||||||
invalidEmail: "Invalid email format",
|
invalidEmail: "Некорректный email",
|
||||||
invalidUrl: "Invalid URL format",
|
invalidUrl: "Некорректный URL",
|
||||||
invalidTel: "Invalid phone format",
|
invalidTel: "Некорректный телефон",
|
||||||
invalid: "Invalid input format",
|
invalid: "Некорректное значение",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function ScheduleAgendaPanel({
|
|||||||
return (
|
return (
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Календарь задач"
|
title="Календарь задач"
|
||||||
description="Задачи refresh и apply по дням"
|
description="Задачи обновления и применения по дням"
|
||||||
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col lg:flex-row">
|
<div className="flex flex-col lg:flex-row">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function ScheduleModulesGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
{row.original.cron_expr ??
|
{row.original.cron_expr ??
|
||||||
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec}s` : '—')}
|
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec} с` : '—')}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Расписание' },
|
meta: { headerTitle: 'Расписание' },
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function AppearanceSettingsTab() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Тема интерфейса"
|
title="Тема интерфейса"
|
||||||
description="Быстрый переключатель также доступен в боковой панели"
|
description="Быстрый переключатель также доступен в боковой панели"
|
||||||
@@ -61,6 +61,7 @@ export function AppearanceSettingsTab() {
|
|||||||
мгновенно
|
мгновенно
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
|
stacked
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
@@ -72,7 +73,7 @@ export function AppearanceSettingsTab() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="Тема интерфейса"
|
aria-label="Тема интерфейса"
|
||||||
className="w-full sm:w-auto"
|
className="w-full min-w-0"
|
||||||
>
|
>
|
||||||
{THEME_OPTIONS.map((option) => {
|
{THEME_OPTIONS.map((option) => {
|
||||||
const Icon = option.icon
|
const Icon = option.icon
|
||||||
@@ -89,16 +90,16 @@ export function AppearanceSettingsTab() {
|
|||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Дашборд"
|
title="Обзор"
|
||||||
description="Блоки на экране «Обзор»"
|
description="Блоки на экране «Обзор»"
|
||||||
>
|
>
|
||||||
<SettingsFieldGroup
|
<SettingsFieldGroup
|
||||||
legend="Быстрые действия"
|
legend="Быстрые действия"
|
||||||
description="Показывать KPI-like плитки быстрых переходов под метриками."
|
description="Показывать плитки быстрых переходов под метриками."
|
||||||
>
|
>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Быстрые действия"
|
title="Быстрые действия"
|
||||||
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
|
description="Блок с частыми переходами (модули, сеть, деплой) на экране «Обзор»."
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Save } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import { FrameDataGrid } from '@/components/reui-kit'
|
||||||
|
import { SelectMenu } from '@/components/select-field'
|
||||||
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
|
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
|
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||||
|
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BIRD_SETTING_KEYS,
|
||||||
|
REVISION_SETTING_KEYS,
|
||||||
|
RUNTIME_LOGS_SETTING_KEYS,
|
||||||
|
buildPayload,
|
||||||
|
partitionSettings,
|
||||||
|
settingsKeys,
|
||||||
|
settingsQueryOptions,
|
||||||
|
type BirdSettingKey,
|
||||||
|
} from '@/queries/settings'
|
||||||
|
import { apiMutate } from '@/lib/api-client'
|
||||||
|
|
||||||
|
const RUNTIME_LOGS_ENABLED_ITEMS = [
|
||||||
|
{ value: 'true', label: 'Вкл' },
|
||||||
|
{ value: 'false', label: 'Выкл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const RUNTIME_LOGS_MODE_ITEMS = [
|
||||||
|
{ value: 'truncate', label: 'Обнулить файл' },
|
||||||
|
{ value: 'delete', label: 'Удалить файл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||||
|
bird_router_id: 'Router ID',
|
||||||
|
bird_local_ipv4: 'Локальный IPv4',
|
||||||
|
bird_local_ipv6: 'Локальный IPv6',
|
||||||
|
bird_local_asn: 'Локальный ASN',
|
||||||
|
bird_bgp_source_ipv4: 'Исходный IPv4 BGP',
|
||||||
|
bird_bgp_source_ipv6: 'Исходный IPv6 BGP',
|
||||||
|
peer_discovery_enabled: 'Автообнаружение пиров',
|
||||||
|
peer_discovery_ranges_v4: 'CIDR автообнаружения IPv4',
|
||||||
|
peer_discovery_ranges_v6: 'CIDR автообнаружения IPv6',
|
||||||
|
peer_discovery_require_external: 'Только внешние ASN',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BIRD_BOOL_ITEMS = [
|
||||||
|
{ value: 'true', label: 'Вкл' },
|
||||||
|
{ value: 'false', label: 'Выкл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
||||||
|
peer_discovery_enabled:
|
||||||
|
'Динамический диапазон соседей в BIRD (карантин import/export none). Нужны CIDR.',
|
||||||
|
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
|
||||||
|
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
|
||||||
|
peer_discovery_require_external: 'Диапазон соседей с внешним (любым чужим) ASN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BirdSettingsTab() {
|
||||||
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
||||||
|
|
||||||
|
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
||||||
|
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
||||||
|
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (partitioned) {
|
||||||
|
setBirdForm({ ...partitioned.bird })
|
||||||
|
setRevisionForm({ ...partitioned.revision })
|
||||||
|
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settingsQ.data])
|
||||||
|
|
||||||
|
const patchMutation = useMutation({
|
||||||
|
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
||||||
|
apiMutate('/v1/settings', 'PATCH', payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Параметры сохранены')
|
||||||
|
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function saveBird() {
|
||||||
|
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
||||||
|
}
|
||||||
|
function saveRevision() {
|
||||||
|
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
||||||
|
}
|
||||||
|
function saveRuntimeLogs() {
|
||||||
|
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<SettingsCard
|
||||||
|
title="Плоскость управления BIRD"
|
||||||
|
description="Глобальные параметры BIRD для обновления модулей и применения конфигурации. Сохранение доступно оператору."
|
||||||
|
footer={
|
||||||
|
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-64" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="BIRD"
|
||||||
|
description="Параметры демона и автообнаружения пиров."
|
||||||
|
>
|
||||||
|
{BIRD_SETTING_KEYS.map((key, index) => (
|
||||||
|
<SettingsSettingField
|
||||||
|
key={key}
|
||||||
|
title={BIRD_LABELS[key]}
|
||||||
|
description={BIRD_HINTS[key]}
|
||||||
|
labelFor={key}
|
||||||
|
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||||
|
stacked
|
||||||
|
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||||
|
>
|
||||||
|
{key === 'peer_discovery_enabled' ||
|
||||||
|
key === 'peer_discovery_require_external' ? (
|
||||||
|
<SelectMenu
|
||||||
|
id={key}
|
||||||
|
items={[...BIRD_BOOL_ITEMS]}
|
||||||
|
value={birdForm[key] || 'false'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||||
|
}
|
||||||
|
placeholder="Выкл"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={key}
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={birdForm[key] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={BIRD_LABELS[key]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSettingField>
|
||||||
|
))}
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Ревизии"
|
||||||
|
description="Время хранения ревизий в БД"
|
||||||
|
footer={
|
||||||
|
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-32" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Хранение"
|
||||||
|
description="Срок хранения ревизий в минутах."
|
||||||
|
>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Срок хранения"
|
||||||
|
description="Сколько минут хранить ревизии конфигурации."
|
||||||
|
labelFor="revision_retention_minutes"
|
||||||
|
stacked
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="revision_retention_minutes"
|
||||||
|
type="number"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={revisionForm.revision_retention_minutes ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRevisionForm((s) => ({
|
||||||
|
...s,
|
||||||
|
revision_retention_minutes: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Файловые логи"
|
||||||
|
description="Автоматическая очистка логов"
|
||||||
|
footer={
|
||||||
|
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-48" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Авто-очистка"
|
||||||
|
description="Расписание и лимиты файловых логов runtime."
|
||||||
|
>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Авто-очистка"
|
||||||
|
description="Включить периодическую очистку файловых логов."
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<SelectMenu
|
||||||
|
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||||
|
placeholder="Выберите"
|
||||||
|
onValueChange={(v) =>
|
||||||
|
v &&
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_enabled: v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Макс. размер файла"
|
||||||
|
description="Порог в мегабайтах, после которого срабатывает очистка."
|
||||||
|
labelFor="runtime_logs_max_file_mb"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="runtime_logs_max_file_mb"
|
||||||
|
type="number"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_max_file_mb: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Расписание"
|
||||||
|
description="Cron-выражение для авто-очистки."
|
||||||
|
labelFor="runtime_logs_auto_schedule"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="runtime_logs_auto_schedule"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_schedule: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Режим очистки"
|
||||||
|
description="Обнулить файл или удалить его."
|
||||||
|
stacked
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<SelectMenu
|
||||||
|
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||||
|
placeholder="Выберите"
|
||||||
|
onValueChange={(v) =>
|
||||||
|
v &&
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_mode: v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<FrameDataGrid
|
||||||
|
title="Дополнительные параметры"
|
||||||
|
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned?.additional ?? []}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
empty={(partitioned?.additional ?? []).length === 0}
|
||||||
|
emptyTitle="Нет дополнительных параметров"
|
||||||
|
skeleton={<div className="h-32" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => (
|
||||||
|
<SettingsKvGrid
|
||||||
|
items={items}
|
||||||
|
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</FrameDataGrid>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link, useNavigate } from '@tanstack/react-router'
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Save } from 'lucide-react'
|
import { KeyRound, Save } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import { SettingRow } from '@/components/settings/setting-row'
|
|||||||
import { SettingsCard } from '@/components/settings/settings-card'
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import {
|
import {
|
||||||
DEV_API_TOKEN,
|
DEV_API_TOKEN,
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
TOKEN_STORAGE_KEY,
|
TOKEN_STORAGE_KEY,
|
||||||
} from '@/lib/api-client'
|
} from '@/lib/api-client'
|
||||||
import { authKeys } from '@/queries/auth'
|
import { authKeys } from '@/queries/auth'
|
||||||
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
|
||||||
@@ -50,21 +50,22 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
{tokenRequired ? (
|
{tokenRequired ? (
|
||||||
<Alert>
|
<Alert variant="warning">
|
||||||
|
<KeyRound aria-hidden />
|
||||||
|
<AlertTitle>Нужен API-токен</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
|
Сохраните токен ниже или нажмите «Использовать dev».
|
||||||
dev».
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Подключение к API"
|
title="Подключение к API"
|
||||||
description="Токен хранится только в этом браузере (localStorage)"
|
description="Токен хранится только в этом браузере"
|
||||||
footer={
|
footer={
|
||||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
<div className="flex w-full min-w-0 flex-wrap justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={useDevToken}>
|
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||||
Использовать dev
|
Использовать dev
|
||||||
</Button>
|
</Button>
|
||||||
@@ -83,8 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
|||||||
title="Токен для запросов"
|
title="Токен для запросов"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
|
Ключ для заголовка Authorization. Управление ключами арендатора — в разделе{' '}
|
||||||
ключами tenant — в разделе{' '}
|
|
||||||
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||||
Права доступа
|
Права доступа
|
||||||
</Link>
|
</Link>
|
||||||
@@ -93,16 +93,18 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
|||||||
}
|
}
|
||||||
titleAddon={
|
titleAddon={
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm">
|
||||||
localStorage
|
браузер
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
labelFor="settings-token"
|
labelFor="settings-token"
|
||||||
|
stacked
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="settings-token"
|
id="settings-token"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="w-full min-w-0"
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(e) => setTokenValue(e.target.value)}
|
onChange={(e) => setTokenValue(e.target.value)}
|
||||||
placeholder="dev или API-ключ"
|
placeholder="dev или API-ключ"
|
||||||
|
|||||||
@@ -23,42 +23,45 @@ import {
|
|||||||
const ADMIN_SECTIONS = [
|
const ADMIN_SECTIONS = [
|
||||||
{
|
{
|
||||||
id: 'tenant-settings',
|
id: 'tenant-settings',
|
||||||
to: '/tenant-settings' as const,
|
to: '/settings' as const,
|
||||||
|
search: { tab: 'bird' as const },
|
||||||
title: 'Параметры арендатора',
|
title: 'Параметры арендатора',
|
||||||
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
||||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
badge: { label: 'оператор', variant: 'warning-light' as const },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'access',
|
id: 'access',
|
||||||
to: '/access' as const,
|
to: '/access' as const,
|
||||||
|
search: undefined,
|
||||||
title: 'Права доступа',
|
title: 'Права доступа',
|
||||||
description: 'API-ключи tenant, роли и управление доступом.',
|
description: 'API-ключи арендатора, роли и управление доступом.',
|
||||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
badge: { label: 'оператор', variant: 'warning-light' as const },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'monitoring',
|
id: 'monitoring',
|
||||||
to: '/monitoring' as const,
|
to: '/monitoring' as const,
|
||||||
|
search: undefined,
|
||||||
title: 'Мониторинг',
|
title: 'Мониторинг',
|
||||||
description: 'Метрики, состояние jobs и observability control plane.',
|
description: 'Метрики, состояние задач и наблюдаемость плоскости управления.',
|
||||||
icon: <ActivityIcon aria-hidden="true" />,
|
icon: <ActivityIcon aria-hidden="true" />,
|
||||||
badge: { label: 'viewer+', variant: 'info-light' as const },
|
badge: { label: 'просмотр+', variant: 'info-light' as const },
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function SectionsSettingsTab() {
|
export function SectionsSettingsTab() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Разделы control plane"
|
title="Разделы плоскости управления"
|
||||||
description="Параметры tenant и операции — отдельно от настроек браузера"
|
description="Параметры арендатора и операции — отдельно от настроек браузера"
|
||||||
>
|
>
|
||||||
<ItemGroup className="gap-0">
|
<ItemGroup className="gap-0">
|
||||||
{ADMIN_SECTIONS.map((section, index) => (
|
{ADMIN_SECTIONS.map((section, index) => (
|
||||||
<Fragment key={section.id}>
|
<Fragment key={section.id}>
|
||||||
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||||
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
<Item className="min-h-0 min-w-0 flex-wrap items-center gap-4 px-5 py-3.5">
|
||||||
<ItemMedia className="self-center!">
|
<ItemMedia className="self-center!">
|
||||||
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||||
{section.icon}
|
{section.icon}
|
||||||
@@ -66,7 +69,7 @@ export function SectionsSettingsTab() {
|
|||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
|
|
||||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||||
<ItemTitle className="gap-2 leading-5">
|
<ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
|
||||||
{section.title}
|
{section.title}
|
||||||
<Badge variant={section.badge.variant} size="xs">
|
<Badge variant={section.badge.variant} size="xs">
|
||||||
{section.badge.label}
|
{section.badge.label}
|
||||||
@@ -76,7 +79,17 @@ export function SectionsSettingsTab() {
|
|||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
<ItemActions className="shrink-0 justify-end self-center">
|
<ItemActions className="shrink-0 justify-end self-center">
|
||||||
<Button variant="outline" size="sm" render={<Link to={section.to} />}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
render={
|
||||||
|
section.to === '/settings' ? (
|
||||||
|
<Link to="/settings" search={{ tab: 'bird' }} />
|
||||||
|
) : (
|
||||||
|
<Link to={section.to} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
Открыть
|
Открыть
|
||||||
</Button>
|
</Button>
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export function SessionSettingsTab() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
|
<SettingsCard title="Текущая сессия" description="Проверка токена через API сессии">
|
||||||
<QueryState
|
<QueryState
|
||||||
data={sessionQ.data}
|
data={sessionQ.data}
|
||||||
isLoading={sessionQ.isLoading && hasStoredToken}
|
isLoading={sessionQ.isLoading && hasStoredToken}
|
||||||
@@ -58,15 +58,17 @@ export function SessionSettingsTab() {
|
|||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
|
|
||||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||||
<ItemTitle className="gap-2 leading-5">
|
<ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
|
||||||
Этот браузер
|
Этот браузер
|
||||||
<Badge variant="success-light" size="xs">
|
<Badge variant="success-light" size="xs">
|
||||||
Активна
|
Активна
|
||||||
</Badge>
|
</Badge>
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
<ItemDescription className="leading-5">
|
<ItemDescription className="leading-5">
|
||||||
Tenant:{' '}
|
Арендатор:{' '}
|
||||||
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
<code className="text-foreground font-mono text-xs break-all">
|
||||||
|
{session.tenant_id}
|
||||||
|
</code>
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
@@ -82,7 +84,7 @@ export function SessionSettingsTab() {
|
|||||||
>
|
>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Роль"
|
title="Роль"
|
||||||
description="Определяет доступ к операциям control plane и CRUD."
|
description="Определяет доступ к операциям плоскости управления и изменению данных."
|
||||||
titleAddon={
|
titleAddon={
|
||||||
sessionQ.data ? (
|
sessionQ.data ? (
|
||||||
<Badge variant="info-light" size="sm">
|
<Badge variant="info-light" size="sm">
|
||||||
@@ -94,12 +96,13 @@ export function SessionSettingsTab() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
stacked
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
{sessionQ.data ? (
|
{sessionQ.data ? (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm break-words">
|
||||||
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
|
Ключ с ролью <strong>{ROLE_LABELS[sessionQ.data.role] ?? sessionQ.data.role}</strong> у арендатора{' '}
|
||||||
<code className="font-mono text-xs">{sessionQ.data.tenant_id}</code>.
|
<code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
|
|||||||
@@ -10,31 +10,59 @@ import {
|
|||||||
FieldTitle,
|
FieldTitle,
|
||||||
} from '@evobgp/ui/components/field'
|
} from '@evobgp/ui/components/field'
|
||||||
|
|
||||||
interface SettingRowProps {
|
export interface SettingRowProps {
|
||||||
title: string
|
title: string
|
||||||
description?: ReactNode
|
description?: ReactNode
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
last?: boolean
|
last?: boolean
|
||||||
|
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
|
||||||
|
separated?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
stacked?: boolean
|
||||||
labelFor?: string
|
labelFor?: string
|
||||||
contentClassName?: string
|
contentClassName?: string
|
||||||
|
className?: string
|
||||||
titleAddon?: ReactNode
|
titleAddon?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings row — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */
|
/**
|
||||||
|
* Settings row — settings-3 / settings-16 DNA.
|
||||||
|
* Control is flex-1 + min-w-0 (never a fixed w-78) so label and input do not overlap.
|
||||||
|
* @see https://reui.io/preview/base/settings-3
|
||||||
|
* @see https://reui.io/preview/base/settings-16
|
||||||
|
*/
|
||||||
export function SettingRow({
|
export function SettingRow({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
last,
|
last,
|
||||||
|
separated = false,
|
||||||
|
compact,
|
||||||
|
stacked,
|
||||||
labelFor,
|
labelFor,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
|
className,
|
||||||
titleAddon,
|
titleAddon,
|
||||||
}: SettingRowProps) {
|
}: SettingRowProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Field orientation="responsive" className="gap-4 px-5 py-3">
|
<Field
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
orientation={stacked ? 'vertical' : 'responsive'}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
className={cn(
|
||||||
|
'min-w-0 gap-4 px-5 py-3.5',
|
||||||
|
!stacked &&
|
||||||
|
'@md/field-group:items-start @md/field-group:*:min-w-0 @md/field-group:*:max-w-full',
|
||||||
|
!last && !separated && 'border-border/40 border-b',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-0 flex-1 flex-col gap-0.5',
|
||||||
|
!stacked && '@md/field-group:max-w-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
{labelFor ? (
|
{labelFor ? (
|
||||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||||
) : (
|
) : (
|
||||||
@@ -44,20 +72,33 @@ export function SettingRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{description ? (
|
{description ? (
|
||||||
<FieldDescription>{description}</FieldDescription>
|
<FieldDescription className="text-sm break-words">{description}</FieldDescription>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FieldContent
|
<FieldContent
|
||||||
className={cn('min-w-0 @md/field-group:w-78', contentClassName)}
|
className={cn(
|
||||||
|
'w-full min-w-0 @md/field-group:flex-1',
|
||||||
|
stacked
|
||||||
|
? 'max-w-none'
|
||||||
|
: compact
|
||||||
|
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
|
||||||
|
: '@md/field-group:max-w-[min(100%,34rem)]',
|
||||||
|
contentClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full justify-start @md/field-group:justify-end">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex w-full min-w-0 justify-start',
|
||||||
|
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</FieldContent>
|
</FieldContent>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{!last ? <FieldSeparator /> : null}
|
{separated && !last ? <FieldSeparator /> : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function SettingsCard({
|
|||||||
footerClassName,
|
footerClassName,
|
||||||
}: SettingsCardProps) {
|
}: SettingsCardProps) {
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
<FrameHeader className="gap-0 border-b px-5 py-3">
|
<FrameHeader className="gap-0 border-b px-5 py-3">
|
||||||
<FrameTitle>{title}</FrameTitle>
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
|||||||
@@ -1,45 +1,138 @@
|
|||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
import { useIsMobile } from '@/hooks/use-mobile'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@evobgp/ui/components/tabs'
|
||||||
|
|
||||||
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
||||||
|
import { BirdSettingsTab } from './bird-settings-tab'
|
||||||
import { ConnectionSettingsTab } from './connection-settings-tab'
|
import { ConnectionSettingsTab } from './connection-settings-tab'
|
||||||
import { SectionsSettingsTab } from './sections-settings-tab'
|
|
||||||
import { SessionSettingsTab } from './session-settings-tab'
|
import { SessionSettingsTab } from './session-settings-tab'
|
||||||
import {
|
import {
|
||||||
SETTINGS_TAB_ITEMS,
|
SETTINGS_TAB_ITEMS,
|
||||||
type SettingsTab,
|
type SettingsSection,
|
||||||
|
type SettingsTabItem,
|
||||||
} from './settings-tabs-data'
|
} from './settings-tabs-data'
|
||||||
|
|
||||||
|
function UiSettingsPanel({ tokenRequired }: { tokenRequired: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
||||||
|
<SessionSettingsTab />
|
||||||
|
<AppearanceSettingsTab />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsNavigation({
|
||||||
|
isMobile,
|
||||||
|
activeValue,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
isMobile: boolean
|
||||||
|
activeValue: string
|
||||||
|
items: SettingsTabItem[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('min-w-0', isMobile ? 'w-full' : 'w-40 shrink-0')}>
|
||||||
|
{isMobile ? (
|
||||||
|
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||||
|
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
|
||||||
|
{items.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
|
||||||
|
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
|
||||||
|
{items.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
|
||||||
|
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified settings — settings-7 AccountSettings 1:1 (header + vertical Tabs).
|
||||||
|
* Surface of cards stays Frame (`SettingsCard`), not shadcn Card.
|
||||||
|
* @see https://reui.io/preview/base/settings-7
|
||||||
|
* @see https://reui.io/blocks
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
export function SettingsPageShell({
|
export function SettingsPageShell({
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
tokenRequired,
|
tokenRequired,
|
||||||
|
showBird,
|
||||||
}: {
|
}: {
|
||||||
activeTab: SettingsTab
|
activeTab: SettingsSection
|
||||||
onTabChange: (tab: SettingsTab) => void
|
onTabChange: (tab: SettingsSection) => void
|
||||||
tokenRequired: boolean
|
tokenRequired: boolean
|
||||||
|
showBird: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const items = showBird
|
||||||
|
? SETTINGS_TAB_ITEMS
|
||||||
|
: SETTINGS_TAB_ITEMS.filter((tab) => tab.value === 'ui')
|
||||||
|
const resolvedTab = activeTab === 'bird' && showBird ? 'bird' : 'ui'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BadgeTabs
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
|
||||||
value={activeTab}
|
<header className="px-1">
|
||||||
onValueChange={(value) => onTabChange(value as SettingsTab)}
|
<h1 className="text-xl font-semibold tracking-tight">Настройки</h1>
|
||||||
items={SETTINGS_TAB_ITEMS.map((tab) => ({
|
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||||
value: tab.value,
|
Интерфейс UI и глобальные параметры BIRD в одном разделе.
|
||||||
label: tab.label,
|
</p>
|
||||||
icon: tab.icon,
|
</header>
|
||||||
}))}
|
|
||||||
>
|
<Tabs
|
||||||
<TabsContent value="connection" className="mt-0">
|
value={resolvedTab}
|
||||||
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
onValueChange={(value) => onTabChange(value as SettingsSection)}
|
||||||
</TabsContent>
|
orientation={isMobile ? 'horizontal' : 'vertical'}
|
||||||
<TabsContent value="session" className="mt-0">
|
className="w-full gap-4 lg:gap-8"
|
||||||
<SessionSettingsTab />
|
>
|
||||||
</TabsContent>
|
<SettingsNavigation
|
||||||
<TabsContent value="appearance" className="mt-0">
|
isMobile={isMobile}
|
||||||
<AppearanceSettingsTab />
|
activeValue={resolvedTab}
|
||||||
</TabsContent>
|
items={items}
|
||||||
<TabsContent value="sections" className="mt-0">
|
/>
|
||||||
<SectionsSettingsTab />
|
|
||||||
</TabsContent>
|
<div className="min-w-0 flex-1">
|
||||||
</BadgeTabs>
|
<TabsContent value="ui" className="mt-0">
|
||||||
|
<UiSettingsPanel tokenRequired={tokenRequired} />
|
||||||
|
</TabsContent>
|
||||||
|
{showBird ? (
|
||||||
|
<TabsContent value="bird" className="mt-0">
|
||||||
|
<BirdSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { type ComponentProps, type ReactNode } from 'react'
|
import { type ComponentProps, type ReactNode } from 'react'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { SettingRow } from '@/components/settings/setting-row'
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldContent,
|
|
||||||
FieldDescription,
|
|
||||||
FieldLabel,
|
|
||||||
FieldSeparator,
|
|
||||||
FieldTitle,
|
|
||||||
} from '@evobgp/ui/components/field'
|
|
||||||
|
|
||||||
/** settings-3 row pattern for tenant/UI settings. */
|
/** Tenant settings row — same SettingRow DNA as UI settings. */
|
||||||
export function SettingsSettingField({
|
export function SettingsSettingField({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -20,38 +12,34 @@ export function SettingsSettingField({
|
|||||||
last,
|
last,
|
||||||
labelFor,
|
labelFor,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
|
stacked,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: ReactNode
|
description?: ReactNode
|
||||||
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
last?: boolean
|
last?: boolean
|
||||||
labelFor?: string
|
labelFor?: string
|
||||||
contentClassName?: string
|
contentClassName?: string
|
||||||
|
stacked?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<SettingRow
|
||||||
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
title={title}
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
description={description}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
last={last}
|
||||||
{labelFor ? (
|
labelFor={labelFor}
|
||||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
contentClassName={contentClassName}
|
||||||
) : (
|
stacked={stacked}
|
||||||
<FieldTitle>{title}</FieldTitle>
|
titleAddon={
|
||||||
)}
|
badge ? (
|
||||||
{badge ? (
|
<Badge variant={badge.variant} size="sm">
|
||||||
<Badge variant={badge.variant} size="sm">
|
{badge.label}
|
||||||
{badge.label}
|
</Badge>
|
||||||
</Badge>
|
) : null
|
||||||
) : null}
|
}
|
||||||
</div>
|
>
|
||||||
<FieldDescription className="text-xs">{description}</FieldDescription>
|
{children}
|
||||||
</div>
|
</SettingRow>
|
||||||
<FieldContent className={cn('min-w-0 @md/field-group:w-78', contentClassName)}>
|
|
||||||
{children}
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
{!last ? <FieldSeparator /> : null}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,32 @@
|
|||||||
import {
|
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||||
KeyRoundIcon,
|
|
||||||
MonitorSmartphoneIcon,
|
|
||||||
PaletteIcon,
|
|
||||||
SlidersHorizontalIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { type ReactNode } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
|
export type SettingsSection = 'ui' | 'bird'
|
||||||
|
|
||||||
export type SettingsTabItem = {
|
export type SettingsTabItem = {
|
||||||
value: SettingsTab
|
value: SettingsSection
|
||||||
label: string
|
label: string
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Side rail items — settings-7 AccountSettings DNA. */
|
||||||
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||||
{
|
{
|
||||||
value: 'connection',
|
value: 'ui',
|
||||||
label: 'Подключение',
|
label: 'Настройки UI',
|
||||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
icon: <SettingsIcon aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'session',
|
value: 'bird',
|
||||||
label: 'Сессия',
|
label: 'Настройки BIRD',
|
||||||
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
|
icon: <ServerCogIcon aria-hidden="true" />,
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'appearance',
|
|
||||||
label: 'Оформление',
|
|
||||||
icon: <PaletteIcon aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'sections',
|
|
||||||
label: 'Разделы',
|
|
||||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function parseSettingsTab(value: unknown): SettingsTab {
|
const LEGACY_BIRD = new Set(['bird', 'revision', 'runtime-logs', 'additional'])
|
||||||
if (
|
|
||||||
value === 'session' ||
|
/** Maps current and legacy `?tab=` values onto the unified settings rail. */
|
||||||
value === 'appearance' ||
|
export function parseSettingsSection(value: unknown): SettingsSection {
|
||||||
value === 'sections'
|
if (typeof value === 'string' && LEGACY_BIRD.has(value)) return 'bird'
|
||||||
) {
|
return 'ui'
|
||||||
return value
|
|
||||||
}
|
|
||||||
return 'connection'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ export function AnalyticsDashboardSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={dashboardMainSidebarClassName}>
|
<div className={dashboardMainSidebarClassName}>
|
||||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-8" />
|
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
|
||||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-4" />
|
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className={chartPanelGridClassName}>
|
||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { apiKeyRoleRu } from '@/lib/ui-labels'
|
|||||||
|
|
||||||
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
|
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
|
||||||
{ value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
|
{ value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
|
||||||
{ value: 'editor', label: `${apiKeyRoleRu('editor')} — CRUD без применения` },
|
{ value: 'editor', label: `${apiKeyRoleRu('editor')} — изменение данных без применения` },
|
||||||
{ value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
|
{ value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
|
||||||
{ value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
|
{ value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -346,11 +346,17 @@ export function permissionForPath(pathname: string): string | null {
|
|||||||
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||||
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||||
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||||
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
|
if (pathname.startsWith('/tenant-settings') || pathname.startsWith('/settings')) {
|
||||||
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
|
return canOpenSettings() ? null : 'bgp:settings:read'
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Unified `/settings` — UI (`settings:read`) or BIRD (`tenant_settings:admin`). */
|
||||||
|
export function canOpenSettings(): boolean {
|
||||||
|
return can('bgp:settings:read') || can('bgp:tenant_settings:admin')
|
||||||
|
}
|
||||||
|
|
||||||
const FALLBACK_PATH = '/dashboard'
|
const FALLBACK_PATH = '/dashboard'
|
||||||
|
|
||||||
/** First path in the sidebar the current user may open. */
|
/** First path in the sidebar the current user may open. */
|
||||||
@@ -365,7 +371,6 @@ export function firstAllowedPath(): string {
|
|||||||
'/schedule',
|
'/schedule',
|
||||||
'/monitoring',
|
'/monitoring',
|
||||||
'/access',
|
'/access',
|
||||||
'/tenant-settings',
|
|
||||||
'/settings',
|
'/settings',
|
||||||
]
|
]
|
||||||
for (const path of candidates) {
|
for (const path of candidates) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const DATA_GRID_MESSAGES_RU = {
|
|||||||
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
|
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
|
||||||
...DATA_GRID_TABLE_LAYOUT,
|
...DATA_GRID_TABLE_LAYOUT,
|
||||||
dense: true,
|
dense: true,
|
||||||
|
width: 'auto',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTextGlobalFilter<TData extends object>(
|
export function createTextGlobalFilter<TData extends object>(
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('isReadyCheckOk', () => {
|
|||||||
|
|
||||||
describe('readyCheckStatusLabel', () => {
|
describe('readyCheckStatusLabel', () => {
|
||||||
it('labels memory and failures', () => {
|
it('labels memory and failures', () => {
|
||||||
expect(readyCheckStatusLabel('memory', true)).toBe('Memory')
|
expect(readyCheckStatusLabel('memory', true)).toBe('В памяти')
|
||||||
expect(readyCheckStatusLabel('ok', true)).toBe('В норме')
|
expect(readyCheckStatusLabel('ok', true)).toBe('В норме')
|
||||||
expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно')
|
expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function readyCheckStatusLabel(value: ReadyCheckValue, ok: boolean): stri
|
|||||||
}
|
}
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const n = value.trim().toLowerCase()
|
const n = value.trim().toLowerCase()
|
||||||
if (n === 'memory') return 'Memory'
|
if (n === 'memory') return 'В памяти'
|
||||||
if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме'
|
if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме'
|
||||||
if (n) return value
|
if (n) return value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,18 +61,45 @@ const JOB_STATUS_RU: Record<string, string> = {
|
|||||||
paused: 'Приостановлен',
|
paused: 'Приостановлен',
|
||||||
disabled: 'Выключен',
|
disabled: 'Выключен',
|
||||||
archived: 'В архиве',
|
archived: 'В архиве',
|
||||||
block: 'block',
|
block: 'Блокировать',
|
||||||
accept: 'accept',
|
accept: 'Принимать',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jobStatusRu(status: string): string {
|
export function jobStatusRu(status: string): string {
|
||||||
return JOB_STATUS_RU[status.toLowerCase()] ?? status
|
return JOB_STATUS_RU[status.toLowerCase()] ?? status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BGP_SESSION_STATE_RU: Record<string, string> = {
|
||||||
|
Idle: 'Простой',
|
||||||
|
Connect: 'Соединение',
|
||||||
|
Active: 'Поиск',
|
||||||
|
OpenSent: 'Open отправлен',
|
||||||
|
OpenConfirm: 'Open подтверждён',
|
||||||
|
Established: 'Установлена',
|
||||||
|
}
|
||||||
|
|
||||||
export function bgpSessionStateRu(state: string | null | undefined): string {
|
export function bgpSessionStateRu(state: string | null | undefined): string {
|
||||||
if (!state) return '—'
|
if (!state) return '—'
|
||||||
if (state === 'Established') return 'Установлена'
|
return BGP_SESSION_STATE_RU[state] ?? state
|
||||||
return state
|
}
|
||||||
|
|
||||||
|
export function speakerRoleRu(role: string | null | undefined): string {
|
||||||
|
switch (role) {
|
||||||
|
case 'master':
|
||||||
|
return 'Основной'
|
||||||
|
case 'primary':
|
||||||
|
return 'Основной'
|
||||||
|
case 'secondary':
|
||||||
|
return 'Резервный'
|
||||||
|
case 'speaker':
|
||||||
|
return 'Спикер'
|
||||||
|
default:
|
||||||
|
return role?.trim() || '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cdnSourceKindRu(kind: string): string {
|
||||||
|
return kind === 'json' ? 'JSON' : 'Текст'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function speakerOnlineLabel(agentOk: boolean | undefined): string {
|
export function speakerOnlineLabel(agentOk: boolean | undefined): string {
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ export const kpiGridClassName =
|
|||||||
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
|
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
|
||||||
|
|
||||||
/** Two-column chart panel row (monitoring / network overview). */
|
/** 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) =>
|
mutationFn: (body: BgpCommunityCreate) =>
|
||||||
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
|
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Сообщество создано')
|
toast.success('Community создано')
|
||||||
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
||||||
},
|
},
|
||||||
onError: (e) =>
|
onError: (e) =>
|
||||||
toast.error(e instanceof Error ? e.message : 'Не удалось создать сообщество'),
|
toast.error(e instanceof Error ? e.message : 'Не удалось создать community'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +55,11 @@ export function useUpdateCommunityMutation() {
|
|||||||
mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) =>
|
mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) =>
|
||||||
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }),
|
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Сообщество обновлено')
|
toast.success('Community обновлено')
|
||||||
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
||||||
},
|
},
|
||||||
onError: (e) =>
|
onError: (e) =>
|
||||||
toast.error(e instanceof Error ? e.message : 'Не удалось обновить сообщество'),
|
toast.error(e instanceof Error ? e.message : 'Не удалось обновить community'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const Route = createFileRoute('/_auth')({
|
|||||||
if (!raw || !normalizeApiToken(raw)) {
|
if (!raw || !normalizeApiToken(raw)) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: '/settings',
|
to: '/settings',
|
||||||
search: { tab: 'connection', reason: 'token-required' },
|
search: { tab: 'ui', reason: 'token-required' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,31 +1,39 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
||||||
import { type SettingsTab } from '@/components/settings/settings-tabs-data'
|
import {
|
||||||
|
parseSettingsSection,
|
||||||
const settingsSearchSchema = z.object({
|
type SettingsSection,
|
||||||
tab: z
|
} from '@/components/settings/settings-tabs-data'
|
||||||
.enum(['connection', 'session', 'appearance', 'sections'])
|
import { can } from '@/lib/auth'
|
||||||
.catch('connection'),
|
|
||||||
reason: z.enum(['token-required']).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/_settings/settings')({
|
export const Route = createFileRoute('/_auth/_settings/settings')({
|
||||||
component: SettingsComponent,
|
component: SettingsComponent,
|
||||||
validateSearch: (search) => settingsSearchSchema.parse(search),
|
validateSearch: (search: Record<string, unknown>): {
|
||||||
|
tab: SettingsSection
|
||||||
|
reason?: 'token-required'
|
||||||
|
} => ({
|
||||||
|
tab: parseSettingsSection(search.tab),
|
||||||
|
...(search.reason === 'token-required'
|
||||||
|
? { reason: 'token-required' as const }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
function SettingsComponent() {
|
function SettingsComponent() {
|
||||||
const navigate = Route.useNavigate()
|
const navigate = Route.useNavigate()
|
||||||
const { tab, reason } = Route.useSearch()
|
const { tab, reason } = Route.useSearch()
|
||||||
const tokenRequired = reason === 'token-required'
|
const tokenRequired = reason === 'token-required'
|
||||||
|
const showBird = !tokenRequired && can('bgp:tenant_settings:admin')
|
||||||
|
const activeTab: SettingsSection =
|
||||||
|
tokenRequired || (tab === 'bird' && !showBird) ? 'ui' : tab
|
||||||
|
|
||||||
function handleTabChange(nextTab: SettingsTab) {
|
function handleTabChange(nextTab: SettingsSection) {
|
||||||
void navigate({
|
void navigate({
|
||||||
search: (prev) => ({
|
search: (prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
tab: nextTab,
|
tab: nextTab,
|
||||||
|
reason: nextTab === 'ui' ? prev.reason : undefined,
|
||||||
}),
|
}),
|
||||||
replace: true,
|
replace: true,
|
||||||
})
|
})
|
||||||
@@ -33,9 +41,10 @@ function SettingsComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsPageShell
|
<SettingsPageShell
|
||||||
activeTab={tab}
|
activeTab={activeTab}
|
||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
tokenRequired={tokenRequired}
|
tokenRequired={tokenRequired}
|
||||||
|
showBird={showBird}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,358 +1,8 @@
|
|||||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { Save } from 'lucide-react'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
import { PanelCard } from '@/components/panel-card'
|
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
|
||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
|
||||||
import { FrameDataGrid } from '@/components/reui-kit'
|
|
||||||
import { SelectField } from '@/components/select-field'
|
|
||||||
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
|
||||||
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
|
||||||
import { QueryState } from '@/components/query-state'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
|
||||||
|
|
||||||
import {
|
|
||||||
BIRD_SETTING_KEYS,
|
|
||||||
REVISION_SETTING_KEYS,
|
|
||||||
RUNTIME_LOGS_SETTING_KEYS,
|
|
||||||
buildPayload,
|
|
||||||
partitionSettings,
|
|
||||||
settingsKeys,
|
|
||||||
settingsQueryOptions,
|
|
||||||
type BirdSettingKey,
|
|
||||||
} from '@/queries/settings'
|
|
||||||
import { apiMutate } from '@/lib/api-client'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
|
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
|
||||||
component: TenantSettingsComponent,
|
beforeLoad: () => {
|
||||||
validateSearch: (search: Record<string, unknown>) => ({
|
throw redirect({ to: '/settings', search: { tab: 'bird' } })
|
||||||
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
|
},
|
||||||
? search.tab
|
component: () => null,
|
||||||
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const RUNTIME_LOGS_ENABLED_ITEMS = [
|
|
||||||
{ value: 'true', label: 'Вкл' },
|
|
||||||
{ value: 'false', label: 'Выкл' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const RUNTIME_LOGS_MODE_ITEMS = [
|
|
||||||
{ value: 'truncate', label: 'обнулить (truncate)' },
|
|
||||||
{ value: 'delete', label: 'удалить файл (delete)' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
|
||||||
bird_router_id: 'Router ID',
|
|
||||||
bird_local_ipv4: 'Локальный IPv4',
|
|
||||||
bird_local_ipv6: 'Локальный IPv6',
|
|
||||||
bird_local_asn: 'Локальный ASN',
|
|
||||||
bird_bgp_source_ipv4: 'BGP source IPv4',
|
|
||||||
bird_bgp_source_ipv6: 'BGP source IPv6',
|
|
||||||
peer_discovery_enabled: 'Автообнаружение пиров',
|
|
||||||
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
|
|
||||||
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
|
|
||||||
peer_discovery_require_external: 'Только external ASN',
|
|
||||||
}
|
|
||||||
|
|
||||||
const BIRD_BOOL_ITEMS = [
|
|
||||||
{ value: 'true', label: 'Вкл' },
|
|
||||||
{ value: 'false', label: 'Выкл' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
|
||||||
peer_discovery_enabled:
|
|
||||||
'Dynamic neighbor range в BIRD (карантин import/export none). Требует CIDR.',
|
|
||||||
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
|
|
||||||
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
|
|
||||||
peer_discovery_require_external: 'neighbor range … external (любой чужой ASN)',
|
|
||||||
}
|
|
||||||
|
|
||||||
function TenantSettingsComponent() {
|
|
||||||
const search = useSearch({ from: '/_auth/_settings/tenant-settings' })
|
|
||||||
const navigate = Route.useNavigate()
|
|
||||||
const settingsQ = useQuery(settingsQueryOptions())
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
|
||||||
|
|
||||||
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
|
||||||
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
|
||||||
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (partitioned) {
|
|
||||||
setBirdForm({ ...partitioned.bird })
|
|
||||||
setRevisionForm({ ...partitioned.revision })
|
|
||||||
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [settingsQ.data])
|
|
||||||
|
|
||||||
const patchMutation = useMutation({
|
|
||||||
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
|
||||||
apiMutate('/v1/settings', 'PATCH', payload),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Параметры сохранены')
|
|
||||||
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
|
||||||
})
|
|
||||||
|
|
||||||
function saveBird() {
|
|
||||||
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
|
||||||
}
|
|
||||||
function saveRevision() {
|
|
||||||
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
|
||||||
}
|
|
||||||
function saveRuntimeLogs() {
|
|
||||||
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<BadgeTabs
|
|
||||||
value={search.tab}
|
|
||||||
onValueChange={(tab) =>
|
|
||||||
navigate({
|
|
||||||
search: { tab: tab as 'bird' | 'revision' | 'runtime-logs' | 'additional' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
items={[
|
|
||||||
{ value: 'bird', label: 'BIRD' },
|
|
||||||
{ value: 'revision', label: 'Ревизии' },
|
|
||||||
{ value: 'runtime-logs', label: 'Файловые логи' },
|
|
||||||
{ value: 'additional', label: 'Дополнительно' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TabsContent value="bird" className="mt-0">
|
|
||||||
<PanelCard
|
|
||||||
title="BIRD control plane"
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
|
|
||||||
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
contentClassName="space-y-4 py-4"
|
|
||||||
>
|
|
||||||
<QueryState
|
|
||||||
data={partitioned}
|
|
||||||
isLoading={settingsQ.isLoading}
|
|
||||||
isError={settingsQ.isError}
|
|
||||||
error={settingsQ.error}
|
|
||||||
skeleton={<div className="h-64" />}
|
|
||||||
onRetry={() => settingsQ.refetch()}
|
|
||||||
>
|
|
||||||
{() => (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{BIRD_SETTING_KEYS.map((key, index) => (
|
|
||||||
<SettingsSettingField
|
|
||||||
key={key}
|
|
||||||
title={BIRD_LABELS[key]}
|
|
||||||
description={BIRD_HINTS[key] ?? key}
|
|
||||||
labelFor={key}
|
|
||||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
|
||||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
|
||||||
>
|
|
||||||
{key === 'peer_discovery_enabled' ||
|
|
||||||
key === 'peer_discovery_require_external' ? (
|
|
||||||
<SelectField
|
|
||||||
id={key}
|
|
||||||
items={[...BIRD_BOOL_ITEMS]}
|
|
||||||
value={birdForm[key] || 'false'}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
|
||||||
}
|
|
||||||
placeholder="Выкл"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
id={key}
|
|
||||||
value={birdForm[key] ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder={BIRD_LABELS[key]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSettingField>
|
|
||||||
))}
|
|
||||||
<div className="px-4 py-4">
|
|
||||||
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
|
||||||
<Save />
|
|
||||||
Сохранить
|
|
||||||
</LoadingButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</PanelCard>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="revision" className="mt-0">
|
|
||||||
<PanelCard
|
|
||||||
title="Ревизии"
|
|
||||||
description="Время хранения ревизий в БД"
|
|
||||||
contentClassName="space-y-4 py-4"
|
|
||||||
>
|
|
||||||
<QueryState
|
|
||||||
data={partitioned}
|
|
||||||
isLoading={settingsQ.isLoading}
|
|
||||||
isError={settingsQ.isError}
|
|
||||||
error={settingsQ.error}
|
|
||||||
skeleton={<div className="h-32" />}
|
|
||||||
onRetry={() => settingsQ.refetch()}
|
|
||||||
>
|
|
||||||
{() => (
|
|
||||||
<div className="flex max-w-sm flex-col gap-1.5">
|
|
||||||
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label>
|
|
||||||
<Input
|
|
||||||
id="revision_retention_minutes"
|
|
||||||
type="number"
|
|
||||||
value={revisionForm.revision_retention_minutes ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setRevisionForm((s) => ({
|
|
||||||
...s,
|
|
||||||
revision_retention_minutes: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="font-mono text-xs text-muted-foreground">
|
|
||||||
revision_retention_minutes
|
|
||||||
</p>
|
|
||||||
<LoadingButton
|
|
||||||
className="mt-2 w-fit"
|
|
||||||
onClick={saveRevision}
|
|
||||||
loading={patchMutation.isPending}
|
|
||||||
>
|
|
||||||
<Save />
|
|
||||||
Сохранить
|
|
||||||
</LoadingButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</PanelCard>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="runtime-logs" className="mt-0">
|
|
||||||
<PanelCard
|
|
||||||
title="Файловые логи"
|
|
||||||
description="Автоматическая очистка логов"
|
|
||||||
contentClassName="space-y-4 py-4"
|
|
||||||
>
|
|
||||||
<QueryState
|
|
||||||
data={partitioned}
|
|
||||||
isLoading={settingsQ.isLoading}
|
|
||||||
isError={settingsQ.isError}
|
|
||||||
error={settingsQ.error}
|
|
||||||
skeleton={<div className="h-48" />}
|
|
||||||
onRetry={() => settingsQ.refetch()}
|
|
||||||
>
|
|
||||||
{() => (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<SelectField
|
|
||||||
label="Авто-очистка включена"
|
|
||||||
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
|
||||||
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
|
||||||
placeholder="Выберите"
|
|
||||||
description="runtime_logs_auto_enabled"
|
|
||||||
onValueChange={(v) =>
|
|
||||||
v &&
|
|
||||||
setRuntimeLogsForm((s) => ({
|
|
||||||
...s,
|
|
||||||
runtime_logs_auto_enabled: v,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
|
|
||||||
<Input
|
|
||||||
id="runtime_logs_max_file_mb"
|
|
||||||
type="number"
|
|
||||||
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setRuntimeLogsForm((s) => ({
|
|
||||||
...s,
|
|
||||||
runtime_logs_max_file_mb: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="font-mono text-xs text-muted-foreground">
|
|
||||||
runtime_logs_max_file_mb
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label>
|
|
||||||
<Input
|
|
||||||
id="runtime_logs_auto_schedule"
|
|
||||||
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setRuntimeLogsForm((s) => ({
|
|
||||||
...s,
|
|
||||||
runtime_logs_auto_schedule: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="font-mono text-xs text-muted-foreground">
|
|
||||||
runtime_logs_auto_schedule
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SelectField
|
|
||||||
label="Режим очистки"
|
|
||||||
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
|
||||||
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
|
||||||
placeholder="Выберите"
|
|
||||||
description="runtime_logs_auto_mode"
|
|
||||||
onValueChange={(v) =>
|
|
||||||
v &&
|
|
||||||
setRuntimeLogsForm((s) => ({
|
|
||||||
...s,
|
|
||||||
runtime_logs_auto_mode: v,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
|
||||||
<Save />
|
|
||||||
Сохранить
|
|
||||||
</LoadingButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</PanelCard>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="additional" className="mt-0">
|
|
||||||
<FrameDataGrid
|
|
||||||
title="Дополнительные параметры"
|
|
||||||
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
|
|
||||||
>
|
|
||||||
<QueryState
|
|
||||||
data={partitioned?.additional ?? []}
|
|
||||||
isLoading={settingsQ.isLoading}
|
|
||||||
isError={settingsQ.isError}
|
|
||||||
error={settingsQ.error}
|
|
||||||
empty={(partitioned?.additional ?? []).length === 0}
|
|
||||||
emptyTitle="Нет дополнительных параметров"
|
|
||||||
skeleton={<div className="h-32" />}
|
|
||||||
onRetry={() => settingsQ.refetch()}
|
|
||||||
>
|
|
||||||
{(items) => (
|
|
||||||
<SettingsKvGrid
|
|
||||||
items={items}
|
|
||||||
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</FrameDataGrid>
|
|
||||||
</TabsContent>
|
|
||||||
</BadgeTabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
|
import { KeyRound, MonitorIcon, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@evobgp/ui/components/item'
|
||||||
|
|
||||||
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
|
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||||
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
import { sessionCanManageApiKeys } from '@/lib/auth'
|
import { sessionCanManageApiKeys } from '@/lib/auth'
|
||||||
import { authSessionQueryOptions } from '@/queries/auth'
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
@@ -38,16 +46,18 @@ function AccessComponent() {
|
|||||||
const kpiItems: KpiStatItem[] = useMemo(
|
const kpiItems: KpiStatItem[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
|
id: 'keys-total',
|
||||||
label: 'Всего ключей',
|
label: 'Всего ключей',
|
||||||
value: keys.length,
|
value: keys.length,
|
||||||
icon: <KeyRound className="size-4" />,
|
icon: <KeyRound className="size-4" />,
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant="primary-light" size="sm">
|
<Badge variant="primary-light" size="sm">
|
||||||
в tenant
|
в арендаторе
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'keys-active',
|
||||||
label: 'Активных',
|
label: 'Активных',
|
||||||
value: activeCount,
|
value: activeCount,
|
||||||
icon: <ShieldCheck className="size-4" />,
|
icon: <ShieldCheck className="size-4" />,
|
||||||
@@ -58,6 +68,7 @@ function AccessComponent() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'keys-revoked',
|
||||||
label: 'Отозванных',
|
label: 'Отозванных',
|
||||||
value: revokedCount,
|
value: revokedCount,
|
||||||
icon: <ShieldOff className="size-4" />,
|
icon: <ShieldOff className="size-4" />,
|
||||||
@@ -78,25 +89,25 @@ function AccessComponent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionKindLabel =
|
const sessionKindLabel =
|
||||||
session?.kind === 'jwt' ? 'Portal JWT' : session?.kind === 'apikey' ? 'API-ключ' : null
|
session?.kind === 'jwt' ? 'JWT портала' : session?.kind === 'apikey' ? 'API-ключ' : null
|
||||||
|
|
||||||
const sessionAccessLabel = (() => {
|
const sessionAccessLabel = (() => {
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
if (session.kind === 'jwt' || session.is_admin || (session.permissions?.length ?? 0) > 0) {
|
if (session.kind === 'jwt' || session.is_admin || (session.permissions?.length ?? 0) > 0) {
|
||||||
if (session.is_admin) return 'admin (portal)'
|
if (session.is_admin) return 'Администратор (портал)'
|
||||||
if (sessionCanManageApiKeys(session)) return 'bgp:access:admin'
|
if (sessionCanManageApiKeys(session)) return 'bgp:access:admin'
|
||||||
return session.permissions?.length
|
return session.permissions?.length
|
||||||
? session.permissions.slice(0, 3).join(', ')
|
? session.permissions.slice(0, 3).join(', ')
|
||||||
: 'без access:admin'
|
: 'без права access:admin'
|
||||||
}
|
}
|
||||||
return session.role || '—'
|
return session.role || '—'
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Права доступа"
|
title="Права доступа"
|
||||||
description="API-ключи control plane и текущая сессия Bearer-токена."
|
description="API-ключи плоскости управления и текущая сессия."
|
||||||
actions={
|
actions={
|
||||||
canManageKeys ? (
|
canManageKeys ? (
|
||||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
@@ -108,47 +119,55 @@ function AccessComponent() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{session ? (
|
{session ? (
|
||||||
<PanelCard
|
<SettingsCard
|
||||||
title="Текущая сессия"
|
title="Текущая сессия"
|
||||||
description="Tenant и права текущего Bearer (API-ключ или portal JWT)."
|
description="Арендатор и права текущей сессии (API-ключ или JWT портала)."
|
||||||
contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
|
|
||||||
>
|
>
|
||||||
<div>
|
<ItemGroup className="gap-0">
|
||||||
<p className="text-muted-foreground">Tenant</p>
|
<Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
|
||||||
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
<ItemMedia className="self-start">
|
||||||
</div>
|
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||||
<div>
|
<MonitorIcon aria-hidden="true" />
|
||||||
<p className="text-muted-foreground">Доступ</p>
|
</Item>
|
||||||
<p className="font-mono text-xs">{sessionAccessLabel}</p>
|
</ItemMedia>
|
||||||
</div>
|
<ItemContent className="min-w-0 gap-3">
|
||||||
{sessionKindLabel ? (
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<div>
|
<ItemTitle className="leading-5">Этот браузер</ItemTitle>
|
||||||
<p className="text-muted-foreground">Тип</p>
|
{sessionKindLabel ? (
|
||||||
<p className="font-mono text-xs">{sessionKindLabel}</p>
|
<Badge variant="info-light" size="sm">
|
||||||
</div>
|
{sessionKindLabel}
|
||||||
) : null}
|
</Badge>
|
||||||
{session.email ? (
|
) : null}
|
||||||
<div>
|
</div>
|
||||||
<p className="text-muted-foreground">Email</p>
|
<ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
|
||||||
<p className="break-all text-xs">{session.email}</p>
|
<span className="break-all">
|
||||||
</div>
|
Арендатор:{' '}
|
||||||
) : null}
|
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
||||||
</PanelCard>
|
</span>
|
||||||
|
<span className="break-words">Доступ: {sessionAccessLabel}</span>
|
||||||
|
{session.email ? <span className="break-all">Email: {session.email}</span> : null}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
</ItemGroup>
|
||||||
|
</SettingsCard>
|
||||||
) : (
|
) : (
|
||||||
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
<SettingsCard title="Текущая сессия">
|
||||||
Не удалось определить сессию. Укажите токен в{' '}
|
<p className="text-muted-foreground px-5 py-4 text-sm">
|
||||||
<Link
|
Не удалось определить сессию. Укажите токен в{' '}
|
||||||
to="/settings"
|
<Link
|
||||||
search={{ tab: 'connection' }}
|
to="/settings"
|
||||||
className="text-primary underline-offset-4 hover:underline"
|
search={{ tab: 'ui' }}
|
||||||
>
|
className="text-primary underline-offset-4 hover:underline"
|
||||||
настройках
|
>
|
||||||
</Link>{' '}
|
настройках
|
||||||
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
</Link>{' '}
|
||||||
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
|
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
||||||
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
|
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
|
||||||
) : null}
|
<span className="text-destructive mt-2 block">{sessionQuery.error.message}</span>
|
||||||
</PanelCard>
|
) : null}
|
||||||
|
</p>
|
||||||
|
</SettingsCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canManageKeys ? (
|
{canManageKeys ? (
|
||||||
@@ -167,11 +186,13 @@ function AccessComponent() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : session ? (
|
) : session ? (
|
||||||
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
<SettingsCard title="API-ключи">
|
||||||
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
|
<p className="text-muted-foreground px-5 py-4 text-sm break-words">
|
||||||
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
|
Управление API-ключами доступно роли <strong>оператор</strong> (API-ключ) или JWT портала
|
||||||
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
|
с правом администратора / <code className="text-xs">bgp:access:admin</code>.
|
||||||
</PanelCard>
|
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
|
||||||
|
</p>
|
||||||
|
</SettingsCard>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Button } from '@evobgp/ui/components/button'
|
|||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
||||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
|
||||||
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
|
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
|
||||||
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
||||||
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
||||||
@@ -15,7 +14,7 @@ import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-o
|
|||||||
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
|
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
|
||||||
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
|
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
|
||||||
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
|
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
|
||||||
import { OpsDashboard } from '@/components/reui-kit'
|
import { FrameDataGrid, OpsDashboard } from '@/components/reui-kit'
|
||||||
import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface'
|
import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface'
|
||||||
import {
|
import {
|
||||||
moduleNameById,
|
moduleNameById,
|
||||||
@@ -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/dashboard-1
|
||||||
* @see https://reui.io/preview/base/stats-12
|
* @see https://reui.io/preview/base/stats-12
|
||||||
*/
|
*/
|
||||||
@@ -99,51 +100,47 @@ function DashboardComponent() {
|
|||||||
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
|
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
|
||||||
charts={
|
charts={
|
||||||
<>
|
<>
|
||||||
<div className={dashboardMainSidebarClassName}>
|
<div className="min-w-0">
|
||||||
<div className="xl:col-span-8">
|
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
||||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
|
||||||
</div>
|
|
||||||
<div className="xl:col-span-4">
|
|
||||||
<DashboardActivityTimeline
|
|
||||||
jobs={jobs}
|
|
||||||
revisions={revisions}
|
|
||||||
peers={peers}
|
|
||||||
speakers={speakers}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={chartPanelGridClassName}>
|
<div className={dashboardMainSidebarClassName}>
|
||||||
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
|
<DashboardActivityTimeline
|
||||||
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
queueTitle="Недавняя активность"
|
queueTitle="Задачи и ревизии"
|
||||||
queueDescription="Задачи и ревизии плоскости управления"
|
queueDescription="Последние фоновые операции и история конфигураций"
|
||||||
queue={
|
queue={
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className={chartPanelGridClassName}>
|
||||||
<DashboardFramePanel
|
<FrameDataGrid
|
||||||
title="Недавние задачи"
|
title="Недавние задачи"
|
||||||
description="Последние фоновые операции"
|
description="Последние фоновые операции"
|
||||||
className="h-full"
|
|
||||||
>
|
>
|
||||||
{activityLoading ? (
|
{activityLoading ? (
|
||||||
<Skeleton className="m-4 h-24 w-auto" />
|
<Skeleton className="m-4 h-24 w-auto" />
|
||||||
) : (
|
) : (
|
||||||
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
|
<DashboardRecentJobsGrid jobs={jobs.slice(0, 8)} nameById={nameById} isLoading={refreshing} />
|
||||||
)}
|
)}
|
||||||
</DashboardFramePanel>
|
</FrameDataGrid>
|
||||||
<DashboardFramePanel
|
<FrameDataGrid
|
||||||
title="Последние ревизии"
|
title="Последние ревизии"
|
||||||
description="История конфигураций"
|
description="История конфигураций"
|
||||||
className="h-full"
|
|
||||||
>
|
>
|
||||||
{activityLoading ? (
|
{activityLoading ? (
|
||||||
<Skeleton className="m-4 h-24 w-auto" />
|
<Skeleton className="m-4 h-24 w-auto" />
|
||||||
) : (
|
) : (
|
||||||
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
||||||
)}
|
)}
|
||||||
</DashboardFramePanel>
|
</FrameDataGrid>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function DirectoriesComponent() {
|
|||||||
|
|
||||||
const items: KpiStatItem[] = [
|
const items: KpiStatItem[] = [
|
||||||
{
|
{
|
||||||
label: 'Сообщества BGP',
|
label: 'BGP community',
|
||||||
value: communities.length,
|
value: communities.length,
|
||||||
icon: <Tags className="size-4" />,
|
icon: <Tags className="size-4" />,
|
||||||
footer: (
|
footer: (
|
||||||
@@ -70,7 +70,7 @@ function DirectoriesComponent() {
|
|||||||
icon: <BookText className="size-4" />,
|
icon: <BookText className="size-4" />,
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm">
|
||||||
все модули tenant
|
все модули арендатора
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -114,7 +114,7 @@ function DirectoriesComponent() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Справочники"
|
title="Справочники"
|
||||||
description="Сообщества BGP и DoH-профили для резолвинга доменов"
|
description="BGP community и DoH-профили для резолвинга доменов"
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -136,13 +136,13 @@ function DirectoriesComponent() {
|
|||||||
<BadgeTabs
|
<BadgeTabs
|
||||||
defaultValue="communities"
|
defaultValue="communities"
|
||||||
items={[
|
items={[
|
||||||
{ value: 'communities', label: 'Сообщества BGP', count: communities.length },
|
{ value: 'communities', label: 'BGP community', count: communities.length },
|
||||||
{ value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
|
{ value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<TabsContent value="communities" className="mt-0">
|
<TabsContent value="communities" className="mt-0">
|
||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
title="Сообщества BGP"
|
title="BGP community"
|
||||||
description="Теги для префиксов в фильтрах BIRD"
|
description="Теги для префиксов в фильтрах BIRD"
|
||||||
actions={addCommunityButton}
|
actions={addCommunityButton}
|
||||||
>
|
>
|
||||||
@@ -152,7 +152,7 @@ function DirectoriesComponent() {
|
|||||||
isError={communitiesQ.isError}
|
isError={communitiesQ.isError}
|
||||||
error={communitiesQ.error}
|
error={communitiesQ.error}
|
||||||
empty={communities.length === 0}
|
empty={communities.length === 0}
|
||||||
emptyTitle="Нет сообществ"
|
emptyTitle="Нет community"
|
||||||
emptyAction={addCommunityButton}
|
emptyAction={addCommunityButton}
|
||||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||||
onRetry={() => communitiesQ.refetch()}
|
onRetry={() => communitiesQ.refetch()}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ function MonitoringComponent() {
|
|||||||
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
|
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
|
||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
title="Доступность и готовность"
|
title="Доступность и готовность"
|
||||||
description="GET /v1/health · GET /v1/ready"
|
description="Проверки живучести и готовности (/v1/health, /v1/ready)"
|
||||||
>
|
>
|
||||||
<QueryState
|
<QueryState
|
||||||
data={readyQ.data}
|
data={readyQ.data}
|
||||||
@@ -148,7 +148,7 @@ function MonitoringComponent() {
|
|||||||
BGP на API-хосте
|
BGP на API-хосте
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
description="GET /v1/bird/status"
|
description="Статус BIRD на хосте API"
|
||||||
className="h-full"
|
className="h-full"
|
||||||
contentClassName="px-5 py-4"
|
contentClassName="px-5 py-4"
|
||||||
>
|
>
|
||||||
@@ -169,7 +169,7 @@ function MonitoringComponent() {
|
|||||||
<div className="flex flex-col gap-2 md:gap-3">
|
<div className="flex flex-col gap-2 md:gap-3">
|
||||||
<SegmentedProgressCard
|
<SegmentedProgressCard
|
||||||
title="Задачи"
|
title="Задачи"
|
||||||
description="Последние 100 задач · GET /v1/jobs"
|
description="Последние 100 задач"
|
||||||
primary={{
|
primary={{
|
||||||
value: jobs.filter((j) => j.status === 'running' || j.status === 'queued').length,
|
value: jobs.filter((j) => j.status === 'running' || j.status === 'queued').length,
|
||||||
label: 'Активных',
|
label: 'Активных',
|
||||||
@@ -230,11 +230,10 @@ function MonitoringComponent() {
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
|
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
|
||||||
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
|
<code className="text-xs">PostgreSQL</code>, затем хранилище и очередь задач в проверках.
|
||||||
и <code className="text-xs">jobs</code> в проверках.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
|
<span className="font-medium text-foreground">Низкая доля установленных BGP-сессий.</span> Проверьте{' '}
|
||||||
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
|
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -250,7 +249,7 @@ function MonitoringComponent() {
|
|||||||
<IllustratedEmptyState
|
<IllustratedEmptyState
|
||||||
icon={Database}
|
icon={Database}
|
||||||
title="PostgreSQL"
|
title="PostgreSQL"
|
||||||
description="Статус соединения и пул отображаются в readiness-проверке на вкладке «Система» (check postgres)."
|
description="Состояние соединения и пула видно в проверке готовности на вкладке «Система»."
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -258,7 +257,7 @@ function MonitoringComponent() {
|
|||||||
<IllustratedEmptyState
|
<IllustratedEmptyState
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title="Файловые логи"
|
title="Файловые логи"
|
||||||
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в tenant-settings."
|
description="Логи API и конвейера задаются переменной EVOBGP_LOG_* и управляются в Настройках BIRD."
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</BadgeTabs>
|
</BadgeTabs>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+36
-23
@@ -2,29 +2,38 @@
|
|||||||
|
|
||||||
## CI (Gitea Actions)
|
## CI (Gitea Actions)
|
||||||
|
|
||||||
Сборка образов — **`docker buildx bake`** (`deploy/docker/docker-bake.hcl`), не отдельные `docker build`.
|
Сборка образов — **`docker buildx bake`** ([docker-bake.hcl](docker-bake.hcl)), не отдельные `docker build`.
|
||||||
|
|
||||||
**Publish:** push в `main` после quality gates — job **release** в [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml): semantic-release + bake с `VERSION` из релиза.
|
**Publish:** push в `main` после quality gates — workflow [CD](../../.gitea/workflows/cd.yaml) job **publish**: semantic-release + зеркало base-образов + bake с `VERSION` из релиза.
|
||||||
|
|
||||||
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
|
Bake читает переменные из **окружения** (`REGISTRY`, `IMAGE_TAG`, `CACHE_REF_*`, `BASE_*`). Скрипт [write-bake-override.sh](write-bake-override.sh) — опциональный helper для локальной отладки.
|
||||||
|
|
||||||
| Было | Стало |
|
BuildKit кэширует `/go/pkg/mod`, `~/.cache/go-build` и pnpm store через `RUN --mount=type=cache`. На CI mounts живут, пока named builder `evobgp` не удаляют (`cleanup: false`).
|
||||||
|------|--------|
|
|
||||||
| 8× `go mod download` + 8× `go build` (разные BIN) | 1× download + 1× компиляция всех `cmd/*` |
|
|
||||||
| 2× сборка BIRD из исходников (api, all) | 1× stage `birdc`, копируется в runtime |
|
|
||||||
| 2× `npm ci` (web, web-all) | 1× `web-deps` + 1× `web-build` + два nginx-образа (общий артефакт) |
|
|
||||||
| 8 runner'ов с checkout/login | 1 job `docker-go`, 1 job `docker-web` |
|
|
||||||
|
|
||||||
Кэш registry (переменные bake):
|
### Слои и runtime
|
||||||
|
|
||||||
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache` — **запись** только из target `go-build-all`
|
| Образ | Runtime base | Заметка |
|
||||||
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache` — **запись** только из target `web-build`
|
|-------|----------------|---------|
|
||||||
|
| scheduler, ingest, render, deploy, node | `gcr.io/distroless/static-debian12:nonroot` | static Go (`CGO_ENABLED=0`), без shell |
|
||||||
|
| api, all | `debian:bookworm-slim` + `birdc` | только клиент birdc, без демона `bird` |
|
||||||
|
| agent | тот же Ubuntu+bird2, что bird2 | общие слои с `evobgp-bird2` |
|
||||||
|
| bird2 | Ubuntu Noble + пакет bird2 | |
|
||||||
|
| web, web-all | `nginx:1.27-alpine` | `worker_processes 1` |
|
||||||
|
|
||||||
Остальные bake-target’ы только `cache-from` (чтение). Параллельный `cache-to` в один ref ломает manifest в registry (`content descriptor … not found`).
|
Сборка Go: `golang:1.24-alpine`. Context режется корневым [.dockerignore](../../.dockerignore).
|
||||||
|
|
||||||
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
|
### Кэш registry
|
||||||
|
|
||||||
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evobgp-buildcache:go-buildcache` и `:web-buildcache` в registry и пересоберите.
|
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache` — **запись** только из `go-build-all`
|
||||||
|
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache` — **запись** только из `web-build`
|
||||||
|
- `git.shx.one/<owner>/evobgp-buildcache:birdc-buildcache` — **запись** только из `go-birdc`
|
||||||
|
- `git.shx.one/<owner>/evobgp-buildcache:base-*` — зеркало FROM с **Docker Hub** (`docker.io/library/…`; distroless — `gcr.io`). Только `linux/amd64`; skip если тег уже в Gitea. Неуспешный copy не валит CD — bake берёт Docker Hub FROM для этой базы.
|
||||||
|
|
||||||
|
`pull = false` в bake: не перекачивать FROM, если слой уже в builder. Не запускайте `docker system prune -a` на runner.
|
||||||
|
|
||||||
|
Параллельный `cache-to` в один ref ломает manifest (`content descriptor … not found`).
|
||||||
|
|
||||||
|
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evobgp-buildcache:*` в registry и пересоберите.
|
||||||
|
|
||||||
## Локальная сборка
|
## Локальная сборка
|
||||||
|
|
||||||
@@ -37,20 +46,24 @@ export IMAGE_TAG=latest
|
|||||||
export SHORT_SHA=$(git rev-parse --short HEAD)
|
export SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
export VERSION=dev
|
export VERSION=dev
|
||||||
export BUILD_TIME=
|
export BUILD_TIME=
|
||||||
sh write-bake-override.sh
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl go-images
|
||||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl go-images
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl web-images
|
||||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl web-images
|
|
||||||
```
|
```
|
||||||
|
|
||||||
В `docker-bake.hcl`: `context = "../.."` (корень репо), `dockerfile = "deploy/docker/…"` (путь от корня репо). **CI:** `write-bake-override.sh` + `--allow=fs.read=$GITHUB_WORKSPACE` в `.gitea/workflows/ci.yaml`.
|
Проверка манифеста без сборки:
|
||||||
|
|
||||||
Один образ (legacy):
|
```bash
|
||||||
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl --print default
|
||||||
|
```
|
||||||
|
|
||||||
|
В `docker-bake.hcl`: `context = "../.."` резолвится **от cwd** (каталог `deploy/docker/`). `dockerfile` — путь от этого context (корень репо). Из корня репо не вызывать bake с `-f deploy/docker/docker-bake.hcl`: получится `lstat ../../deploy`. Можно задать `BUILDX_BAKE_FILE_RELATIVE_PATHS=1`.
|
||||||
|
|
||||||
|
Один образ (legacy, target `build-all`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f deploy/docker/gobinary/Dockerfile \
|
docker build -f deploy/docker/gobinary/Dockerfile \
|
||||||
--target build-all \
|
--target build-all \
|
||||||
--build-arg BIN=evobgp-api \
|
-t evobgp-build-all:local .
|
||||||
-t evobgp-api:local .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime-образы в bake ожидают stage `build-all` (через bake contexts), не отдельный `--build-arg BIN` на старый target `build`.
|
Runtime-образы в bake ожидают stage `build-all` (через bake contexts).
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
# BIRD из репозитория Ubuntu (Noble 24.04 LTS) — актуальнее пакета Debian bookworm.
|
# BIRD из репозитория Ubuntu (Noble 24.04 LTS) — актуальнее пакета Debian bookworm.
|
||||||
# Сборка из корня репозитория: docker build -f deploy/docker/bird2/Dockerfile .
|
# Stage bird2-base общий с evobgp-agent (одинаковые слои на speaker-VPS).
|
||||||
# Зеркало ECR Public вместо прямого pull с Docker Hub.
|
ARG BASE_UBUNTU=docker.io/library/ubuntu:noble
|
||||||
FROM public.ecr.aws/docker/library/ubuntu:noble
|
|
||||||
RUN apt-get update \
|
FROM ${BASE_UBUNTU} AS bird2-base
|
||||||
&& apt-get install -y --no-install-recommends bird2 \
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends bird2 ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
FROM bird2-base AS bird2
|
||||||
COPY deploy/bird/bird.conf /etc/bird/bird.conf
|
COPY deploy/bird/bird.conf /etc/bird/bird.conf
|
||||||
RUN mkdir -p /etc/bird/bird.d
|
RUN mkdir -p /etc/bird/bird.d
|
||||||
EXPOSE 179
|
EXPOSE 179
|
||||||
|
|||||||
+108
-25
@@ -1,7 +1,7 @@
|
|||||||
# Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl).
|
# Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl).
|
||||||
# context = "../.." — корень репозитория (относительно этого файла).
|
# context = "../.." — корень репо. Bake резолвит context от cwd (не от HCL),
|
||||||
# dockerfile — путь от корня репозитория (относительно context).
|
# поэтому вызов из deploy/docker/ либо BUILDX_BAKE_FILE_RELATIVE_PATHS=1.
|
||||||
# Переменные: REGISTRY, IMAGE_TAG, CACHE_REF_GO, CACHE_REF_WEB
|
# Переменные Bake читаются из окружения (см. docs.docker.com/build/bake/variables/).
|
||||||
|
|
||||||
variable "REGISTRY" {
|
variable "REGISTRY" {
|
||||||
default = "git.shx.one/evobgp"
|
default = "git.shx.one/evobgp"
|
||||||
@@ -35,14 +35,40 @@ variable "CACHE_REF_WEB" {
|
|||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "CACHE_REF_BIRDC" {
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_GOLANG" {
|
||||||
|
default = "docker.io/library/golang:1.24-alpine"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_DEBIAN" {
|
||||||
|
default = "docker.io/library/debian:bookworm-slim"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_DISTROLESS" {
|
||||||
|
default = "gcr.io/distroless/static-debian12:nonroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_NODE" {
|
||||||
|
default = "docker.io/library/node:22-alpine"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_NGINX" {
|
||||||
|
default = "docker.io/library/nginx:1.27-alpine"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_UBUNTU" {
|
||||||
|
default = "docker.io/library/ubuntu:noble"
|
||||||
|
}
|
||||||
|
|
||||||
function "go-cache-from" {
|
function "go-cache-from" {
|
||||||
params = []
|
params = []
|
||||||
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : []
|
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
# Экспорт кэша — только go-build-all / web-build (один writer на ref).
|
# Экспорт кэша — один writer на ref (параллельный cache-to в один ref ломает manifest).
|
||||||
# Несколько target с cache-to в один ref и mode=max дают гонку в registry
|
|
||||||
# (content descriptor not found при параллельном bake).
|
|
||||||
function "go-cache-to-export" {
|
function "go-cache-to-export" {
|
||||||
params = []
|
params = []
|
||||||
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO},mode=max"] : []
|
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO},mode=max"] : []
|
||||||
@@ -58,6 +84,16 @@ function "web-cache-to-export" {
|
|||||||
result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB},mode=max"] : []
|
result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB},mode=max"] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function "birdc-cache-from" {
|
||||||
|
params = []
|
||||||
|
result = notequal("", CACHE_REF_BIRDC) ? ["type=registry,ref=${CACHE_REF_BIRDC}"] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function "birdc-cache-to-export" {
|
||||||
|
params = []
|
||||||
|
result = notequal("", CACHE_REF_BIRDC) ? ["type=registry,ref=${CACHE_REF_BIRDC},mode=max"] : []
|
||||||
|
}
|
||||||
|
|
||||||
group "default" {
|
group "default" {
|
||||||
targets = ["go-images", "web-images", "evobgp-bird2"]
|
targets = ["go-images", "web-images", "evobgp-bird2"]
|
||||||
}
|
}
|
||||||
@@ -75,26 +111,38 @@ group "go-images" {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
# web-deps / web-build — только зависимости (contexts), без tags; при --push в группе не указывать.
|
|
||||||
group "web-images" {
|
group "web-images" {
|
||||||
targets = ["evobgp-web", "evobgp-web-all"]
|
targets = ["evobgp-web", "evobgp-web-all"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
target "_common" {
|
||||||
|
platforms = ["linux/amd64"]
|
||||||
|
pull = false
|
||||||
|
}
|
||||||
|
|
||||||
|
target "_go-bases" {
|
||||||
|
args = {
|
||||||
|
BASE_GOLANG = BASE_GOLANG
|
||||||
|
BASE_DEBIAN = BASE_DEBIAN
|
||||||
|
BASE_DISTROLESS = BASE_DISTROLESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# --- Go: go mod download → все cmd/* → birdc (один раз) → runtime-образы ---
|
# --- Go: go mod download → все cmd/* → birdc (один раз) → runtime-образы ---
|
||||||
|
|
||||||
target "go-deps" {
|
target "go-deps" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "deps"
|
target = "deps"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
cache-from = go-cache-from()
|
cache-from = go-cache-from()
|
||||||
}
|
}
|
||||||
|
|
||||||
target "go-build-all" {
|
target "go-build-all" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "build-all"
|
target = "build-all"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
args = {
|
args = {
|
||||||
VERSION = VERSION
|
VERSION = VERSION
|
||||||
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
||||||
@@ -108,18 +156,19 @@ target "go-build-all" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "go-birdc" {
|
target "go-birdc" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "birdc"
|
target = "birdc"
|
||||||
platforms = ["linux/amd64"]
|
cache-from = birdc-cache-from()
|
||||||
cache-from = go-cache-from()
|
cache-to = birdc-cache-to-export()
|
||||||
}
|
}
|
||||||
|
|
||||||
target "_go-runtime" {
|
target "_go-runtime" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "runtime"
|
target = "runtime"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
build-all = "target:go-build-all"
|
build-all = "target:go-build-all"
|
||||||
}
|
}
|
||||||
@@ -127,15 +176,15 @@ target "_go-runtime" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "_go-runtime-birdc" {
|
target "_go-runtime-birdc" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "runtime-birdc"
|
target = "runtime-birdc"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
build-all = "target:go-build-all"
|
build-all = "target:go-build-all"
|
||||||
birdc = "target:go-birdc"
|
birdc = "target:go-birdc"
|
||||||
}
|
}
|
||||||
cache-from = go-cache-from()
|
cache-from = concat(go-cache-from(), birdc-cache-from())
|
||||||
}
|
}
|
||||||
|
|
||||||
function "image-tags" {
|
function "image-tags" {
|
||||||
@@ -198,32 +247,51 @@ target "evobgp-node" {
|
|||||||
tags = image-tags("evobgp-node")
|
tags = image-tags("evobgp-node")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
target "evobgp-bird2-base" {
|
||||||
|
inherits = ["_common"]
|
||||||
|
context = "../.."
|
||||||
|
dockerfile = "deploy/docker/bird2/Dockerfile"
|
||||||
|
target = "bird2-base"
|
||||||
|
args = {
|
||||||
|
BASE_UBUNTU = BASE_UBUNTU
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
target "evobgp-agent" {
|
target "evobgp-agent" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-agent/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-agent/Dockerfile"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
build-all = "target:go-build-all"
|
build-all = "target:go-build-all"
|
||||||
|
bird2-base = "target:evobgp-bird2-base"
|
||||||
}
|
}
|
||||||
cache-from = go-cache-from()
|
cache-from = go-cache-from()
|
||||||
tags = image-tags("evobgp-agent")
|
tags = image-tags("evobgp-agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Web: npm ci (кэш) → build → nginx ---
|
# --- Web ---
|
||||||
|
|
||||||
target "web-deps" {
|
target "web-deps" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
target = "deps"
|
target = "deps"
|
||||||
platforms = ["linux/amd64"]
|
args = {
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
BASE_NGINX = BASE_NGINX
|
||||||
|
}
|
||||||
cache-from = web-cache-from()
|
cache-from = web-cache-from()
|
||||||
}
|
}
|
||||||
|
|
||||||
target "web-build" {
|
target "web-build" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
target = "build"
|
target = "build"
|
||||||
platforms = ["linux/amd64"]
|
args = {
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
BASE_NGINX = BASE_NGINX
|
||||||
|
}
|
||||||
contexts = {
|
contexts = {
|
||||||
deps = "target:web-deps"
|
deps = "target:web-deps"
|
||||||
}
|
}
|
||||||
@@ -232,34 +300,49 @@ target "web-build" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-web" {
|
target "evobgp-web" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
target = "web"
|
target = "web"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
web-artifacts = "target:web-build"
|
web-artifacts = "target:web-build"
|
||||||
}
|
}
|
||||||
args = { EVOBGP_UPSTREAM = "evobgp-api" }
|
args = {
|
||||||
|
EVOBGP_UPSTREAM = "evobgp-api"
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
BASE_NGINX = BASE_NGINX
|
||||||
|
}
|
||||||
cache-from = web-cache-from()
|
cache-from = web-cache-from()
|
||||||
tags = image-tags("evobgp-web")
|
tags = image-tags("evobgp-web")
|
||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-web-all" {
|
target "evobgp-web-all" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
target = "web"
|
target = "web"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
web-artifacts = "target:web-build"
|
web-artifacts = "target:web-build"
|
||||||
}
|
}
|
||||||
args = { EVOBGP_UPSTREAM = "evobgp-all" }
|
args = {
|
||||||
|
EVOBGP_UPSTREAM = "evobgp-all"
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
BASE_NGINX = BASE_NGINX
|
||||||
|
}
|
||||||
cache-from = web-cache-from()
|
cache-from = web-cache-from()
|
||||||
tags = image-tags("evobgp-web-all")
|
tags = image-tags("evobgp-web-all")
|
||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-bird2" {
|
target "evobgp-bird2" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/bird2/Dockerfile"
|
dockerfile = "deploy/docker/bird2/Dockerfile"
|
||||||
platforms = ["linux/amd64"]
|
target = "bird2"
|
||||||
tags = image-tags("evobgp-bird2")
|
args = {
|
||||||
|
BASE_UBUNTU = BASE_UBUNTU
|
||||||
|
}
|
||||||
|
contexts = {
|
||||||
|
bird2-base = "target:evobgp-bird2-base"
|
||||||
|
}
|
||||||
|
tags = image-tags("evobgp-bird2")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# Агент: бинарь из общего build-all (docker-bake.hcl → contexts.build-all), bird2 из apt.
|
# Агент: бинарь из bake context build-all, runtime = тот же bird2-base, что evobgp-bird2.
|
||||||
FROM public.ecr.aws/docker/library/ubuntu:noble
|
FROM bird2-base
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends bird2 ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
ARG BIN=evobgp-agent
|
ARG BIN=evobgp-agent
|
||||||
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp-agent
|
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp-agent
|
||||||
WORKDIR /etc/bird
|
WORKDIR /etc/bird
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# React + Vite статическая панель EvoBGP + nginx.
|
# React + Vite статическая панель EvoBGP + nginx.
|
||||||
# Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build).
|
# Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build).
|
||||||
# Сборка ведётся из корня репозитория (context = "../.." в docker-bake.hcl).
|
ARG BASE_NODE=docker.io/library/node:22-alpine
|
||||||
|
ARG BASE_NGINX=docker.io/library/nginx:1.27-alpine
|
||||||
|
|
||||||
FROM public.ecr.aws/docker/library/node:22-alpine AS deps
|
FROM ${BASE_NODE} AS deps
|
||||||
WORKDIR /repo
|
WORKDIR /repo
|
||||||
RUN corepack enable && corepack prepare [email protected] --activate
|
RUN corepack enable && corepack prepare [email protected] --activate
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
@@ -16,11 +17,13 @@ FROM deps AS build
|
|||||||
COPY tsconfig.base.json ./
|
COPY tsconfig.base.json ./
|
||||||
COPY apps/web/ ./apps/web/
|
COPY apps/web/ ./apps/web/
|
||||||
COPY packages/ui/ ./packages/ui/
|
COPY packages/ui/ ./packages/ui/
|
||||||
RUN pnpm --filter @evobgp/web run build
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||||
|
pnpm --filter @evobgp/web run build
|
||||||
|
|
||||||
FROM public.ecr.aws/docker/library/nginx:1.27-alpine AS web
|
FROM ${BASE_NGINX} AS web
|
||||||
ARG EVOBGP_UPSTREAM=evobgp-api
|
ARG EVOBGP_UPSTREAM=evobgp-api
|
||||||
COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf
|
COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf
|
||||||
RUN sed -e "s/evobgp-api/${EVOBGP_UPSTREAM}/g" /tmp/nginx-default.conf > /etc/nginx/conf.d/default.conf \
|
RUN sed -e "s/evobgp-api/${EVOBGP_UPSTREAM}/g" /tmp/nginx-default.conf > /etc/nginx/conf.d/default.conf \
|
||||||
&& rm -f /tmp/nginx-default.conf
|
&& rm -f /tmp/nginx-default.conf \
|
||||||
|
&& sed -i 's/^[[:space:]]*worker_processes[[:space:]]*auto;/worker_processes 1;/' /etc/nginx/nginx.conf
|
||||||
COPY --from=web-artifacts /repo/apps/web/dist /usr/share/nginx/html
|
COPY --from=web-artifacts /repo/apps/web/dist /usr/share/nginx/html
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 256;
|
||||||
|
|
||||||
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
|
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
|
||||||
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
|
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# Универсальная сборка бинарей cmd/* (ARG BIN) или всех сразу (target build-all).
|
# Универсальная сборка бинарей cmd/* (ARG BIN) или всех сразу (target build-all).
|
||||||
# INSTALL_BIRDC=1 — birdc из stage birdc (собирается один раз, переиспользуется api/all).
|
# Воркеры (runtime): distroless static. api/all (runtime-birdc): debian-slim + birdc.
|
||||||
# CI: docker buildx bake -f deploy/docker/docker-bake.hcl
|
# CI: docker buildx bake -f deploy/docker/docker-bake.hcl
|
||||||
FROM public.ecr.aws/docker/library/golang:1.24-bookworm AS deps
|
ARG BASE_GOLANG=docker.io/library/golang:1.24-alpine
|
||||||
|
ARG BASE_DEBIAN=docker.io/library/debian:bookworm-slim
|
||||||
|
ARG BASE_DISTROLESS=gcr.io/distroless/static-debian12:nonroot
|
||||||
|
|
||||||
|
FROM ${BASE_GOLANG} AS deps
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
FROM deps AS build-all
|
FROM deps AS build-all
|
||||||
COPY . .
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG GIT_SHA=unknown
|
ARG GIT_SHA=unknown
|
||||||
ARG BUILD_TIME=
|
ARG BUILD_TIME=
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
RUN --mount=type=bind,target=. \
|
||||||
|
--mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||||
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||||
set -eux; \
|
set -eux; \
|
||||||
mkdir -p /out; \
|
mkdir -p /out; \
|
||||||
@@ -29,12 +33,12 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
|||||||
|
|
||||||
# Один бинарь (локальная сборка); в CI — build-all + runtime.
|
# Один бинарь (локальная сборка); в CI — build-all + runtime.
|
||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
COPY . .
|
|
||||||
ARG BIN=evobgp-api
|
ARG BIN=evobgp-api
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG GIT_SHA=unknown
|
ARG GIT_SHA=unknown
|
||||||
ARG BUILD_TIME=
|
ARG BUILD_TIME=
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
RUN --mount=type=bind,target=. \
|
||||||
|
--mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||||
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||||
CGO_ENABLED=0 go build -trimpath \
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
-ldflags="-s -w \
|
-ldflags="-s -w \
|
||||||
@@ -43,29 +47,33 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
|||||||
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
|
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
|
||||||
-o /out/evobgp "./cmd/${BIN}"
|
-o /out/evobgp "./cmd/${BIN}"
|
||||||
|
|
||||||
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS birdc
|
FROM ${BASE_DEBIAN} AS birdc
|
||||||
ARG BIRD_VERSION=2.14
|
ARG BIRD_VERSION=2.14
|
||||||
COPY deploy/docker/bird/bird-from-source.sh /tmp/bird-from-source.sh
|
COPY deploy/docker/bird/bird-from-source.sh /tmp/bird-from-source.sh
|
||||||
RUN chmod +x /tmp/bird-from-source.sh \
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
|
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||||
|
&& chmod +x /tmp/bird-from-source.sh \
|
||||||
&& BIRD_VERSION="${BIRD_VERSION}" /tmp/bird-from-source.sh \
|
&& BIRD_VERSION="${BIRD_VERSION}" /tmp/bird-from-source.sh \
|
||||||
&& rm -f /tmp/bird-from-source.sh
|
&& rm -f /tmp/bird-from-source.sh
|
||||||
|
|
||||||
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS runtime-base
|
# scheduler / ingest / render / deploy / node — static Go, без shell.
|
||||||
RUN apt-get update \
|
FROM ${BASE_DISTROLESS} AS runtime
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
FROM runtime-base AS runtime
|
|
||||||
ARG BIN=evobgp-api
|
ARG BIN=evobgp-api
|
||||||
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
|
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
|
||||||
COPY scripts/firewall /opt/evobgp/scripts/firewall
|
|
||||||
ENV EVOBGP_FIREWALL_SCRIPTS=/opt/evobgp/scripts/firewall
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/usr/local/bin/evobgp"]
|
ENTRYPOINT ["/usr/local/bin/evobgp"]
|
||||||
|
|
||||||
FROM runtime AS runtime-birdc
|
# api / all — birdc (readline + ncurses). Только клиент birdc, без демона bird.
|
||||||
# birdc: динамическая линковка readline + ncurses (debian bookworm).
|
FROM ${BASE_DEBIAN} AS runtime-birdc
|
||||||
RUN apt-get update \
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
&& apt-get install -y --no-install-recommends libreadline8 libncurses6 \
|
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates libreadline8 libncurses6 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=birdc /usr/local/sbin/bird /usr/local/sbin/birdc /usr/local/sbin/
|
ARG BIN=evobgp-api
|
||||||
|
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
|
||||||
|
COPY --from=birdc /usr/local/sbin/birdc /usr/local/sbin/birdc
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/usr/local/bin/evobgp"]
|
||||||
|
|||||||
@@ -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
|
#!/usr/bin/env sh
|
||||||
# Writes docker-bake.override.hcl for CI (buildx without --var support).
|
# Optional helper for local bake when env-based Bake variables are inconvenient.
|
||||||
|
# CI задаёт те же имена переменных в окружении и вызывает bake без этого файла.
|
||||||
set -eu
|
set -eu
|
||||||
OUT="${1:-docker-bake.override.hcl}"
|
OUT="${1:-docker-bake.override.hcl}"
|
||||||
REGISTRY="${REGISTRY:?REGISTRY required}"
|
REGISTRY="${REGISTRY:?REGISTRY required}"
|
||||||
@@ -10,30 +11,29 @@ VERSION="${VERSION:-dev}"
|
|||||||
BUILD_TIME="${BUILD_TIME:-}"
|
BUILD_TIME="${BUILD_TIME:-}"
|
||||||
CACHE_REF_GO="${CACHE_REF_GO:-}"
|
CACHE_REF_GO="${CACHE_REF_GO:-}"
|
||||||
CACHE_REF_WEB="${CACHE_REF_WEB:-}"
|
CACHE_REF_WEB="${CACHE_REF_WEB:-}"
|
||||||
|
CACHE_REF_BIRDC="${CACHE_REF_BIRDC:-}"
|
||||||
|
BASE_GOLANG="${BASE_GOLANG:-}"
|
||||||
|
BASE_DEBIAN="${BASE_DEBIAN:-}"
|
||||||
|
BASE_DISTROLESS="${BASE_DISTROLESS:-}"
|
||||||
|
BASE_NODE="${BASE_NODE:-}"
|
||||||
|
BASE_NGINX="${BASE_NGINX:-}"
|
||||||
|
BASE_UBUNTU="${BASE_UBUNTU:-}"
|
||||||
cat >"$OUT" <<EOF
|
cat >"$OUT" <<EOF
|
||||||
# Generated by deploy/docker/write-bake-override.sh — do not commit.
|
# Generated by deploy/docker/write-bake-override.sh — do not commit.
|
||||||
variable "REGISTRY" {
|
variable "REGISTRY" { default = "${REGISTRY}" }
|
||||||
default = "${REGISTRY}"
|
variable "IMAGE_TAG" { default = "${IMAGE_TAG}" }
|
||||||
}
|
variable "SHORT_SHA" { default = "${SHORT_SHA}" }
|
||||||
variable "IMAGE_TAG" {
|
variable "SHA_FULL" { default = "${SHA_FULL}" }
|
||||||
default = "${IMAGE_TAG}"
|
variable "VERSION" { default = "${VERSION}" }
|
||||||
}
|
variable "BUILD_TIME" { default = "${BUILD_TIME}" }
|
||||||
variable "SHORT_SHA" {
|
variable "CACHE_REF_GO" { default = "${CACHE_REF_GO}" }
|
||||||
default = "${SHORT_SHA}"
|
variable "CACHE_REF_WEB" { default = "${CACHE_REF_WEB}" }
|
||||||
}
|
variable "CACHE_REF_BIRDC" { default = "${CACHE_REF_BIRDC}" }
|
||||||
variable "SHA_FULL" {
|
|
||||||
default = "${SHA_FULL}"
|
|
||||||
}
|
|
||||||
variable "VERSION" {
|
|
||||||
default = "${VERSION}"
|
|
||||||
}
|
|
||||||
variable "BUILD_TIME" {
|
|
||||||
default = "${BUILD_TIME}"
|
|
||||||
}
|
|
||||||
variable "CACHE_REF_GO" {
|
|
||||||
default = "${CACHE_REF_GO}"
|
|
||||||
}
|
|
||||||
variable "CACHE_REF_WEB" {
|
|
||||||
default = "${CACHE_REF_WEB}"
|
|
||||||
}
|
|
||||||
EOF
|
EOF
|
||||||
|
# BASE_* — только если заданы (иначе bake берёт default из docker-bake.hcl).
|
||||||
|
if [ -n "$BASE_GOLANG" ]; then printf 'variable "BASE_GOLANG" { default = "%s" }\n' "$BASE_GOLANG" >>"$OUT"; fi
|
||||||
|
if [ -n "$BASE_DEBIAN" ]; then printf 'variable "BASE_DEBIAN" { default = "%s" }\n' "$BASE_DEBIAN" >>"$OUT"; fi
|
||||||
|
if [ -n "$BASE_DISTROLESS" ]; then printf 'variable "BASE_DISTROLESS" { default = "%s" }\n' "$BASE_DISTROLESS" >>"$OUT"; fi
|
||||||
|
if [ -n "$BASE_NODE" ]; then printf 'variable "BASE_NODE" { default = "%s" }\n' "$BASE_NODE" >>"$OUT"; fi
|
||||||
|
if [ -n "$BASE_NGINX" ]; then printf 'variable "BASE_NGINX" { default = "%s" }\n' "$BASE_NGINX" >>"$OUT"; fi
|
||||||
|
if [ -n "$BASE_UBUNTU" ]; then printf 'variable "BASE_UBUNTU" { default = "%s" }\n' "$BASE_UBUNTU" >>"$OUT"; fi
|
||||||
|
|||||||
+4
-4
@@ -118,10 +118,10 @@ EvoBGP управляет генерацией и применением BGP-к
|
|||||||
|
|
||||||
| Маршрут | Назначение |
|
| Маршрут | Назначение |
|
||||||
|---------|------------|
|
|---------|------------|
|
||||||
| `/settings` | Только браузер: API-токен, тема (localStorage). Tenant KV здесь **не** редактируются. |
|
| `/settings?tab=ui` | Настройки UI: API-токен, сессия, тема. |
|
||||||
| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии**, **Файловые логи** (автоочистка FS), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. |
|
| `/settings?tab=bird` | Настройки BIRD: control plane, ревизии, файловые логи, дополнительные ключи `/v1/settings`. Старый `/tenant-settings` редиректит сюда. |
|
||||||
| `/network` → Control plane | Краткая сводка BIRD + ссылка на `/tenant-settings?tab=bird`. |
|
| `/network` → Control plane | Краткая сводка BIRD. |
|
||||||
| `/operations` | Ревизии, diff, jobs; вкладка «Система» перенесена в `/tenant-settings`. |
|
| `/operations` | Ревизии, diff, jobs. |
|
||||||
|
|
||||||
### Web UI: файловые runtime-логи
|
### Web UI: файловые runtime-логи
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
## Готовые образы без сборки (Container Registry Gitea)
|
## Готовые образы без сборки (Container Registry Gitea)
|
||||||
|
|
||||||
После успешного CI (push в `main` или `master`) образы публикуются в **Container Registry** вашего Gitea. В workflow зафиксирован хост реестра **`git.shx.one`**; имя владельца в пути образа — **в нижнем регистре**, как у `github.repository_owner` в CI (например, пользователь `Denozord` → префикс `denozord`).
|
После успешного **CD** (push в `main` или `master`, job **publish**) образы публикуются в **Container Registry** вашего Gitea. В workflow зафиксирован хост реестра **`git.shx.one`**; имя владельца в пути образа — **в нижнем регистре**, как у `github.repository_owner` в CI (например, пользователь `Denozord` → префикс `denozord`).
|
||||||
|
|
||||||
### Шаблон имени и теги
|
### Шаблон имени и теги
|
||||||
|
|
||||||
|
|||||||
+13
-12
@@ -23,21 +23,22 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
|
|||||||
|
|
||||||
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
|
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
|
||||||
|
|
||||||
## CI-пайплайн (один push в main)
|
## CI-пайплайн (push в main)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
push/merge в main
|
push/merge в main
|
||||||
→ CI: openapi, web, go, bird2 (параллельно)
|
→ workflow CD: quality (openapi, web, go, bird2)
|
||||||
→ job release (в том же workflow, после quality gates):
|
→ job publish:
|
||||||
→ semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
|
→ semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
|
||||||
→ Gitea Release + CHANGELOG.md как attachment
|
→ Gitea Release + CHANGELOG.md как attachment
|
||||||
→ docker buildx bake с VERSION=X.Y.Z
|
→ зеркало base-образов в evobgp-buildcache:base-*
|
||||||
|
→ docker buildx bake с VERSION=X.Y.Z (pull=false, named builder evobgp)
|
||||||
→ образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA
|
→ образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA
|
||||||
```
|
```
|
||||||
|
|
||||||
Pull request: только quality gates + commitlint; релиз и образы **не** публикуются.
|
Pull request: workflow **CI** — quality gates + commitlint; релиз и образы **не** публикуются.
|
||||||
|
|
||||||
Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release**).
|
Workflows: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml), [.gitea/workflows/cd.yaml](../.gitea/workflows/cd.yaml), reusable [.gitea/workflows/quality.yaml](../.gitea/workflows/quality.yaml).
|
||||||
|
|
||||||
Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий).
|
Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий).
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release
|
|||||||
| releases | Gitea Release + notes |
|
| releases | Gitea Release + notes |
|
||||||
| packages (Container Registry) | push образов |
|
| packages (Container Registry) | push образов |
|
||||||
|
|
||||||
Fallback: **`gitea.token`** (нужны права на releases и packages).
|
Fallback для **git tag**: `github.token`, если PAT недоступен. Push образов в Container Registry — **только `ACTIONS_PAT`** (у job token Gitea нет права packages).
|
||||||
|
|
||||||
## Источник правды для версии в runtime
|
## Источник правды для версии в runtime
|
||||||
|
|
||||||
@@ -75,15 +76,15 @@ Web UI показывает версию из API (footer sidebar, страни
|
|||||||
|
|
||||||
Правило: **один scope** из таблицы в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) (`web`, `httpapi`, `api`, …).
|
Правило: **один scope** из таблицы в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) (`web`, `httpapi`, `api`, …).
|
||||||
|
|
||||||
На push в `main` job **release** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
|
На push в `main` job **publish** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
|
||||||
|
|
||||||
Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `refactor(web): …`).
|
Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `refactor(web): …`).
|
||||||
|
|
||||||
## Перезапуск упавшего job release
|
## Перезапуск упавшего job publish
|
||||||
|
|
||||||
semantic-release пишет `.release-version` только в `successCmd` при **новом** релизе. Если тег `vX.Y.Z` уже создан, а `docker buildx bake` упал, повторный run того же SHA делает semantic-release no-op (файла нет). Job **release** тогда берёт версию из git-тега на `HEAD` и публикует образы.
|
semantic-release пишет `.release-version` только в `successCmd` при **новом** релизе. Если тег `vX.Y.Z` уже создан, а `docker buildx bake` упал, повторный run того же SHA делает semantic-release no-op (файла нет). Job **publish** тогда берёт версию из git-тега на `HEAD` и публикует образы.
|
||||||
|
|
||||||
Перезапускать нужно **весь job release**, не отдельный шаг bake: checkout + semantic-release + detect + bake идут подряд.
|
Перезапускать нужно **весь job publish**, не отдельный шаг bake: checkout + semantic-release + detect + bake идут подряд.
|
||||||
|
|
||||||
## CHANGELOG
|
## CHANGELOG
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ Release notes — в Gitea Release; файл `CHANGELOG.md` генерирует
|
|||||||
|
|
||||||
## Проверка после релиза
|
## Проверка после релиза
|
||||||
|
|
||||||
1. Один run workflow **CI** на push в main: job **release** зелёный.
|
1. Один run workflow **CD** на push в main: job **publish** зелёный.
|
||||||
2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes.
|
2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes.
|
||||||
3. Container Registry: `evobgp-api:vX.Y.Z`, `evobgp-api:X.Y.Z`, `evobgp-api:latest`.
|
3. Container Registry: `evobgp-api:vX.Y.Z`, `evobgp-api:X.Y.Z`, `evobgp-api:latest`.
|
||||||
4. `curl http://localhost:8080/version` → `"version":"X.Y.Z"`.
|
4. `curl http://localhost:8080/version` → `"version":"X.Y.Z"`.
|
||||||
|
|||||||
@@ -13,10 +13,12 @@
|
|||||||
"@commitlint/cli": "^19.8.1",
|
"@commitlint/cli": "^19.8.1",
|
||||||
"@commitlint/config-conventional": "^19.8.1",
|
"@commitlint/config-conventional": "^19.8.1",
|
||||||
"@markwylde/semantic-release-gitea": "^2.2.0",
|
"@markwylde/semantic-release-gitea": "^2.2.0",
|
||||||
|
"@redocly/cli": "1.34.5",
|
||||||
"@semantic-release/changelog": "^6.0.3",
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||||
"@semantic-release/exec": "^7.0.0",
|
"@semantic-release/exec": "^7.0.0",
|
||||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||||
|
"conventional-commits-parser": "^6.4.0",
|
||||||
"semantic-release": "^25.0.2"
|
"semantic-release": "^25.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ function Tabs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-muted",
|
default:
|
||||||
line: "gap-1 border-b border-border bg-transparent",
|
"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: {
|
defaultVariants: {
|
||||||
@@ -58,10 +59,12 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|||||||
<TabsPrimitive.Tab
|
<TabsPrimitive.Tab
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
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",
|
"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=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",
|
"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",
|
||||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
"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",
|
||||||
"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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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